diff --git a/.agents/skills/chronos-route-procedure/SKILL.md b/.agents/skills/chronos-route-procedure/SKILL.md index 3deb7acd..af7f7dc4 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 `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. +- 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/.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/AGENTS.md b/AGENTS.md index 77abdf24..96666259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,10 @@ ## 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/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. @@ -19,35 +21,59 @@ ## 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`. + +## 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`) -- 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`) 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 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. 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 ### 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 module layout in [apps/chronos/src/modules](apps/chronos/src/modules): one folder per feature, holding its handler files, its `_router.ts` that exports a PLAIN object literal mirroring the contract nesting (`export const doorlockRouter = { devices: { list, create, … } }`), its `schema.ts`, its feature-private `utils/`, and — when it has any — a `_module.ts` manifest. `base.router({...})` requires the full contract, so it belongs in [apps/chronos/src/router.ts](apps/chronos/src/router.ts) and never in a module file. +- A module's `_module.ts` (`satisfies Module`, the shape in [apps/chronos/src/modules/module.ts](apps/chronos/src/modules/module.ts)) declares what the feature owns beyond its routes: `jobs` for scheduled work and `notifications` for the handlers it raises. [apps/chronos/src/modules/index.ts](apps/chronos/src/modules/index.ts) is the one list of modules, and `utils/cron.ts` and the notification bootstrap both read it — so a new feature registers its own tables, jobs and notifications instead of editing four shared files. A feature with nothing to contribute (a read-only view, a health probe) simply has no `_module.ts`. +- Tables are the exception: they go in [apps/chronos/src/modules/schemas.ts](apps/chronos/src/modules/schemas.ts), not in the manifest. `src/database/index.ts` constructs `db` at module load, so that list has to be importable without dragging a module's notification handlers — and through them the engine that queries `db` — into an import cycle. +- What a notification says, who receives it and which preference gates it live in the module's `_notifications.ts`, as one `NotificationHandler` object per type. The engine in [apps/chronos/src/utils/notifications/engine.ts](apps/chronos/src/utils/notifications/engine.ts) holds no per-type bookkeeping; it only exports the audience resolvers a handler calls. +- 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 -- Change TypeScript schema sources under [apps/chronos/src/database/schema](apps/chronos/src/database/schema); never hand-edit generated SQL under migrations. +- Change TypeScript schema sources: a feature's tables live in `apps/chronos/src/modules//schema.ts`, and only the shared identity and RBAC tables stay in [apps/chronos/src/database/schema](apps/chronos/src/database/schema) (`authentication.ts`, `authorization.ts`, `api-keys.ts` — every other schema has a foreign key to them). Never hand-edit generated SQL under migrations. - Reuse shared column helpers like [apps/chronos/src/database/helpers.ts](apps/chronos/src/database/helpers.ts) instead of re-declaring timestamp columns. -- Follow the table style in [apps/chronos/src/database/schema/doorlock.ts](apps/chronos/src/database/schema/doorlock.ts): explicit foreign keys, intentional `onDelete` behavior, indexes/composite keys where relationships need them. +- Follow the table style in [apps/chronos/src/modules/doorlock/schema.ts](apps/chronos/src/modules/doorlock/schema.ts): explicit foreign keys, intentional `onDelete` behavior, indexes/composite keys where relationships need them. +- A new feature's tables are picked up by the schema glob in [apps/chronos/drizzle.config.ts](apps/chronos/drizzle.config.ts); add them to `src/modules/schemas.ts` so Drizzle's runtime schema matches. - After intentional schema changes, generate the migration with the `db:*` scripts in [apps/chronos/package.json](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. @@ -55,9 +81,19 @@ ### 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. 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. +- 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. @@ -65,13 +101,14 @@ ### 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 - 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. @@ -79,7 +116,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. @@ -88,3 +125,14 @@ - Chronos scripts and package metadata: [apps/chronos/package.json](apps/chronos/package.json) - Iris scripts and package metadata: [apps/iris/package.json](apps/iris/package.json) + + + +# This is NOT the Turborepo you know + +Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`. + +Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access. + +This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 700ffc16..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] @@ -63,7 +68,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 @@ -86,7 +91,7 @@ This guide gets you from a fresh clone to a running dev environment, then covers CI runs the same checks (without auto-fix) on every push and PR. -- **Schema changes**: edit sources under `apps/chronos/src/database/schema`, then generate and commit the migration: +- **Schema changes**: edit the owning module's `apps/chronos/src/modules//schema.ts` (the shared identity/RBAC tables stay in `apps/chronos/src/database/schema`), add the feature's tables to `apps/chronos/src/modules/schemas.ts`, then generate and commit the migration: ```bash cd apps/chronos 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/.env.example b/apps/chronos/.env.example index 33b06d25..69d02ca9 100644 --- a/apps/chronos/.env.example +++ b/apps/chronos/.env.example @@ -36,9 +36,25 @@ CHRONOS_ENTRA_CLIENT_SECRET=FILL_ME_IN # HTTP port the API binds (default: 3001) # CHRONOS_PORT=3001 -# development | production. Any non-production value makes error responses -# carry the underlying message and cause (default: development) -# CHRONOS_MODE=development +# -------------------------------------------------- +# | 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 (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 @@ -77,6 +93,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 @@ -111,6 +148,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/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/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/drizzle.config.ts b/apps/chronos/drizzle.config.ts index 9ce3df1d..c818de67 100644 --- a/apps/chronos/drizzle.config.ts +++ b/apps/chronos/drizzle.config.ts @@ -8,7 +8,9 @@ export default defineConfig({ }, dialect: 'postgresql', out: './src/database/migrations', - schema: './src/database/schema', + // The shared identity/RBAC tables, plus whatever each module owns. A glob so + // a new module's schema never has to be registered here. + schema: ['./src/database/schema/*.ts', './src/modules/*/schema.ts'], strict: true, verbose: true, diff --git a/apps/chronos/openapi/chronos-openapi.json b/apps/chronos/openapi/chronos-openapi.json new file mode 100644 index 00000000..9f7fe41f --- /dev/null +++ b/apps/chronos/openapi/chronos-openapi.json @@ -0,0 +1,56176 @@ +{ + "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": { + "/admin/api-keys/{keyId}": { + "delete": { + "operationId": "deleteAdminApiKeysByKeyId", + "description": "Revoke any user's API key", + "tags": [ + "API keys" + ], + "parameters": [ + { + "name": "keyId", + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + }, + "patch": { + "operationId": "patchAdminApiKeysByKeyId", + "description": "Enable or disable any user's API key", + "tags": [ + "API keys" + ], + "parameters": [ + { + "name": "keyId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastRequest": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ownerEmail": { + "type": "string" + }, + "ownerName": { + "type": "string" + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "referenceId": { + "type": "string" + }, + "requestCount": { + "type": "number" + }, + "start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "enabled", + "expiresAt", + "id", + "lastRequest", + "name", + "ownerEmail", + "ownerName", + "prefix", + "referenceId", + "requestCount", + "start", + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/admin/api-keys": { + "get": { + "operationId": "getAdminApiKeys", + "description": "List every user's API keys", + "tags": [ + "API keys" + ], + "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": "ownerId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + }, + "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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + "enabled": { + "type": "boolean" + }, + "expiresAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastRequest": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "x-native-type": "date" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ownerEmail": { + "type": "string" + }, + "ownerName": { + "type": "string" + }, + "prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "referenceId": { + "type": "string" + }, + "requestCount": { + "type": "number" + }, + "start": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "x-native-type": "date" + } + }, + "required": [ + "createdAt", + "enabled", + "expiresAt", + "id", + "lastRequest", + "name", + "ownerEmail", + "ownerName", + "prefix", + "referenceId", + "requestCount", + "start", + "updatedAt" + ] + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/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", + "maxLength": 64 + }, + "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", + "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" + } + } + } + } + }, + "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", + "maxLength": 200 + }, + "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": { + "items": { + "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": { + "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": [ + { + "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", + "metadata", + "page", + "reporterEmail", + "reporterId", + "status", + "subject", + "updatedAt" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": { + "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": [ + { + "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", + "metadata", + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/cohort": { + "get": { + "operationId": "getCohort", + "description": "List all cohorts", + "tags": [ + "Cohort" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroomIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "teacherId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "timetableId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroomIds", + "id", + "name", + "short", + "teacherId", + "timetableId" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getDoorlockCards", + "description": "List all access cards", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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": 32, + "maxLength": 128 + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getDoorlockDevices", + "description": "List all doorlock devices", + "tags": [ + "Doorlock" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": 32, + "maxLength": 128 + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": "object", + "properties": { + "stats": { + "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" + ] + } + } + }, + "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" + } + } + } + } + }, + "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", + "pattern": "^https:\\/\\/" + } + }, + "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", + "pattern": "^https:\\/\\/" + } + }, + "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" + } + } + } + } + }, + "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": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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", + "format": "uuid" + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/kiosk": { + "post": { + "operationId": "postKiosk", + "description": "Register a kiosk", + "tags": [ + "Kiosk" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "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" + }, + "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": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "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", + "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", + "config", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getKiosk", + "description": "List all kiosks", + "tags": [ + "Kiosk" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "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", + "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", + "config", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "updatedAt" + ] + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + } + } + } + } + }, + "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": { + "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" + }, + "kind": { + "enum": [ + "tv", + "navigator" + ], + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "appVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "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", + "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", + "config", + "createdAt", + "enabled", + "id", + "kind", + "lastSeenAt", + "lastSeenIp", + "machineId", + "name", + "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" + } + } + } + } + }, + "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": { + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "predictedDepartureTime": { + "type": "number" + }, + "routeShortDesc": { + "type": "string" + } + }, + "required": [ + "label", + "predictedDepartureTime", + "routeShortDesc" + ] + }, + { + "type": "null" + } + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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", + "config" + ] + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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": { + "items": { + "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", + "format": "uuid" + } + }, + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getNavigatorBuildings", + "description": "List the campus buildings", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "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": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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}(?:[0-9a-fA-F]{2})?$" + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "colorhex", + "name" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getNavigatorClassroomTypes", + "description": "List the classroom types", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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" + } + } + } + } + }, + "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}(?:[0-9a-fA-F]{2})?$" + }, + "name": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getNavigatorCorridors", + "description": "List the corridors", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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" + } + } + } + } + }, + "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" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "description", + "id", + "mapped", + "name", + "x", + "y" + ] + } + }, + "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" + ] + } + }, + "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" + ] + } + }, + "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" + ] + } + }, + "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" + ] + } + }, + "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": [ + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getNavigatorLifts", + "description": "List the lifts", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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 + }, + "rotation": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "building_id", + "max_storey", + "min_storey", + "name", + "rotation", + "x", + "y" + ] + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getNavigatorStairs", + "description": "List the staircases", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "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" + } + } + } + } + }, + "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 + }, + "rotation": { + "type": "number" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getNavigatorTranslations", + "description": "List every translation row", + "tags": [ + "Navigator" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + } + } + } + } + }, + "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": { + "lang_key": { + "type": "string" + }, + "text": { + "type": "string" + }, + "text_key": { + "type": "string" + } + }, + "required": [ + "lang_key", + "text", + "text_key" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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" + } + } + } + } + } + } + }, + "/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", + "format": "uuid" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "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" + ] + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "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", + "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", + "format": "uuid" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "createdAt", + "highlighted", + "id", + "imageByteSize", + "imageContentType", + "imageKey", + "imageUpdatedAt", + "kioskOnly", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds", + "kioskIds" + ] + } + }, + "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" + } + } + } + } + }, + "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", + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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", + "format": "uuid" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "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" + ] + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + }, + "kioskIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + }, + "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": { + "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", + "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", + "content", + "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" + } + } + } + } + }, + "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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "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", + "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", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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", + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + }, + "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": { + "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", + "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", + "content", + "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" + } + } + } + } + }, + "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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "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", + "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", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "createdAt", + "id", + "publishedAt", + "slug", + "status", + "title", + "updatedAt" + ] + } + }, + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + } + } + }, + "/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": { + "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", + "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", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "content", + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "content", + "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" + } + } + } + } + }, + "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", + "format": "uuid" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "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" + ] + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "authorId": { + "type": "string", + "format": "uuid" + }, + "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", + "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", + "format": "uuid" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "createdAt", + "id", + "title", + "updatedAt", + "validFrom", + "validUntil", + "cohortIds" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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", + "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" + } + } + } + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + }, + "author": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "image", + "name" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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", + "format": "uuid" + } + }, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "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" + ] + } + }, + "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": { + "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", + "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", + "format": "uuid" + } + } + }, + "required": [ + "authorId", + "content", + "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" + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/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" + } + } + } + } + } + } + }, + "/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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "can": { + "type": "array", + "items": { + "type": "string" + } + }, + "name": { + "type": "string" + } + }, + "required": [ + "can", + "name" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "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/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", + "format": "uuid" + } + }, + "deletedTeacherIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + "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" + } + } + } + } + }, + "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", + "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" + } + } + } + } + }, + "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", + "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" + ] + } + } + } + }, + "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/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" + }, + "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" + } + } + } + } + }, + "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": "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" + }, + "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": [ + "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" + } + } + } + } + }, + "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", + "format": "uuid" + }, + "name": { + "type": "string" + } + }, + "required": [ + "becomesOrphaned", + "id", + "name" + ] + } + }, + "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" + ] + } + } + } + }, + "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/timetables/valid": { + "get": { + "operationId": "getTimetableTimetablesValid", + "description": "Get all the latest valid timetables.", + "tags": [ + "Timetable" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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" + }, + "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": [ + "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" + } + } + } + } + }, + "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": "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" + }, + "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" + ] + } + }, + "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/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": "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" + }, + "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" + ] + } + }, + "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/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": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "classroomIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "short": { + "type": "string" + }, + "teacherId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "timetableId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "classroomIds", + "id", + "name", + "short", + "teacherId", + "timetableId" + ] + } + }, + "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" + } + } + } + } + }, + "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": "object", + "properties": { + "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" + } + ] + } + }, + "required": [ + "divisionLabel", + "divisionTag", + "entireClass", + "id", + "name", + "selected", + "studentCount", + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": "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" + ] + } + }, + "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/subjects": { + "get": { + "operationId": "getTimetableSubjects", + "description": "Get all subjects from the database.", + "tags": [ + "Subject" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string" + }, + "short": { + "type": "string" + } + }, + "required": [ + "firstName", + "id", + "lastName", + "short" + ] + } + }, + "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/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": "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" + } + ] + } + }, + "required": [ + "email", + "firstName", + "id", + "lastName", + "short", + "user", + "userId" + ] + } + }, + "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" + } + } + } + } + }, + "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", + "format": "uuid" + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": "object", + "properties": { + "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": { + "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" + ] + } + }, + "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/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", + "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" + }, + "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" + } + } + } + } + } + } + }, + "/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": "object", + "properties": { + "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": { + "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" + ] + } + }, + "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/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": "object", + "properties": { + "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": { + "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" + ] + } + }, + "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/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": "object", + "properties": { + "items": { + "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", + "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" + ] + } + }, + "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": [ + { + "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" + ] + } + }, + "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", + "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" + ] + } + }, + "substituteCandidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hasH1": { + "type": "boolean" + }, + "hasH2": { + "type": "boolean" + }, + "teacher": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "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" + } + } + } + } + } + } + }, + "/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", + "format": "uuid" + } + }, + "substituter": { + "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" + }, + "substituter": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + }, + "get": { + "operationId": "getTimetableSubstitutions", + "description": "Get all substitutions from the database.", + "tags": [ + "Substitution" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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", + "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" + ] + } + }, + "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" + } + ] + } + }, + "required": [ + "lessons", + "substitution", + "teacher" + ] + } + }, + "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" + } + } + } + } + }, + "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", + "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" + } + } + } + } + }, + "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", + "format": "uuid" + } + }, + { + "type": "null" + } + ] + }, + "substituter": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "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", + "format": "uuid" + }, + "substituter": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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", + "format": "uuid" + }, + "substituter": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "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" + } + } + } + } + }, + "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": "object", + "properties": { + "items": { + "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" + } + ] + } + }, + "required": [ + "lessons", + "substitution", + "teacher" + ] + } + }, + "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" + } + } + } + } + }, + "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", + "format": "uuid" + }, + "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" + } + ] + } + }, + "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" + } + } + } + } + }, + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getTimetableMovedLessons", + "description": "Get all moved lessons.", + "tags": [ + "Moved Lesson" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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": { + "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" + ] + }, + "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" + ] + } + }, + "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/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", + "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" + } + } + } + } + }, + "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": false, + "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": { + "type": "string", + "format": "uuid" + }, + "startingPeriod": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "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" + ] + } + } + } + }, + "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/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" + } + } + } + } + }, + "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": "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": { + "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" + ] + }, + "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" + ] + } + }, + "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/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": "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": { + "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" + ] + }, + "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" + ] + } + }, + "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/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": "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": { + "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" + ] + }, + "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" + ] + } + }, + "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" + } + } + } + } + } + } + }, + "/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": "role", + "in": "query", + "schema": { + "type": "string" + }, + "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": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cohortId": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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", + "format": "uuid" + }, + { + "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", + "format": "uuid" + }, + { + "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" + } + } + } + } + }, + "security": [ + { + "sessionAuth": [] + } + ] + } + }, + "/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": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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]{12}|(?:[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": { + "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]{12}|(?:[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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "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": { + "items": { + "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]{12}|(?:[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" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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]{12}|(?:[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": { + "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]{12}|(?:[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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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]{12}|(?:[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": { + "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]{12}|(?:[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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getWifiNas", + "description": "List authorized WiFi NAS devices.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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]{12}|(?:[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" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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]{12}|(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2})$" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "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]{12}|(?:[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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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" + } + } + } + } + } + } + }, + "/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": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" + } + }, + "required": [ + "priority", + "roleName", + "speedProfileId" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getWifiRoleSpeedProfiles", + "description": "List WiFi role speed-profile mappings.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" + } + }, + "required": [ + "priority", + "roleName", + "speedProfileId" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": { + "priority": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "roleName": { + "type": "string" + }, + "speedProfileId": { + "type": "string" + } + }, + "required": [ + "priority", + "roleName", + "speedProfileId" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "get": { + "operationId": "getWifiSpeedProfiles", + "description": "List UniFi WiFi speed profiles.", + "tags": [ + "WiFi" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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": { + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + }, + "/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": { + "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" + ] + } + }, + "totalDevices": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "totalUsers": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeDevices", + "authSeries", + "totalDevices", + "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" + } + } + } + } + }, + "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" + } + } + } + } + } + } + }, + "/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]{12}|(?:[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": { + "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" + } + ] + }, + "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" + ] + } + } + } + }, + "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": [] + } + ] + }, + "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": { + "items": { + "type": "array", + "items": { + "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" + } + ] + }, + "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" + ] + } + }, + "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" + } + } + } + } + }, + "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" + } + } + } + } + }, + "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]{12}|(?:[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": { + "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" + } + ] + }, + "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" + ] + } + } + } + }, + "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": [] + } + ] + } + } + } +} diff --git a/apps/chronos/package.json b/apps/chronos/package.json index 114b0079..b4bb3b7c 100644 --- a/apps/chronos/package.json +++ b/apps/chronos/package.json @@ -7,58 +7,53 @@ "db:migrate": "bun --bun drizzle-kit migrate", "db:studio": "bun --bun drizzle-kit studio", "db:seed": "bun run ./src/database/scripts/seed.ts", - "db:seed-petriktv": "bun run ./src/database/scripts/seed-petriktv.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" }, "dependencies": { - "@better-auth/drizzle-adapter": "^1.7.2", + "@better-auth/api-key": "1.7.7", + "@better-auth/drizzle-adapter": "^1.7.7", "@filcdev/api": "workspace:*", + "@filcdev/auth": "workspace:*", + "@filcdev/mail": "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", - "@sentry/bun": "^10.73.0", + "@logtape/logtape": "^2.4.0", + "@orpc/contract": "catalog:", + "@orpc/experimental-ratelimit": "catalog:", + "@orpc/openapi": "catalog:", + "@orpc/server": "catalog:", + "@orpc/zod": "catalog:", + "@sentry/bun": "^11.5.0", "better-auth": "catalog:", "cronbake": "^0.4.0", - "dayjs": "^1.11.23", + "dayjs": "catalog:", "drizzle-orm": "^0.45.2", "drizzle-zod": "^0.8.3", - "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", + "fast-xml-parser": "catalog:", + "firebase-admin": "^14.5.0", + "jose": "^6.2.12", "string-ts": "^2.3.1", "zod": "catalog:" }, "devDependencies": { - "@inquirer/prompts": "^8.7.1", - "@logtape/pretty": "^2.3.2", - "@tsconfig/strictest": "^2.0.8", + "@filcdev/tsconfig": "workspace:*", + "@inquirer/prompts": "^8.7.3", + "@logtape/pretty": "^2.4.0", + "@openapi-spec/downgrader": "^0.3.0", "@types/bun": "catalog:", "@types/cli-progress": "^3.11.6", - "@types/nodemailer": "^8.0.1", "cli-progress": "^3.12.0", - "drizzle-kit": "^0.31.10", - "pg": "^8.23.0", + "drizzle-kit": "^0.31.11", + "pg": "^8.23.1", "typescript": "catalog:" }, - "exports": { - "./types/auth": "./src/_types/auth.ts", - "./types/hc": "./src/_types/hc.ts" - }, "imports": { - "#*": [ - "./src/*.ts", - "./src/*/index.ts" - ] + "#*": "./src/*.ts", + "#database": "./src/database/index.ts", + "#modules": "./src/modules/index.ts" } } 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 new file mode 100644 index 00000000..8a8fd005 --- /dev/null +++ b/apps/chronos/scripts/generate-openapi.ts @@ -0,0 +1,28 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { getLogger } from '@logtape/logtape'; +import { configureLogger } from '#utils/logger'; +import { + buildOpenApiDocument, + serializeOpenApiDocument, +} from './openapi-document'; + +/** + * 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`). `bun run + * openapi:check` fails when this file is stale instead of writing it. + */ + +const spec = await buildOpenApiDocument(); + +await mkdir('openapi', { recursive: true }); +await writeFile('openapi/chronos-openapi.json', serializeOpenApiDocument(spec)); + +await configureLogger('chronos'); +getLogger(['chronos', 'openapi']).info( + `Wrote openapi/chronos-openapi.json (${Object.keys(spec.paths ?? {}).length} paths)` +); 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/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/_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..0169cb64 100644 --- a/apps/chronos/src/_types/globals.ts +++ b/apps/chronos/src/_types/globals.ts @@ -1,29 +1,21 @@ -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; + /** + * 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; + 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/database/index.ts b/apps/chronos/src/database/index.ts index 45b0446b..81622601 100644 --- a/apps/chronos/src/database/index.ts +++ b/apps/chronos/src/database/index.ts @@ -5,13 +5,7 @@ import { migrate } from 'drizzle-orm/bun-sql/migrator'; import { apiKeySchema } from '#database/schema/api-keys'; import { authenticationSchema } from '#database/schema/authentication'; import { authorizationSchema } from '#database/schema/authorization'; -import { bugReportSchema } from '#database/schema/bug-report'; -import { doorlockSchema } from '#database/schema/doorlock'; -import { kioskSchema } from '#database/schema/kiosk'; -import { navigatorSchema } from '#database/schema/navigator'; -import { newsSchema } from '#database/schema/news'; -import { notificationsSchema } from '#database/schema/notifications'; -import { timetableSchema } from '#database/schema/timetable'; +import { moduleSchemas } from '#modules/schemas'; import { env } from '#utils/environment'; const logger = getLogger(['chronos', 'drizzle']); @@ -39,17 +33,16 @@ if (env.mode === 'production') { client = globalConn.connection; } +/** + * The shared identity and RBAC tables, plus every module's own tables. The two + * are merged rather than concatenated so a module's tables are named once, in + * `src/modules/schemas.ts`, and never here. + */ const schema = { ...apiKeySchema, ...authenticationSchema, ...authorizationSchema, - ...bugReportSchema, - ...doorlockSchema, - ...kioskSchema, - ...navigatorSchema, - ...newsSchema, - ...notificationsSchema, - ...timetableSchema, + ...moduleSchemas, }; export const db = drizzle({ diff --git a/apps/chronos/src/database/migrations/0022_absurd_next_avengers.sql b/apps/chronos/src/database/migrations/0022_absurd_next_avengers.sql new file mode 100644 index 00000000..f7b0c5f4 --- /dev/null +++ b/apps/chronos/src/database/migrations/0022_absurd_next_avengers.sql @@ -0,0 +1,3 @@ +DROP INDEX "account_issuer_accountId_uidx";--> statement-breakpoint +ALTER TABLE "user" ALTER COLUMN "roles" SET DEFAULT '{"user"}';--> statement-breakpoint +ALTER TABLE "account" DROP COLUMN "issuer"; \ No newline at end of file diff --git a/apps/chronos/src/database/migrations/0023_last_gorgon.sql b/apps/chronos/src/database/migrations/0023_last_gorgon.sql new file mode 100644 index 00000000..5693ad30 --- /dev/null +++ b/apps/chronos/src/database/migrations/0023_last_gorgon.sql @@ -0,0 +1,29 @@ +CREATE TABLE "apikey" ( + "config_id" text DEFAULT 'default' NOT NULL, + "created_at" timestamp NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "expires_at" timestamp, + "id" uuid PRIMARY KEY DEFAULT pg_catalog.gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "last_refill_at" timestamp, + "last_request" timestamp, + "metadata" text, + "name" text, + "permissions" text, + "prefix" text, + "rate_limit_enabled" boolean DEFAULT true NOT NULL, + "rate_limit_max" integer, + "rate_limit_time_window" integer, + "refill_amount" integer, + "refill_interval" integer, + "reference_id" text NOT NULL, + "remaining" integer, + "request_count" integer DEFAULT 0 NOT NULL, + "start" text, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +DROP TABLE "api_key" CASCADE;--> statement-breakpoint +CREATE INDEX "apikey_config_id_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint +CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");--> statement-breakpoint +CREATE INDEX "apikey_reference_id_idx" ON "apikey" USING btree ("reference_id"); \ No newline at end of file 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/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/0022_snapshot.json b/apps/chronos/src/database/migrations/meta/0022_snapshot.json new file mode 100644 index 00000000..926766ec --- /dev/null +++ b/apps/chronos/src/database/migrations/meta/0022_snapshot.json @@ -0,0 +1,3823 @@ +{ + "id": "235ec9a6-ad7c-430a-920e-5674372a5659", + "prevId": "24b239dd-0c8a-4aa9-8b7a-15560e462175", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "api_key_user_id_idx": { + "name": "api_key_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_hash_unique": { + "name": "api_key_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "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 + } + }, + "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/0023_snapshot.json b/apps/chronos/src/database/migrations/meta/0023_snapshot.json new file mode 100644 index 00000000..ad71a87d --- /dev/null +++ b/apps/chronos/src/database/migrations/meta/0023_snapshot.json @@ -0,0 +1,3896 @@ +{ + "id": "1169f8b0-5106-4a1b-99c9-1b7e703e0250", + "prevId": "235ec9a6-ad7c-430a-920e-5674372a5659", + "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 + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + } + }, + "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/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/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 81be6597..ae519587 100644 --- a/apps/chronos/src/database/migrations/meta/_journal.json +++ b/apps/chronos/src/database/migrations/meta/_journal.json @@ -155,6 +155,34 @@ "when": 1789390484814, "tag": "0021_bored_darkhawk", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1791394991092, + "tag": "0022_absurd_next_avengers", + "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1791433967383, + "tag": "0023_last_gorgon", + "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "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/database/schema/api-keys.ts b/apps/chronos/src/database/schema/api-keys.ts index 3ee76457..f9b82b05 100644 --- a/apps/chronos/src/database/schema/api-keys.ts +++ b/apps/chronos/src/database/schema/api-keys.ts @@ -1,35 +1,68 @@ -import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; -import { timestamps } from '#database/helpers'; -import { user } from './authentication'; +import { sql } from 'drizzle-orm'; +import { + boolean, + index, + integer, + pgTable, + text, + timestamp, + uuid, +} from 'drizzle-orm/pg-core'; /** - * API keys let users (or the aegis doorlock) authenticate against the backend - * without a browser session cookie. The raw key is only ever returned once at - * creation time; we persist a scrypt-derived key (with a per-key random salt) - * and a short, non-secret prefix used purely for display/identification in - * listings. The `keyHash` column stores `salt:keyHex` where both halves are - * hex-encoded. + * The `apikey` table owned by better-auth's official `@better-auth/api-key` + * plugin. + * + * Better Auth writes this table itself through the Drizzle adapter, so the + * column names and types mirror the plugin's own schema declaration + * (`apiKeySchema` in `@better-auth/api-key`) rather than being derived from a + * Drizzle source of truth. Two details are load-bearing: + * + * - `key` stores the *hashed* key. The plugin hashes with SHA-256 and looks a + * key up by its hash directly, so the column is indexed and must stay unique + * enough to be the lookup path. + * - `referenceId` is the owning user (or organization) id. It replaced the old + * `userId` column in the plugin's own schema, and the owner type is decided + * by the configuration's `references` setting, not stored per row. + * + * `user` is deliberately NOT declared as a foreign key: Better Auth manages + * this table's lifecycle (including deleting expired rows on its own schedule) + * and the plugin's schema does not declare a relation. A cascade FK would make + * the plugin's own deletes fight the database's. */ -export const apiKey = pgTable( - 'api_key', +export const apikey = pgTable( + 'apikey', { + configId: text('config_id').default('default').notNull(), + createdAt: timestamp('created_at').notNull(), + enabled: boolean('enabled').default(true).notNull(), expiresAt: timestamp('expires_at'), - id: uuid('id').primaryKey().defaultRandom(), - keyHash: text('key_hash').notNull().unique(), - lastUsedAt: timestamp('last_used_at'), - name: text('name').notNull(), - prefix: text('prefix').notNull(), - userId: uuid('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - ...timestamps, + id: uuid('id').default(sql`pg_catalog.gen_random_uuid()`).primaryKey(), + key: text('key').notNull(), + lastRefillAt: timestamp('last_refill_at'), + lastRequest: timestamp('last_request'), + metadata: text('metadata'), + name: text('name'), + permissions: text('permissions'), + prefix: text('prefix'), + rateLimitEnabled: boolean('rate_limit_enabled').default(true).notNull(), + rateLimitMax: integer('rate_limit_max'), + rateLimitTimeWindow: integer('rate_limit_time_window'), + referenceId: text('reference_id').notNull(), + refillAmount: integer('refill_amount'), + refillInterval: integer('refill_interval'), + remaining: integer('remaining'), + requestCount: integer('request_count').default(0).notNull(), + start: text('start'), + updatedAt: timestamp('updated_at').notNull(), }, (t) => [ - index('api_key_user_id_idx').on(t.userId), - index('api_key_key_hash_idx').on(t.keyHash), + index('apikey_config_id_idx').on(t.configId), + index('apikey_key_idx').on(t.key), + index('apikey_reference_id_idx').on(t.referenceId), ] ); export const apiKeySchema = { - apiKey, + apikey, }; diff --git a/apps/chronos/src/database/schema/authentication.ts b/apps/chronos/src/database/schema/authentication.ts index ad9930c5..84fd71c5 100644 --- a/apps/chronos/src/database/schema/authentication.ts +++ b/apps/chronos/src/database/schema/authentication.ts @@ -5,7 +5,6 @@ import { pgTable, text, timestamp, - uniqueIndex, uuid, } from 'drizzle-orm/pg-core'; @@ -18,7 +17,7 @@ export const user = pgTable('user', { image: text('image'), name: text('name').notNull(), nickname: text('nickname'), - roles: text('roles').array().notNull(), + roles: text('roles').array().default(['user']).notNull(), updatedAt: timestamp('updated_at') .$onUpdate(() => new Date()) .notNull(), @@ -52,7 +51,6 @@ export const account = pgTable( createdAt: timestamp('created_at').notNull(), id: uuid('id').default(sql`pg_catalog.gen_random_uuid()`).primaryKey(), idToken: text('id_token'), - issuer: text('issuer').notNull(), password: text('password'), providerId: text('provider_id').notNull(), refreshToken: text('refresh_token'), @@ -65,13 +63,7 @@ export const account = pgTable( .notNull() .references(() => user.id, { onDelete: 'cascade' }), }, - (table) => [ - uniqueIndex('account_issuer_accountId_uidx').on( - table.issuer, - table.accountId - ), - index('account_userId_idx').on(table.userId), - ] + (table) => [index('account_userId_idx').on(table.userId)] ); export const verification = pgTable( diff --git a/apps/chronos/src/database/scripts/import-navigator.ts b/apps/chronos/src/database/scripts/import-navigator.ts index 15309291..643ed0e8 100644 --- a/apps/chronos/src/database/scripts/import-navigator.ts +++ b/apps/chronos/src/database/scripts/import-navigator.ts @@ -42,12 +42,12 @@ import { navigatorLift, navigatorStair, navigatorTranslation, -} from '#database/schema/navigator'; +} from '#modules/navigator/schema'; import { building as buildingTable, classroom as classroomTable, classroomType as classroomTypeTable, -} from '#database/schema/timetable'; +} from '#modules/timetable/schema'; import { configureLogger } from '#utils/logger'; await configureLogger('chronos'); diff --git a/apps/chronos/src/database/scripts/reset.ts b/apps/chronos/src/database/scripts/reset.ts index 3ce03a44..05d0716e 100644 --- a/apps/chronos/src/database/scripts/reset.ts +++ b/apps/chronos/src/database/scripts/reset.ts @@ -6,8 +6,9 @@ import { db } from '#database/index'; import { apiKeySchema } from '#database/schema/api-keys'; import { authenticationSchema } from '#database/schema/authentication'; import { authorizationSchema } from '#database/schema/authorization'; -import { doorlockSchema } from '#database/schema/doorlock'; -import { timetableSchema } from '#database/schema/timetable'; +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/database/scripts/seed-petriktv.ts b/apps/chronos/src/database/scripts/seed-petriktv.ts deleted file mode 100644 index 73406d1d..00000000 --- a/apps/chronos/src/database/scripts/seed-petriktv.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { getLogger } from '@logtape/logtape'; -import dayjs from 'dayjs'; -import { asc, eq, inArray } from 'drizzle-orm'; -import { db, prepareDb } from '#database'; -import { - classroom, - lesson, - movedLesson, - movedLessonLessonMTM, - period, - substitution, - substitutionLessonMTM, - teacher, -} from '#database/schema/timetable'; -import { env } from '#utils/environment'; -import { configureLogger } from '#utils/logger'; -import { getActiveTimetableId } from '#utils/timetable/active'; - -const ROW_COUNT = 16; -const TEST_PREFIX = 'petriktv-test'; - -const logger = getLogger(['chronos', 'petriktv-seed']); - -type CandidateLesson = { - dayDefinitionId: string; - id: string; - periodId: string; -}; - -const timeToMinutes = (value: string): number => { - const [hours, minutes] = value.split(':').map(Number); - return (hours ?? 0) * 60 + (minutes ?? 0); -}; - -const seedPetrikTv = async () => { - if (env.mode === 'production') { - throw new Error('The PetrikTV test-data seeder cannot run in production.'); - } - - const timetableId = await getActiveTimetableId(); - if (!timetableId) { - throw new Error('No active timetable found. Import a timetable first.'); - } - - const now = dayjs(); - const nowMinutes = now.hour() * 60 + now.minute(); - const today = new Date(`${now.format('YYYY-MM-DD')}T12:00:00.000Z`); - - const [teachers, classrooms, lessons] = await Promise.all([ - db.select({ id: teacher.id }).from(teacher), - db.select({ id: classroom.id }).from(classroom), - db - .select({ - dayDefinitionId: lesson.dayDefinitionId, - id: lesson.id, - periodEndTime: period.endTime, - periodId: lesson.periodId, - }) - .from(lesson) - .innerJoin(period, eq(lesson.periodId, period.id)) - .where(eq(lesson.timetableId, timetableId)) - .orderBy(asc(period.period), asc(lesson.id)), - ]); - - const upcomingLessons: CandidateLesson[] = lessons - .filter((item) => timeToMinutes(item.periodEndTime) > nowMinutes) - .map(({ dayDefinitionId, id, periodId }) => ({ - dayDefinitionId, - id, - periodId, - })); - - if (upcomingLessons.length === 0) { - throw new Error('No upcoming lessons found for the active timetable.'); - } - - if (classrooms.length === 0) { - throw new Error('No classrooms found. Moved lessons need target rooms.'); - } - - const selectedLessons = Array.from({ length: ROW_COUNT }, (_, index) => { - const selectedLesson = upcomingLessons[index % upcomingLessons.length]; - if (!selectedLesson) { - throw new Error('Failed to select an upcoming lesson.'); - } - return selectedLesson; - }); - - const substitutionIds = Array.from( - { length: ROW_COUNT }, - (_, index) => `${TEST_PREFIX}-sub-${String(index + 1).padStart(2, '0')}` - ); - const movedLessonIds = Array.from( - { length: ROW_COUNT }, - (_, index) => `${TEST_PREFIX}-moved-${String(index + 1).padStart(2, '0')}` - ); - - await db.transaction(async (tx) => { - await tx - .delete(substitutionLessonMTM) - .where(inArray(substitutionLessonMTM.substitutionId, substitutionIds)); - await tx - .delete(substitution) - .where(inArray(substitution.id, substitutionIds)); - await tx - .delete(movedLessonLessonMTM) - .where(inArray(movedLessonLessonMTM.movedLessonId, movedLessonIds)); - await tx.delete(movedLesson).where(inArray(movedLesson.id, movedLessonIds)); - - await tx.insert(substitution).values( - selectedLessons.map((_, index) => ({ - comment: 'PetrikTV test data', - date: today, - id: substitutionIds[index] as string, - substituter: - index % 8 === 7 - ? null - : (teachers[index % teachers.length]?.id ?? null), - })) - ); - - await tx.insert(substitutionLessonMTM).values( - selectedLessons.map((selectedLesson, index) => ({ - lessonId: selectedLesson.id, - substitutionId: substitutionIds[index] as string, - })) - ); - - await tx.insert(movedLesson).values( - selectedLessons.map((selectedLesson, index) => ({ - comment: 'PetrikTV test data', - date: today, - id: movedLessonIds[index] as string, - room: classrooms[index % classrooms.length]?.id ?? null, - startingDay: selectedLesson.dayDefinitionId, - startingPeriod: selectedLesson.periodId, - })) - ); - - await tx.insert(movedLessonLessonMTM).values( - selectedLessons.map((selectedLesson, index) => ({ - lessonId: selectedLesson.id, - movedLessonId: movedLessonIds[index] as string, - })) - ); - }); - - logger.info( - `Created ${ROW_COUNT} substitutions and ${ROW_COUNT} moved lessons for ${now.format('YYYY-MM-DD')}.` - ); -}; - -await configureLogger('chronos'); -await prepareDb(); -await seedPetrikTv(); diff --git a/apps/chronos/src/database/scripts/seed.ts b/apps/chronos/src/database/scripts/seed.ts index bc622326..df7546a8 100644 --- a/apps/chronos/src/database/scripts/seed.ts +++ b/apps/chronos/src/database/scripts/seed.ts @@ -9,22 +9,23 @@ import { checkbox, confirm } from '@inquirer/prompts'; import { getLogger } from '@logtape/logtape'; import { Presets, SingleBar } from 'cli-progress'; import dayjs from 'dayjs'; -import { eq, inArray } from 'drizzle-orm'; +import { asc, eq, inArray } from 'drizzle-orm'; import { db, prepareDb } from '#database/index'; import { user } from '#database/schema/authentication'; +import { timetableImportStore } from '#database/timetable-import-store'; import { auditLog, card, cardDevice, device, deviceHealth, -} from '#database/schema/doorlock'; +} from '#modules/doorlock/schema'; import { announcement, announcementCohortMtm, blogPost, systemMessage, -} from '#database/schema/news'; +} from '#modules/news/schema'; import { classroom, cohort, @@ -37,11 +38,15 @@ import { substitution, substitutionLessonMTM, teacher, -} from '#database/schema/timetable'; -import { timetableImportStore } from '#database/timetable-import-store'; +} from '#modules/timetable/schema'; +import { getActiveTimetableId } from '#modules/timetable/utils/active'; +import { env } from '#utils/environment'; import { configureLogger } from '#utils/logger'; const CANCELLATION_PROBABILITY = 0.3; +/** Rows of PetrikTV demo data: one substitution and one moved lesson each. */ +const PETRIKTV_ROW_COUNT = 16; +const PETRIKTV_TEST_PREFIX = 'petriktv-test'; const SUBSTITUTION_ROOM_PROBABILITY = 0.4; await configureLogger('chronos'); @@ -813,6 +818,147 @@ const seedNews = async (baseData: BaseData) => { logger.info('News module seeded successfully'); }; +/** Minutes since midnight, for comparing a period's end against the clock. */ +const timeToMinutes = (value: string): number => { + const [hours, minutes] = value.split(':').map(Number); + return (hours ?? 0) * 60 + (minutes ?? 0); +}; + +type PetrikTvLesson = { + dayDefinitionId: string; + id: string; + periodId: string; +}; + +/** + * Demo data for the PetrikTV kiosk screens: substitutions and moved lessons on + * today's date, derived from the lessons that have not finished yet. Re-running + * it replaces the previous batch — the rows carry a prefixed id, so nothing else + * in the database is touched. + */ +const seedPetrikTv = async () => { + if (env.mode === 'production') { + throw new Error('The PetrikTV test-data seeder cannot run in production.'); + } + + const timetableId = await getActiveTimetableId(); + if (!timetableId) { + throw new Error('No active timetable found. Import a timetable first.'); + } + + const now = dayjs(); + const nowMinutes = now.hour() * 60 + now.minute(); + const today = new Date(`${now.format('YYYY-MM-DD')}T12:00:00.000Z`); + + const [teachers, classrooms, lessons] = await Promise.all([ + db.select({ id: teacher.id }).from(teacher), + db.select({ id: classroom.id }).from(classroom), + db + .select({ + dayDefinitionId: lesson.dayDefinitionId, + id: lesson.id, + periodEndTime: period.endTime, + periodId: lesson.periodId, + }) + .from(lesson) + .innerJoin(period, eq(lesson.periodId, period.id)) + .where(eq(lesson.timetableId, timetableId)) + .orderBy(asc(period.period), asc(lesson.id)), + ]); + + const upcomingLessons: PetrikTvLesson[] = lessons + .filter((item) => timeToMinutes(item.periodEndTime) > nowMinutes) + .map(({ dayDefinitionId, id, periodId }) => ({ + dayDefinitionId, + id, + periodId, + })); + + if (upcomingLessons.length === 0) { + throw new Error('No upcoming lessons found for the active timetable.'); + } + + if (classrooms.length === 0) { + throw new Error('No classrooms found. Moved lessons need target rooms.'); + } + + const selectedLessons = Array.from( + { length: PETRIKTV_ROW_COUNT }, + (_, index) => { + const selectedLesson = upcomingLessons[index % upcomingLessons.length]; + if (!selectedLesson) { + throw new Error('Failed to select an upcoming lesson.'); + } + return selectedLesson; + } + ); + + const substitutionIds = Array.from( + { length: PETRIKTV_ROW_COUNT }, + (_, index) => + `${PETRIKTV_TEST_PREFIX}-sub-${String(index + 1).padStart(2, '0')}` + ); + const movedLessonIds = Array.from( + { length: PETRIKTV_ROW_COUNT }, + (_, index) => + `${PETRIKTV_TEST_PREFIX}-moved-${String(index + 1).padStart(2, '0')}` + ); + + await db.transaction(async (tx) => { + await tx + .delete(substitutionLessonMTM) + .where(inArray(substitutionLessonMTM.substitutionId, substitutionIds)); + await tx + .delete(substitution) + .where(inArray(substitution.id, substitutionIds)); + await tx + .delete(movedLessonLessonMTM) + .where(inArray(movedLessonLessonMTM.movedLessonId, movedLessonIds)); + await tx.delete(movedLesson).where(inArray(movedLesson.id, movedLessonIds)); + + await tx.insert(substitution).values( + selectedLessons.map((_, index) => ({ + comment: 'PetrikTV test data', + date: today, + id: substitutionIds[index] as string, + substituter: + index % 8 === 7 + ? null + : (teachers[index % teachers.length]?.id ?? null), + })) + ); + + await tx.insert(substitutionLessonMTM).values( + selectedLessons.map((selectedLesson, index) => ({ + lessonId: selectedLesson.id, + substitutionId: substitutionIds[index] as string, + })) + ); + + await tx.insert(movedLesson).values( + selectedLessons.map((selectedLesson, index) => ({ + comment: 'PetrikTV test data', + date: today, + id: movedLessonIds[index] as string, + room: classrooms[index % classrooms.length]?.id ?? null, + startingDay: selectedLesson.dayDefinitionId, + startingPeriod: selectedLesson.periodId, + })) + ); + + await tx.insert(movedLessonLessonMTM).values( + selectedLessons.map((selectedLesson, index) => ({ + lessonId: selectedLesson.id, + movedLessonId: movedLessonIds[index] as string, + })) + ); + }); + + logger.info( + `Created ${PETRIKTV_ROW_COUNT} substitutions and ${PETRIKTV_ROW_COUNT} moved lessons for ${now.format('YYYY-MM-DD')}.` + ); +}; + const handleUserCreation = async ( modules: string[], baseData: BaseData @@ -870,6 +1016,10 @@ const seed = async () => { { name: 'Substitutions and Moved Lessons', value: 'lessons' }, { name: 'Doorlock (Devices, Cards, Logs)', value: 'doorlock' }, { name: 'News (Announcements, System Messages, Blogs)', value: 'news' }, + { + name: 'PetrikTV test data (substitutions and moved lessons for the TV)', + value: 'petriktv', + }, ], message: 'Select which modules to seed:', }); @@ -878,6 +1028,7 @@ const seed = async () => { doorlock: seedDoorlock, lessons: seedLessons, news: seedNews, + petriktv: () => seedPetrikTv(), }; if (modules.length === 0) { diff --git a/apps/chronos/src/database/timetable-import-store.ts b/apps/chronos/src/database/timetable-import-store.ts index f9fe15dc..fdde994f 100644 --- a/apps/chronos/src/database/timetable-import-store.ts +++ b/apps/chronos/src/database/timetable-import-store.ts @@ -49,7 +49,7 @@ import { termDefinition, timetable as timetableTable, weekDefinition as weekTable, -} from '#database/schema/timetable'; +} from '#modules/timetable/schema'; type Database = typeof db; export type TxClient = Parameters[0]>[0]; diff --git a/apps/chronos/src/index.ts b/apps/chronos/src/index.ts index 20f7e14f..42a63f5c 100644 --- a/apps/chronos/src/index.ts +++ b/apps/chronos/src/index.ts @@ -1,40 +1,39 @@ 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 { 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 { + type DeviceSocketData, + DOORLOCK_SOCKET_PATH, + handlers as deviceSocketHandlers, + upgrade as upgradeDeviceSocket, +} from '#modules/doorlock/device-socket'; +import { handleUnsubscribe } from '#modules/notifications/unsubscribe-html'; +import { appRouter } from '#router'; +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 { + corsPlugins, + MAX_REQUEST_BODY_BYTES, + resolveClientIp, + withSecurityHeaders, +} from '#utils/security'; import { initSentry } from '#utils/telemetry'; await configureLogger('chronos'); @@ -46,146 +45,186 @@ 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 - ) - ); +await initializeNotificationEngine(); + +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. + * + * 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 ({ + context, + next, + path, +}) => { + 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:', { + context: { + clientIp: context.clientIp, + procedure: path.join('/'), + requestId: context.requestId, + userId: context.user?.id ?? context.session?.userId ?? null, + }, + 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: `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(); + 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: { clientIp, requestId, user }, + } = options; + + if (isDevelopment) { + logger.debug(`${method} ${url} - ${ms}ms`, { + duration: ms, + method, + requestId, + status, + url, + user: user ? { email: user.email, id: user.id } : null, + }); + + return result; } - logger.error('UNCAUGHT API error occurred:', { - message: err.message, - stack: err.stack, + logger.info('Received request', { + duration: ms, + ip: clientIp, + method, + requestId, + status, + ua: options.request.headers.get('user-agent') ?? 'unknown', + url, + user: user ? { email: user.email, id: user.id } : null, }); - 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 - ); + return result; +}; + +const openApiPlugin = new OpenAPIReferencePlugin({ + docsPath: '/doc/swagger', + docsProvider: 'swagger', + docsTitle: 'Chronos backend API', + schemaConverters: [new ZodToJsonSchemaConverter()], + specGenerateOptions: openApiSpecOptions, + specPath: '/doc/openapi.json', }); -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, - }); - return c.redirect('/error'); +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], +}); + +const buildContext = async ( + request: Request, + srv: Bun.Server +): Promise => ({ + ...(await resolveCaller(request.headers)), + 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(), }); -export const server = Bun.serve({ - fetch: app.fetch, +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), 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), + url.pathname + ); + } + + 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' }); + + const final = withSecurityHeaders( + matched && response + ? response + : new Response('Not found', { status: 404 }), + url.pathname + ); + final.headers.set('x-request-id', requestId); + + return final; + }, + maxRequestBodySize: MAX_REQUEST_BODY_BYTES, 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 }); -} const handleShutdown = async () => { logger.info('Shutting down chronos...'); @@ -196,6 +235,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..60d0660f 100644 --- a/apps/chronos/src/middleware/auth.ts +++ b/apps/chronos/src/middleware/auth.ts @@ -1,95 +1,137 @@ -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 { extractApiKey, validateApiKey } from '#utils/api-keys'; +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'; import { rbac, userHasPermission } from '#utils/authorization'; +import { env } from '#utils/environment'; import { setSentryUser } from '#utils/telemetry'; -type Session = typeof auth.$Infer.Session; +const logger = getLogger(['chronos', 'auth']); -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; } -); -export const requireAuthentication = createMiddleware( - async (c, next) => { - if (!c.var.session) { - throw new HTTPException(StatusCodes.UNAUTHORIZED, { - message: 'Unauthorized', - }); + 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, or an anonymous id from the signed rate-limit cookie. + * Also seeds Sentry's user context. + * + * API keys are not handled here. The `@better-auth/api-key` plugin is + * registered with `enableSessionForAPIKeys`, so a valid key in the request + * headers makes `getSession` above answer with a session for the key's owner — + * one validation per request, and the key's own rate limit is applied by the + * plugin. That also means the session is a real session row's shape, so the + * guards below need no special case. + */ +export async function resolveCaller( + headers: Headers +): Promise> { + // The api-key plugin *throws* (`Invalid API key.`, `API Key is disabled`) + // rather than answering null when a key is present but unusable, and this + // runs before every procedure — an uncaught throw here would turn any + // 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. + // + // 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); + return { anonymousId: null, session: session.session, user: 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/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/api-keys/_router.ts b/apps/chronos/src/modules/api-keys/_router.ts new file mode 100644 index 00000000..0e1a990b --- /dev/null +++ b/apps/chronos/src/modules/api-keys/_router.ts @@ -0,0 +1,11 @@ +import { + deleteAdminApiKey, + listAdminApiKeys, + updateAdminApiKey, +} from '#modules/api-keys/index'; + +export const apiKeysRouter = { + delete: deleteAdminApiKey, + list: listAdminApiKeys, + update: updateAdminApiKey, +}; diff --git a/apps/chronos/src/modules/api-keys/index.ts b/apps/chronos/src/modules/api-keys/index.ts new file mode 100644 index 00000000..1b83d572 --- /dev/null +++ b/apps/chronos/src/modules/api-keys/index.ts @@ -0,0 +1,121 @@ +import { permissions } from '@filcdev/api/permissions'; +import { and, count, desc, eq, ilike, or, type SQL, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { apikey } from '#database/schema/api-keys'; +import { user } from '#database/schema/authentication'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +/** + * Admin view over the better-auth `apikey` table. + * + * The rows belong to the plugin, so this module only ever reads them and + * flips the `enabled` flag — it never writes a key, and it never selects the + * hashed `key` column, because nothing here needs it. The join to `user` + * supplies the owner's name and email for the list. + * + * Deleting here is deliberately not delegated to better-auth's + * `deleteApiKey`, which rejects a key the calling session does not own: an + * admin revoking someone else's key is the whole point of the screen. + */ + +/** The columns the admin list renders; the hashed key is not among them. */ +const adminApiKeyColumns = { + createdAt: apikey.createdAt, + enabled: apikey.enabled, + expiresAt: apikey.expiresAt, + id: apikey.id, + lastRequest: apikey.lastRequest, + name: apikey.name, + ownerEmail: user.email, + ownerName: user.name, + prefix: apikey.prefix, + referenceId: apikey.referenceId, + requestCount: apikey.requestCount, + start: apikey.start, + updatedAt: apikey.updatedAt, +}; + +export const listAdminApiKeys = base.adminApiKeys.list + .use(requireAuthorization(permissions.usersManage)) + .handler(async ({ input }) => { + const { limit, offset, ownerId, search } = input; + + // `referenceId` is a text column and cannot be compared to a uuid + // parameter directly, so the owner filter is cast to text. + const ownerFilter = ownerId + ? sql`${apikey.referenceId} = ${ownerId}` + : undefined; + const searchFilter = search + ? or( + ilike(user.name, `%${search}%`), + ilike(user.email, `%${search}%`), + ilike(apikey.name, `%${search}%`) + ) + : undefined; + const whereClause = and(ownerFilter, searchFilter) as SQL | undefined; + + const apiKeys = await db + .select(adminApiKeyColumns) + .from(apikey) + .innerJoin(user, sql`${apikey.referenceId} = ${user.id}::text`) + .where(whereClause) + .limit(limit) + .offset(offset) + .orderBy(desc(apikey.createdAt)); + + const [totalRow] = await db + .select({ count: count() }) + .from(apikey) + .innerJoin(user, sql`${apikey.referenceId} = ${user.id}::text`) + .where(whereClause); + + return { items: apiKeys, total: totalRow?.count ?? 0 }; + }); + +export const updateAdminApiKey = base.adminApiKeys.update + .use(requireAuthorization(permissions.usersManage)) + .handler(async ({ input }) => { + const { enabled, keyId } = input; + + const [updated] = await db + .update(apikey) + .set({ enabled, updatedAt: new Date() }) + .where(eq(apikey.id, keyId)) + .returning({ id: apikey.id }); + + if (!updated) { + throw notFound('API key not found'); + } + + const [row] = await db + .select(adminApiKeyColumns) + .from(apikey) + .innerJoin(user, sql`${apikey.referenceId} = ${user.id}::text`) + .where(eq(apikey.id, keyId)) + .limit(1); + + if (!row) { + throw notFound('API key not found'); + } + + return row; + }); + +export const deleteAdminApiKey = base.adminApiKeys.delete + .use(requireAuthorization(permissions.usersManage)) + .handler(async ({ input }) => { + const { keyId } = input; + + const [deleted] = await db + .delete(apikey) + .where(eq(apikey.id, keyId)) + .returning({ id: apikey.id }); + + if (!deleted) { + throw notFound('API key not found'); + } + + return { id: deleted.id }; + }); diff --git a/apps/chronos/src/modules/bug-report/_router.ts b/apps/chronos/src/modules/bug-report/_router.ts new file mode 100644 index 00000000..b153a5d9 --- /dev/null +++ b/apps/chronos/src/modules/bug-report/_router.ts @@ -0,0 +1,13 @@ +import { + createBugReport, + deleteBugReport, + listBugReports, + updateBugReportStatus, +} from '#modules/bug-report/index'; + +export const bugReportRouter = { + create: createBugReport, + delete: deleteBugReport, + list: listBugReports, + updateStatus: updateBugReportStatus, +}; diff --git a/apps/chronos/src/modules/bug-report/index.ts b/apps/chronos/src/modules/bug-report/index.ts new file mode 100644 index 00000000..0677a7ee --- /dev/null +++ b/apps/chronos/src/modules/bug-report/index.ts @@ -0,0 +1,133 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { + and, + count, + desc, + eq, + gte, + ilike, + lte, + or, + type SQL, +} from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { bugReport } from '#modules/bug-report/schema'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +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) + .values({ + description, + metadata, + page, + reporterEmail: user?.email ?? null, + reporterId: user?.id ?? null, + subject, + }) + .returning({ id: bugReport.id }); + + if (!inserted) { + throw new ORPCError('INTERNAL', { + message: 'Failed to store bug report', + }); + } + + return { id: inserted.id }; + }); + +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)[] = []; + + if (status) { + conditions.push(eq(bugReport.status, status)); + } + if (dateFrom) { + conditions.push(gte(bugReport.createdAt, new Date(dateFrom))); + } + if (dateTo) { + conditions.push(lte(bugReport.createdAt, new Date(dateTo))); + } + if (search) { + const pattern = `%${search}%`; + conditions.push( + or( + ilike(bugReport.subject, pattern), + ilike(bugReport.description, pattern), + ilike(bugReport.reporterEmail, pattern) + ) + ); + } + + const where = conditions.length > 0 ? and(...conditions) : undefined; + const offset = (page - 1) * limit; + + const [reports, totalResult] = await Promise.all([ + db + .select() + .from(bugReport) + .where(where) + .orderBy(desc(bugReport.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(bugReport).where(where), + ]); + + return { items: reports, total: totalResult[0]?.count ?? 0 }; + }); + +export const updateBugReportStatus = base.bugReport.updateStatus + .use(requireAuthorization(permissions.bugReportsWrite)) + .handler(async ({ input }) => { + const { id, status } = input; + + const [existing] = await db + .select() + .from(bugReport) + .where(eq(bugReport.id, id)); + + if (!existing) { + throw notFound('Bug report not found'); + } + + const [updated] = await db + .update(bugReport) + .set({ status }) + .where(eq(bugReport.id, id)) + .returning(); + + if (!updated) { + throw notFound('Bug report not found'); + } + + return updated; + }); + +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({ id: bugReport.id }); + + if (!deleted) { + throw notFound('Bug report not found'); + } + + return { id: deleted.id }; + }); diff --git a/apps/chronos/src/database/schema/bug-report.ts b/apps/chronos/src/modules/bug-report/schema.ts similarity index 90% rename from apps/chronos/src/database/schema/bug-report.ts rename to apps/chronos/src/modules/bug-report/schema.ts index 91856a38..a641d669 100644 --- a/apps/chronos/src/database/schema/bug-report.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/_router.ts b/apps/chronos/src/modules/cohort/_router.ts new file mode 100644 index 00000000..a0fd2228 --- /dev/null +++ b/apps/chronos/src/modules/cohort/_router.ts @@ -0,0 +1,3 @@ +import { listCohorts } from '#modules/cohort/index'; + +export const cohortRouter = { cohort: listCohorts }; diff --git a/apps/chronos/src/modules/cohort/index.ts b/apps/chronos/src/modules/cohort/index.ts new file mode 100644 index 00000000..768d8b80 --- /dev/null +++ b/apps/chronos/src/modules/cohort/index.ts @@ -0,0 +1,9 @@ +import { db } from '#database'; +import { cohort } from '#modules/timetable/schema'; +import { base } from '#orpc'; + +export const listCohorts = base.cohort.cohort.handler(async () => { + const cohorts = await db.select().from(cohort); + + return { items: cohorts, total: cohorts.length }; +}); diff --git a/apps/chronos/src/modules/dashboard/_router.ts b/apps/chronos/src/modules/dashboard/_router.ts new file mode 100644 index 00000000..ceaec231 --- /dev/null +++ b/apps/chronos/src/modules/dashboard/_router.ts @@ -0,0 +1,3 @@ +import { getDashboardStats } from '#modules/dashboard/index'; + +export const dashboardRouter = { stats: getDashboardStats }; diff --git a/apps/chronos/src/routes/dashboard/index.ts b/apps/chronos/src/modules/dashboard/index.ts similarity index 68% rename from apps/chronos/src/routes/dashboard/index.ts rename to apps/chronos/src/modules/dashboard/index.ts index 2da98285..be80a582 100644 --- a/apps/chronos/src/routes/dashboard/index.ts +++ b/apps/chronos/src/modules/dashboard/index.ts @@ -1,45 +1,15 @@ -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 { getActiveTimetableId } from '#utils/timetable/active'; +import { cohort, movedLesson, substitution } from '#modules/timetable/schema'; +import { getActiveTimetableId } from '#modules/timetable/utils/active'; +import { base } from '#orpc'; +import { toDateOnlyString } from '#utils/date'; -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}`; -} - -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); @@ -101,14 +71,14 @@ export const getDashboardStats = dashboardFactory.createHandlers( >(); 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); @@ -118,7 +88,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 +99,6 @@ export const getDashboardStats = dashboardFactory.createHandlers( totalSubstitutions: liveSubsRow?.count ?? 0, totalUsers: usersRow?.count ?? 0, }, - }); + }; } ); diff --git a/apps/chronos/src/modules/doorlock/_module.ts b/apps/chronos/src/modules/doorlock/_module.ts new file mode 100644 index 00000000..21adcb6a --- /dev/null +++ b/apps/chronos/src/modules/doorlock/_module.ts @@ -0,0 +1,18 @@ +import { doorlockNotifications } from '#modules/doorlock/_notifications'; +import { cleanUpOldDeviceAuditLogs } from '#modules/doorlock/utils/cards'; +import type { Module } from '#modules/module'; + +/** + * The aegis door lock: its tables, the monthly audit-log trim, and the + * "your card was used" notification the device socket dispatches. + */ +export const doorlockModule = { + jobs: [ + { + callback: cleanUpOldDeviceAuditLogs, + cron: '@monthly', + name: 'clean-up-old-card-audit-logs', + }, + ], + notifications: doorlockNotifications, +} satisfies Module; diff --git a/apps/chronos/src/modules/doorlock/_notifications.ts b/apps/chronos/src/modules/doorlock/_notifications.ts new file mode 100644 index 00000000..33c6942a --- /dev/null +++ b/apps/chronos/src/modules/doorlock/_notifications.ts @@ -0,0 +1,37 @@ +import { resolveAudienceForUser } from '#utils/notifications/engine'; +import { isHungarianLocale } from '#utils/notifications/locale'; +import type { NotificationHandler } from '#utils/notifications/types'; + +/** + * The notification the door lock owns: a card was used, told to the user the + * card belongs to. Dispatched the moment the device socket reports it, so it + * carries no delay. + */ +export const doorlockNotifications: readonly NotificationHandler[] = [ + { + buildContent: (payload, locale) => { + const p = payload as { deviceName: string }; + const device = p.deviceName; + let content: string; + if (device) { + content = isHungarianLocale(locale) + ? `A belépőkártyád használatát észleltük: ${device}.` + : `Your access card was just used at ${device}.`; + } else { + content = isHungarianLocale(locale) + ? 'A belépőkártyád használatát észleltük.' + : 'Your access card was just used.'; + } + return { + content, + title: isHungarianLocale(locale) + ? 'Belépőkártya használat' + : 'Card Used', + }; + }, + getAudience: (payload) => resolveAudienceForUser(payload), + getDelay: () => 0, + preferenceKey: 'doorlockCardUsed', + type: 'doorlock_card_used', + }, +]; diff --git a/apps/chronos/src/modules/doorlock/_router.ts b/apps/chronos/src/modules/doorlock/_router.ts new file mode 100644 index 00000000..47c11e63 --- /dev/null +++ b/apps/chronos/src/modules/doorlock/_router.ts @@ -0,0 +1,55 @@ +import { + createCard, + deleteCard, + listCards, + listDoorlockUsers, + updateCard, +} from '#modules/doorlock/cards'; +import { + createDevice, + deleteDevice, + listDevices, + updateDevice, +} from '#modules/doorlock/devices'; +import { exportLogs } from '#modules/doorlock/export'; +import { listLogs } from '#modules/doorlock/logs'; +import { triggerBulkOta, triggerDeviceOta } from '#modules/doorlock/ota'; +import { + activateVirtualCard, + listSelfCards, + updateSelfCardFrozen, +} from '#modules/doorlock/self'; +import { deviceStats, doorlockStats } from '#modules/doorlock/stats'; + +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/modules/doorlock/cards.ts b/apps/chronos/src/modules/doorlock/cards.ts new file mode 100644 index 00000000..b61580b0 --- /dev/null +++ b/apps/chronos/src/modules/doorlock/cards.ts @@ -0,0 +1,154 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { eq, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { requireAuthorization } from '#middleware/auth'; +import { card } from '#modules/doorlock/schema'; +import { + type DoorlockCardWithRelations, + fetchCardById, + fetchCards, + migrateAuditLogsForNewCard, + replaceCardDevices, +} from '#modules/doorlock/utils/cards'; +import { syncDevicesByIds } from '#modules/doorlock/utils/device-sync'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +const assertCardExists = (cardRecord?: DoorlockCardWithRelations | null) => { + if (!cardRecord) { + throw notFound('Card not found'); + } + return cardRecord; +}; + +export const listCards = base.doorlock.cards.list + .use(requireAuthorization(permissions.doorlockCardsRead)) + .handler(async () => { + const cards = await fetchCards(); + + return { items: cards, total: cards.length }; + }); + +export const listDoorlockUsers = base.doorlock.cards.users + .use(requireAuthorization(permissions.doorlockCardsWrite)) + .handler(async () => { + const usersList = await db + .select({ + email: user.email, + id: user.id, + name: user.name, + nickname: user.nickname, + }) + .from(user) + .orderBy(sql`coalesce(${user.nickname}, ${user.name})`); + + return { items: usersList, total: usersList.length }; + }); + +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) + .values({ + cardData: payload.cardData, + enabled: payload.enabled, + frozen: payload.frozen, + name: payload.name, + userId: payload.userId ?? null, + }) + .returning({ id: card.id }); + + if (!inserted) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create card', + }); + } + + await replaceCardDevices(tx, inserted.id, payload.authorizedDeviceIds); + if (payload.userId) { + await migrateAuditLogsForNewCard( + tx, + inserted.id, + payload.userId, + payload.cardData + ); + } + return inserted.id; + }); + + const createdCard = assertCardExists(await fetchCardById(cardId)); + + await syncDevicesByIds( + createdCard.authorizedDevices.map( + (authorizedDevice) => authorizedDevice.id + ) + ); + + return createdCard; + }); + +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 + .update(card) + .set({ + enabled: payload.enabled, + frozen: payload.frozen, + name: payload.name, + userId: payload.userId ?? null, + }) + .where(eq(card.id, cardId)) + .returning({ id: card.id }); + + if (!updated) { + throw notFound('Card not found'); + } + + await replaceCardDevices(tx, cardId, payload.authorizedDeviceIds); + }); + + const updatedCard = assertCardExists(await fetchCardById(cardId)); + + await syncDevicesByIds( + updatedCard.authorizedDevices.map( + (authorizedDevice) => authorizedDevice.id + ) + ); + + return updatedCard; + }); + +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) { + throw notFound('Card not found'); + } + const [deleted] = await db + .delete(card) + .where(eq(card.id, cardId)) + .returning({ id: card.id }); + + if (!deleted) { + throw notFound('Card not found'); + } + + await syncDevicesByIds( + existingCard.authorizedDevices.map( + (authorizedDevice) => authorizedDevice.id + ) + ); + + return deleted; + }); diff --git a/apps/chronos/src/modules/doorlock/device-socket.ts b/apps/chronos/src/modules/doorlock/device-socket.ts new file mode 100644 index 00000000..fa2cba66 --- /dev/null +++ b/apps/chronos/src/modules/doorlock/device-socket.ts @@ -0,0 +1,287 @@ +import { getLogger } from '@logtape/logtape'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '#database'; +import { server } from '#index'; +import { + auditLog, + card, + cardDevice, + deviceHealth, + device as lockDevice, +} from '#modules/doorlock/schema'; +import { + incomingMessageSchema, + type OutgoingMessage, + outgoingMessageSchema, +} from '#modules/doorlock/utils/schemas'; +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 } +) => { + try { + logger.trace('Received raw WebSocket message', { device, message }); + const parsed = JSON.parse(message); + const result = incomingMessageSchema.safeParse(parsed); + + if (!result.success) { + logger.warn('Invalid WebSocket message format', { + device, + error: z.treeifyError(result.error), + message, + }); + return { + details: result.error, + error: 'INVALID_MESSAGE_FORMAT', + }; + } + + const deserialized = result.data; + logger.trace('Deserialized WebSocket message', { + deserialized, + device, + message, + }); + + switch (deserialized.type) { + case 'ping': { + logger.trace('Handling ping message', { deserialized, device }); + const getDeviceState = () => { + switch (deserialized.data.debug.deviceState) { + case 0: + return 'booting'; + case 1: + return 'idle'; + case 2: + return 'error'; + case 3: + return 'updating'; + default: + logger.warn('Received unexpected device status', { + deserialized, + device, + }); + return 'error'; + } + }; + + logger.trace('Persisting device health ping', { device }); + 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 tx + .update(lockDevice) + .set({ updatedAt: new Date() }) + .where(eq(lockDevice.id, device.id)); + }); + break; + } + case 'card-read': { + logger.trace('Handling card-read message', { deserialized, device }); + + const [cardUser] = await db + .select() + .from(card) + .where(eq(card.cardData, deserialized.uid)) + .limit(1); + + // 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, + }); + break; + } + default: + logger.warn('Unhandled message type received', { + deserialized, + device, + }); + } + return null; + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + logger.warn('Message handling failed', { device, error: err, message }); + return { details: err.message, error: 'MESSAGE_HANDLING_FAILED' }; + } +}; + +const normalizeName = (name: string) => + name + .trim() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + +export const sendMessage = (content: OutgoingMessage, deviceId: string) => { + const validated = outgoingMessageSchema.parse(content); + + const payload: OutgoingMessage = + validated.type === 'open-door' && validated.name + ? { ...validated, name: normalizeName(validated.name) } + : validated; + + logger.trace('Publishing WebSocket message', { deviceId, payload }); + server.publish(`device-${deviceId}`, JSON.stringify(payload)); +}; + +export const syncDatabase = async (deviceId: string) => { + logger.trace('Preparing database sync payload', { deviceId }); + const cards = await db + .select() + .from(card) + .innerJoin(cardDevice, eq(card.id, cardDevice.cardId)) + .where( + and( + eq(cardDevice.deviceId, deviceId), + eq(card.enabled, true), + eq(card.frozen, false) + ) + ); + + const database = cards.map((c) => ({ + name: normalizeName(c.card.name), + uid: c.card.cardData, + })); + + logger.trace('Sending database sync payload', { + count: database.length, + deviceId, + }); + sendMessage( + { + db: database, + type: 'sync-database', + }, + deviceId + ); +}; + +/** + * 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 { + const gotToken = request.headers.get('X-Aegis-Device-Token'); + + if (!gotToken) { + logger.warn('WebSocket connection attempt without a device token'); + return Response.json( + { message: 'Authentication required: provide X-Aegis-Device-Token' }, + { status: 401 } + ); + } + + const [device] = await db + .select() + .from(lockDevice) + .where(eq(lockDevice.apiToken, gotToken)) + .limit(1); + + if (!device) { + logger.debug('WebSocket connection attempt with an invalid device token'); + 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); +} + +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}`); + }, + + 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)); + } + }, + + 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}`); + + 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/doorlock/devices.ts b/apps/chronos/src/modules/doorlock/devices.ts new file mode 100644 index 00000000..4c111d25 --- /dev/null +++ b/apps/chronos/src/modules/doorlock/devices.ts @@ -0,0 +1,121 @@ +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 { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { device } from '#modules/doorlock/schema'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; + +const logger = getLogger(['chronos', 'doorlock', 'devices']); + +function mapDevicePayload(payload: DevicePayloadInput) { + return { + apiToken: payload.apiToken, + lastResetReason: payload.lastResetReason ?? null, + location: payload.location ?? null, + name: payload.name, + } satisfies typeof device.$inferInsert; +} + +function buildConstraintError(error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'constraint' in error && + (error as { constraint?: string }).constraint === 'device_api_token_unique' + ) { + return conflict('A device with this API token already exists.'); + } + return null; +} + +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 { items: devices, total: devices.length }; + }); + +export const createDevice = base.doorlock.devices.create + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { + try { + const [inserted] = await db + .insert(device) + .values(mapDevicePayload(input)) + .returning(); + + if (!inserted) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create device', + }); + } + + return inserted; + } catch (error) { + logger.error('Failed to create device', { error }); + const knownError = buildConstraintError(error); + if (knownError) { + throw knownError; + } + throw new ORPCError('INTERNAL', { + cause: error, + message: 'Failed to create device', + }); + } + }); + +export const updateDevice = base.doorlock.devices.update + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { + const { id, ...payload } = input; + + try { + const [updated] = await db + .update(device) + .set(mapDevicePayload(payload)) + .where(eq(device.id, id)) + .returning(); + + if (!updated) { + throw notFound('Device not found'); + } + + return updated; + } catch (error) { + logger.error('Failed to update device', { error }); + const knownError = buildConstraintError(error); + if (knownError) { + throw knownError; + } + if (error instanceof ORPCError) { + throw error; + } + throw new ORPCError('INTERNAL', { + cause: error, + message: 'Failed to update device', + }); + } + }); + +export const deleteDevice = base.doorlock.devices.delete + .use(requireAuthorization(permissions.doorlockDevicesWrite)) + .handler(async ({ input }) => { + const [deleted] = await db + .delete(device) + .where(eq(device.id, input.id)) + .returning({ id: device.id }); + + if (!deleted) { + throw notFound('Device not found'); + } + + return deleted; + }); diff --git a/apps/chronos/src/routes/doorlock/export.ts b/apps/chronos/src/modules/doorlock/export.ts similarity index 70% rename from apps/chronos/src/routes/doorlock/export.ts rename to apps/chronos/src/modules/doorlock/export.ts index 4ac9466c..2dc88e8a 100644 --- a/apps/chronos/src/routes/doorlock/export.ts +++ b/apps/chronos/src/modules/doorlock/export.ts @@ -1,17 +1,13 @@ -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 { desc, eq, gte, ilike, lte, or } from 'drizzle-orm'; 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 { auditLog, card, device } from '#modules/doorlock/schema'; +import { base } from '#orpc'; +import { andAll } from '#utils/drizzle'; const appendBaseFilters = ( filters: SQL[], @@ -65,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]/; @@ -142,30 +130,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 +164,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/modules/doorlock/logs.ts b/apps/chronos/src/modules/doorlock/logs.ts new file mode 100644 index 00000000..39ecd202 --- /dev/null +++ b/apps/chronos/src/modules/doorlock/logs.ts @@ -0,0 +1,159 @@ +import type { LogsQueryInput } from '@filcdev/api/domains/doorlock/logs'; +import { permissions } from '@filcdev/api/permissions'; +import type { SQL } 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; +type UserSummary = Pick< + typeof user.$inferSelect, + 'id' | 'name' | 'email' | 'nickname' +>; + +export type DoorlockLogEntry = typeof auditLog.$inferSelect & { + device?: DeviceSummary | null; + card?: CardSummary | null; + owner?: UserSummary | null; +}; + +const appendBaseFilters = (filters: SQL[], query: LogsQueryInput) => { + if (query.cardId) { + filters.push(eq(auditLog.cardId, query.cardId)); + } + if (query.deviceId) { + filters.push(eq(auditLog.deviceId, query.deviceId)); + } + if (query.userId) { + filters.push(eq(auditLog.userId, query.userId)); + } + if (typeof query.granted === 'boolean') { + filters.push(eq(auditLog.result, query.granted)); + } + if (query.from) { + filters.push(gte(auditLog.timestamp, new Date(query.from))); + } + if (query.to) { + filters.push(lte(auditLog.timestamp, new Date(query.to))); + } +}; + +const appendSearchFilters = (query: LogsQueryInput) => { + const { search: searchTerm } = query; + if (!searchTerm) { + return; + } + + const pattern = `%${searchTerm}%`; + + return or( + ilike(device.name, pattern), + ilike(card.name, pattern), + ilike(user.name, pattern), + ilike(user.nickname, pattern), + ilike(auditLog.cardData, pattern) + ); +}; + +const buildWhereClause = (query: LogsQueryInput): SQL | undefined => { + const filters: SQL[] = []; + + appendBaseFilters(filters, query); + const filter = appendSearchFilters(query); + if (filter) { + filters.push(filter); + } + + return andAll(filters); +}; + +const mapRowsToLogs = ( + rows: Array<{ + buttonPressed: boolean; + cardData: string | null; + cardId: string | null; + deviceId: string; + id: number; + logCardId: string | null; + logCardName: string | null; + logDeviceId: string | null; + logDeviceName: string | null; + ownerEmail: string | null; + ownerId: string | null; + ownerName: string | null; + ownerNickname: string | null; + result: boolean; + timestamp: Date; + userId: string | null; + }> +) => + rows.map((row) => ({ + buttonPressed: row.buttonPressed, + card: row.logCardId + ? { id: row.logCardId, name: row.logCardName ?? 'Unknown card' } + : null, + cardData: row.cardData, + cardId: row.cardId, + device: row.logDeviceId + ? { id: row.logDeviceId, name: row.logDeviceName ?? 'Unknown device' } + : null, + deviceId: row.deviceId, + id: row.id, + owner: row.ownerId + ? { + email: row.ownerEmail ?? 'unknown@example.com', + id: row.ownerId, + name: row.ownerName ?? 'Unknown user', + nickname: row.ownerNickname, + } + : null, + result: row.result, + timestamp: row.timestamp, + userId: row.userId, + })); + +export const listLogs = base.doorlock.logs.list + .use(requireAuthorization(permissions.doorlockLogsRead)) + .handler(async ({ input: query }) => { + 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 { items: logs, total: totalResult[0]?.count ?? 0 }; + }); diff --git a/apps/chronos/src/modules/doorlock/ota.ts b/apps/chronos/src/modules/doorlock/ota.ts new file mode 100644 index 00000000..57aadd42 --- /dev/null +++ b/apps/chronos/src/modules/doorlock/ota.ts @@ -0,0 +1,57 @@ +import { permissions } from '@filcdev/api/permissions'; +import { getLogger } from '@logtape/logtape'; +import { eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { sendMessage } from '#modules/doorlock/device-socket'; +import { device as lockDevice } from '#modules/doorlock/schema'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +const logger = getLogger(['chronos', 'doorlock', 'ota']); + +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 }) + .from(lockDevice) + .where(eq(lockDevice.id, id)) + .limit(1); + + if (!dev) { + throw notFound('Device not found'); + } + + logger.info('Triggering OTA update for device', { + device: dev, + url, + }); + + sendMessage({ type: 'update', url }, dev.id); + + return { ok: true as const }; + }); + +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 }) + .from(lockDevice); + + logger.info('Triggering bulk OTA update', { + count: devices.length, + url, + }); + + for (const dev of devices) { + sendMessage({ type: 'update', url }, dev.id); + } + + return { count: devices.length }; + }); diff --git a/apps/chronos/src/database/schema/doorlock.ts b/apps/chronos/src/modules/doorlock/schema.ts similarity index 97% rename from apps/chronos/src/database/schema/doorlock.ts rename to apps/chronos/src/modules/doorlock/schema.ts index 48e59cdc..4790347b 100644 --- a/apps/chronos/src/database/schema/doorlock.ts +++ b/apps/chronos/src/modules/doorlock/schema.ts @@ -9,7 +9,7 @@ import { uuid, } from 'drizzle-orm/pg-core'; import { timestamps } from '#database/helpers'; -import { user } from './authentication'; +import { user } from '#database/schema/authentication'; export const device = pgTable('device', { apiToken: text('api_token').notNull().unique(), diff --git a/apps/chronos/src/modules/doorlock/self.ts b/apps/chronos/src/modules/doorlock/self.ts new file mode 100644 index 00000000..4ed3eaaa --- /dev/null +++ b/apps/chronos/src/modules/doorlock/self.ts @@ -0,0 +1,131 @@ +import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; +import { and, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthentication } from '#middleware/auth'; +import { sendMessage } from '#modules/doorlock/device-socket'; +import { auditLog, card } from '#modules/doorlock/schema'; +import { fetchCardById, fetchCards } from '#modules/doorlock/utils/cards'; +import { syncDevicesByIds } from '#modules/doorlock/utils/device-sync'; +import { base } from '#orpc'; +import { badRequest, conflict, forbidden, notFound } from '#utils/http'; + +const logger = getLogger(['chronos', 'doorlock', 'self']); + +export const listSelfCards = base.doorlock.self.cards.list + .use(requireAuthentication) + .handler(async ({ context }) => { + const cards = await fetchCards(eq(card.userId, context.session.userId)); + + return { items: cards, total: cards.length }; + }); + +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 }) + .where(and(eq(card.id, cardId), eq(card.userId, context.session.userId))) + .returning({ id: card.id }); + + if (!updated) { + throw notFound('Card not found'); + } + + const updatedCard = await fetchCardById(cardId); + + if (!updatedCard || updatedCard.userId !== context.session.userId) { + logger.warn('Unexpected card fetch after self-update', { + cardId, + userId: context.session.userId, + }); + throw notFound('Card not found'); + } + + await syncDevicesByIds( + updatedCard.authorizedDevices.map( + (authorizedDevice) => authorizedDevice.id + ) + ); + + return 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 !== context.session.userId) { + throw notFound('Card not found'); + } + + if (!cardRecord.enabled || cardRecord.frozen) { + throw conflict('Card is currently inactive'); + } + + if (!cardRecord.authorizedDevices.length) { + throw conflict('Card is not authorized on any devices'); + } + + const resolveTargetDevice = () => { + if (deviceId) { + const matched = cardRecord.authorizedDevices.find( + (authorizedDevice) => authorizedDevice.id === deviceId + ); + if (!matched) { + throw forbidden('Card cannot control the requested device'); + } + return matched; + } + + if (cardRecord.authorizedDevices.length > 1) { + throw badRequest( + 'deviceId is required for cards linked to multiple devices' + ); + } + + return cardRecord.authorizedDevices[0]; + }; + + const targetDevice = resolveTargetDevice(); + + if (!targetDevice) { + throw notFound('Target device not found'); + } + + sendMessage( + { + name: + cardRecord.owner?.nickname ?? + cardRecord.owner?.name ?? + cardRecord.name, + type: 'open-door', + }, + targetDevice.id + ); + + const [logEntry] = await db + .insert(auditLog) + .values({ + buttonPressed: true, + cardData: cardRecord.cardData, + cardId: cardRecord.id, + deviceId: targetDevice.id, + result: true, + userId: context.session.userId, + }) + .returning(); + + if (!logEntry) { + throw new ORPCError('INTERNAL', { + message: 'Failed to record the door activation', + }); + } + + return logEntry; + }); diff --git a/apps/chronos/src/routes/doorlock/stats.ts b/apps/chronos/src/modules/doorlock/stats.ts similarity index 62% rename from apps/chronos/src/routes/doorlock/stats.ts rename to apps/chronos/src/modules/doorlock/stats.ts index 96dee143..cbed46a7 100644 --- a/apps/chronos/src/routes/doorlock/stats.ts +++ b/apps/chronos/src/modules/doorlock/stats.ts @@ -1,24 +1,14 @@ -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 { - auditLog, - card, - 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 { auditLog, card, device, deviceHealth } from '#modules/doorlock/schema'; +import { base } from '#orpc'; const sevenDaysAgo = () => { const date = new Date(); @@ -26,28 +16,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 +87,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 +107,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 +120,5 @@ export const deviceStatsRoute = doorlockFactory.createHandlers( }, })); - return ok(c, formattedStats); - } -); + return { stats: formattedStats }; + }); diff --git a/apps/chronos/src/utils/doorlock/cards.ts b/apps/chronos/src/modules/doorlock/utils/cards.ts similarity index 99% rename from apps/chronos/src/utils/doorlock/cards.ts rename to apps/chronos/src/modules/doorlock/utils/cards.ts index 5b99230b..d926cd6a 100644 --- a/apps/chronos/src/utils/doorlock/cards.ts +++ b/apps/chronos/src/modules/doorlock/utils/cards.ts @@ -10,7 +10,7 @@ import { cardDevice, device, deviceHealth, -} from '#database/schema/doorlock'; +} from '#modules/doorlock/schema'; const logger = getLogger(['chronos', 'doorlock', 'cards']); diff --git a/apps/chronos/src/utils/doorlock/device-sync.ts b/apps/chronos/src/modules/doorlock/utils/device-sync.ts similarity index 88% rename from apps/chronos/src/utils/doorlock/device-sync.ts rename to apps/chronos/src/modules/doorlock/utils/device-sync.ts index 843a43d8..a1301c33 100644 --- a/apps/chronos/src/utils/doorlock/device-sync.ts +++ b/apps/chronos/src/modules/doorlock/utils/device-sync.ts @@ -1,5 +1,5 @@ import { getLogger } from '@logtape/logtape'; -import { syncDatabase } from '#routes/doorlock/websocket-handler'; +import { syncDatabase } from '#modules/doorlock/device-socket'; const logger = getLogger(['chronos', 'doorlock', 'sync']); diff --git a/apps/chronos/src/utils/doorlock/schemas.ts b/apps/chronos/src/modules/doorlock/utils/schemas.ts similarity index 100% rename from apps/chronos/src/utils/doorlock/schemas.ts rename to apps/chronos/src/modules/doorlock/utils/schemas.ts diff --git a/apps/chronos/src/modules/health/_router.ts b/apps/chronos/src/modules/health/_router.ts new file mode 100644 index 00000000..f984f2fb --- /dev/null +++ b/apps/chronos/src/modules/health/_router.ts @@ -0,0 +1,3 @@ +import { health } from '#modules/health/index'; + +export const healthRouter = { health }; diff --git a/apps/chronos/src/modules/health/index.ts b/apps/chronos/src/modules/health/index.ts new file mode 100644 index 00000000..37328213 --- /dev/null +++ b/apps/chronos/src/modules/health/index.ts @@ -0,0 +1,14 @@ +import { sql } from 'drizzle-orm'; +import { db } from '#database'; +import { base } from '#orpc'; +import { serviceUnavailable } from '#utils/http'; + +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/modules/index.ts b/apps/chronos/src/modules/index.ts new file mode 100644 index 00000000..bb1c1e48 --- /dev/null +++ b/apps/chronos/src/modules/index.ts @@ -0,0 +1,27 @@ +import { doorlockModule } from '#modules/doorlock/_module'; +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. + * + * This is the one list a new feature adds itself to for behaviour. The routers + * deliberately are not here: `src/router.ts` spells each feature out in a + * literal so `base.router({...})` still fails to compile when a procedure is + * missing, extra or mistyped, which no dynamically assembled list could do. + * Tables live in `src/modules/schemas.ts` instead, which the database can + * import without dragging runtime behaviour in with it. + * + * A feature with nothing to contribute (a read-only view, a health probe) + * simply has no `_module.ts` and does not appear below. + */ +export const modules: readonly Module[] = [ + doorlockModule, + newsModule, + notificationsModule, + timetableModule, + wifiModule, +]; diff --git a/apps/chronos/src/modules/kiosk/_router.ts b/apps/chronos/src/modules/kiosk/_router.ts new file mode 100644 index 00000000..e3960bd6 --- /dev/null +++ b/apps/chronos/src/modules/kiosk/_router.ts @@ -0,0 +1,24 @@ +import { departures } from '#modules/kiosk/departures'; +import { heartbeat } from '#modules/kiosk/heartbeat'; +import { + createKiosk, + deleteKiosk, + listKiosks, + updateKiosk, +} from '#modules/kiosk/index'; +import { listNews } from '#modules/kiosk/news'; +import { newsImage } from '#modules/kiosk/news-image'; +import { petrikNews } from '#modules/kiosk/petrik-news'; +import { weather } from '#modules/kiosk/weather'; + +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/modules/kiosk/departures.ts similarity index 66% rename from apps/chronos/src/routes/kiosk/departures.ts rename to apps/chronos/src/modules/kiosk/departures.ts index d6403ab5..bd8d443d 100644 --- a/apps/chronos/src/routes/kiosk/departures.ts +++ b/apps/chronos/src/modules/kiosk/departures.ts @@ -1,12 +1,9 @@ -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 { getLogger } from '@logtape/logtape'; +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 logger = getLogger(['chronos', 'kiosk', 'departures']); const BKK_URL = 'https://futar.bkk.hu/api/query/v1/ws/otp/api/where/arrivals-and-departures-for-stop'; @@ -39,10 +36,6 @@ const departuresCache = new Map< { expiresAt: number; value: Departure | null } >(); -const { schema: departuresRequestSchema } = await resolver( - kioskDeparturesRequestSchema -).toOpenAPISchema(); - type BkkReferences = NonNullable['references']; const toDeparture = ( @@ -129,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; } } @@ -155,61 +152,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 { items: next, total: next.length }; +}); diff --git a/apps/chronos/src/modules/kiosk/heartbeat.ts b/apps/chronos/src/modules/kiosk/heartbeat.ts new file mode 100644 index 00000000..b4087dd3 --- /dev/null +++ b/apps/chronos/src/modules/kiosk/heartbeat.ts @@ -0,0 +1,55 @@ +import { kioskKindSchema } from '@filcdev/api/domains/kiosk/config'; +import { eq } from 'drizzle-orm'; +import { db } from '#database'; +import { kiosk } from '#modules/kiosk/schema'; +import { base } from '#orpc'; + +export const heartbeat = base.kiosk.heartbeat.handler( + async ({ context, input }) => { + const { appVersion, machineId } = input; + + const [existing] = await db + .select() + .from(kiosk) + .where(eq(kiosk.machineId, machineId)); + + if (!existing) { + return { registered: false }; + } + + const forwardedFor = context.reqHeaders.get('x-forwarded-for'); + const lastSeenIp = forwardedFor?.split(',')[0]?.trim() || context.clientIp; + + await db + .update(kiosk) + .set({ + appVersion, + lastSeenAt: new Date(), + ...(lastSeenIp ? { lastSeenIp } : {}), + }) + .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 { + enabled: false as const, + kiosk: { id: existing.id, kind, name: existing.name }, + registered: true as const, + }; + } + + return { + enabled: true as const, + kiosk: { + config: existing.config, + id: existing.id, + kind, + name: existing.name, + }, + registered: true as const, + }; + } +); diff --git a/apps/chronos/src/modules/kiosk/index.ts b/apps/chronos/src/modules/kiosk/index.ts new file mode 100644 index 00000000..6ac8f836 --- /dev/null +++ b/apps/chronos/src/modules/kiosk/index.ts @@ -0,0 +1,146 @@ +import { + type KioskKind, + navigatorKioskConfigSchema, + tvKioskConfigSchema, +} from '@filcdev/api/domains/kiosk/config'; +import { permissions } from '@filcdev/api/permissions'; +import { getLogger } from '@logtape/logtape'; +import { asc, eq } from 'drizzle-orm'; +import { db } from '#database'; +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'; + +/** + * 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 + * empty one: the navigator schema fills in its defaults, while a tv kiosk must + * at least carry a `departures` array. + */ +function parseKioskConfig(kind: KioskKind, config: unknown) { + const schema = + kind === 'tv' ? tvKioskConfigSchema : navigatorKioskConfigSchema; + const result = schema.safeParse(config ?? {}); + + if (!result.success) { + throw badRequest(`Invalid ${kind} kiosk config`, result.error); + } + + return result.data; +} + +/** + * `machine_id` is the kiosk table's only unique constraint, so either its name + * or the SQLSTATE identifies the conflict. + */ +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 { items: kiosks, total: kiosks.length }; + }); + +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 { + [inserted] = await db + .insert(kiosk) + .values({ + config, + enabled: input.enabled ?? true, + kind: input.kind, + machineId: input.machineId, + name: input.name, + }) + .returning(); + } catch (error) { + if (isMachineIdConflict(error)) { + throw conflict('A kiosk with this machine id already exists.'); + } + logger.error('Failed to create kiosk', { error }); + throw errors.INTERNAL({ + cause: error, + message: 'Failed to create kiosk', + }); + } + + if (!inserted) { + throw errors.INTERNAL({ message: 'Failed to create kiosk' }); + } + + return 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)); + + if (!existing) { + throw notFound('Kiosk not found'); + } + + const effectiveKind = payload.kind ?? (existing.kind as KioskKind); + const changes = { + ...(payload.config === undefined + ? {} + : { config: parseKioskConfig(effectiveKind, payload.config) }), + ...(payload.enabled === undefined ? {} : { enabled: payload.enabled }), + ...(payload.kind === undefined ? {} : { kind: payload.kind }), + ...(payload.name === undefined ? {} : { name: payload.name }), + }; + + if (Object.keys(changes).length === 0) { + return existing; + } + + const [updated] = await db + .update(kiosk) + .set(changes) + .where(eq(kiosk.id, id)) + .returning(); + + if (!updated) { + throw notFound('Kiosk not found'); + } + + return updated; + }); + +export const deleteKiosk = base.kiosk.delete + .use(requireAuthorization(permissions.kiosksManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [deleted] = await db + .delete(kiosk) + .where(eq(kiosk.id, id)) + .returning(); + + if (!deleted) { + throw notFound('Kiosk not found'); + } + + return { id: deleted.id }; + }); diff --git a/apps/chronos/src/modules/kiosk/news-image.ts b/apps/chronos/src/modules/kiosk/news-image.ts new file mode 100644 index 00000000..ebbe454d --- /dev/null +++ b/apps/chronos/src/modules/kiosk/news-image.ts @@ -0,0 +1,40 @@ +import { eq } from 'drizzle-orm'; +import { db } from '#database'; +import { announcement } from '#modules/news/schema'; +import { base } from '#orpc'; +import { notFound, serviceUnavailable } from '#utils/http'; +import { getObjectFile, isObjectStorageConfigured } from '#utils/storage/s3'; + +export const newsImage = base.kiosk.news.image.handler( + async ({ context, input }) => { + const { id } = input; + + const [item] = await db + .select({ + imageContentType: announcement.imageContentType, + imageKey: announcement.imageKey, + }) + .from(announcement) + .where(eq(announcement.id, id)); + + if (!(item?.imageKey && item.imageContentType)) { + throw notFound('Image not found'); + } + + if (!isObjectStorageConfigured()) { + 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. + 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/modules/kiosk/news.ts b/apps/chronos/src/modules/kiosk/news.ts new file mode 100644 index 00000000..724b3ea4 --- /dev/null +++ b/apps/chronos/src/modules/kiosk/news.ts @@ -0,0 +1,76 @@ +import { and, asc, isNotNull, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { + announcement, + announcementCohortMtm, + announcementKioskMtm, +} from '#modules/news/schema'; +import { + activeAnnouncementConditions, + flattenAnnouncementContent, +} from '#modules/news/utils/announcements'; +import { base } from '#orpc'; + +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 ( + SELECT 1 FROM ${announcementCohortMtm} + WHERE ${announcementCohortMtm.announcementId} = ${announcement.id} + )`, + ...activeAnnouncementConditions(now) + ) + ) + .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}`) + : []; + + // 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 + ); + + 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/routes/kiosk/petrik-news.ts b/apps/chronos/src/modules/kiosk/petrik-news.ts similarity index 52% rename from apps/chronos/src/routes/kiosk/petrik-news.ts rename to apps/chronos/src/modules/kiosk/petrik-news.ts index 6c07cf2d..4ec7ff2e 100644 --- a/apps/chronos/src/routes/kiosk/petrik-news.ts +++ b/apps/chronos/src/modules/kiosk/petrik-news.ts @@ -3,34 +3,28 @@ import { DEFAULT_PETRIK_NEWS_MAX_ITEMS, navigatorKioskConfigSchema, } from '@filcdev/api/domains/kiosk/config'; -import { zValidator } from '@hono/zod-validator'; +import { getLogger } from '@logtape/logtape'; +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 { assertAllowedFeedUrl } from '#utils/kiosk/feed-url'; +import { kiosk } from '#modules/kiosk/schema'; +import { fetchAllowedFeedUrl } from '#modules/kiosk/utils/feed-url'; import { type PetrikNewsItem, parseFeaturedImages, parsePetrikNewsFeed, stripTrailingSlash, -} from '#utils/kiosk/petrik-news'; -import { kioskPetrikNewsResponseSchema } from '#utils/kiosk/schemas'; -import { filcExt } from '#utils/openapi'; -import { kioskFactory } from './_factory'; +} from '#modules/kiosk/utils/petrik-news'; +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; 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, @@ -81,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 @@ -95,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), @@ -144,89 +139,69 @@ 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); + + if (!feed.enabled) { + 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()) { + const items = cached.items.slice(0, feed.maxItems); + return { items, total: items.length }; + } + + let xml: string; + try { + 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), + }); + + if (!response.ok) { + throw new Error(`petrik.hu responded with ${response.status}`); } - // 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) }); + xml = await readCappedText(response, MAX_FEED_BYTES); + } catch (error) { + if (error instanceof ORPCError) { + throw error; } + throw badGateway('Failed to fetch petrik.hu news', error); + } - await assertAllowedFeedUrl(feed.feedUrl); + const items = parsePetrikNewsFeed(xml); - let xml: string; + // 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 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 featured = await fetchFeaturedImages(feed.feedUrl); + for (const item of items) { + item.imageUrl = + featured.get(stripTrailingSlash(item.url)) ?? item.imageUrl; } - - 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', - }); - } - - 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. - } + // 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 }); } + } - feedCache.set(feed.feedUrl, { - expiresAt: Date.now() + PETRIK_NEWS_CACHE_TTL_MS, - items, - }); + feedCache.set(feed.feedUrl, { + expiresAt: Date.now() + PETRIK_NEWS_CACHE_TTL_MS, + items, + }); - return ok(c, { 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/database/schema/kiosk.ts b/apps/chronos/src/modules/kiosk/schema.ts similarity index 88% rename from apps/chronos/src/database/schema/kiosk.ts rename to apps/chronos/src/modules/kiosk/schema.ts index 83320d50..1af90f94 100644 --- a/apps/chronos/src/database/schema/kiosk.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/utils/kiosk/feed-url.ts b/apps/chronos/src/modules/kiosk/utils/feed-url.ts similarity index 81% rename from apps/chronos/src/utils/kiosk/feed-url.ts rename to apps/chronos/src/modules/kiosk/utils/feed-url.ts index 3a58aced..29ce24a2 100644 --- a/apps/chronos/src/utils/kiosk/feed-url.ts +++ b/apps/chronos/src/modules/kiosk/utils/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,17 +225,22 @@ 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 - * 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); @@ -273,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/utils/kiosk/petrik-news.ts b/apps/chronos/src/modules/kiosk/utils/petrik-news.ts similarity index 95% rename from apps/chronos/src/utils/kiosk/petrik-news.ts rename to apps/chronos/src/modules/kiosk/utils/petrik-news.ts index dbc5a466..af93bfda 100644 --- a/apps/chronos/src/utils/kiosk/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/kiosk/weather.ts b/apps/chronos/src/modules/kiosk/weather.ts new file mode 100644 index 00000000..45e1fac6 --- /dev/null +++ b/apps/chronos/src/modules/kiosk/weather.ts @@ -0,0 +1,69 @@ +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { badGateway } from '#utils/http'; + +const WEATHER_URL = 'https://api.weatherapi.com/v1/current.json'; +const WEATHER_CACHE_TTL_MS = 10 * 60 * 1000; +const WEATHER_TIMEOUT_MS = 10_000; + +type WeatherResponse = { + current: { + condition: { icon: string }; + precip_mm: number; + temp_c: number; + wind_kph: number; + }; +}; + +type KioskWeather = { + icon: string; + precipMm: number; + tempC: number; + windKph: number; +}; + +let cachedWeather: { expiresAt: number; value: KioskWeather } | null = null; + +export const weather = base.kiosk.weather.handler(async () => { + if (!env.weatherApiKey) { + throw badGateway('Weather API key is not configured'); + } + + if (cachedWeather && cachedWeather.expiresAt > Date.now()) { + return cachedWeather.value; + } + + const url = new URL(WEATHER_URL); + url.searchParams.set('key', env.weatherApiKey); + url.searchParams.set('q', env.kioskWeatherLocation); + url.searchParams.set('lang', 'hu'); + + let payload: WeatherResponse; + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Weather API responded with ${response.status}`); + } + + payload = (await response.json()) as WeatherResponse; + } catch (error) { + throw badGateway('Failed to fetch weather', error); + } + + const value = { + icon: payload.current.condition.icon, + precipMm: payload.current.precip_mm, + tempC: Math.round(payload.current.temp_c), + windKph: payload.current.wind_kph, + }; + + cachedWeather = { + expiresAt: Date.now() + WEATHER_CACHE_TTL_MS, + value, + }; + + return value; +}); diff --git a/apps/chronos/src/modules/module.ts b/apps/chronos/src/modules/module.ts new file mode 100644 index 00000000..b656e519 --- /dev/null +++ b/apps/chronos/src/modules/module.ts @@ -0,0 +1,36 @@ +import type { NotificationHandler } from '#utils/notifications/types'; + +/** + * A cron entry a module contributes to the shared baker. Matches the shape + * `baker.add` takes, narrowed to the three fields a scheduled job needs. + */ +export type ModuleJob = { + callback: () => Promise | void; + cron: string; + name: string; +}; + +/** + * What one module contributes to the application, beyond its `_router.ts`. + * + * The router is deliberately *not* here: `src/router.ts` keeps spelling every + * feature out in a literal so `base.router({...})` fails to compile when a + * procedure is missing, extra or mistyped. Everything below that has no such + * static guarantee is declared by the module and collected here, so adding a + * feature never means editing `utils/cron.ts` or the notification engine by + * hand. + * + * Tables are not declared here either: `src/modules/schemas.ts` holds those, + * because `src/database/index.ts` constructs `db` at module load and must not + * pull a module's notification handlers — and through them the engine that + * queries `db` — into an import cycle. + * + * Both fields are optional. A feature with no scheduled work and no + * notifications contributes nothing and simply has no `_module.ts`. + */ +export type Module = { + /** Scheduled work, registered on the shared cronbake baker at boot. */ + jobs?: readonly ModuleJob[]; + /** Notification handlers, registered on the engine at boot. */ + notifications?: readonly NotificationHandler[]; +}; diff --git a/apps/chronos/src/modules/navigator/_router.ts b/apps/chronos/src/modules/navigator/_router.ts new file mode 100644 index 00000000..c74bb9c3 --- /dev/null +++ b/apps/chronos/src/modules/navigator/_router.ts @@ -0,0 +1,99 @@ +import { + createBuildingRoute, + deleteBuildingRoute, + listBuildingsRoute, + updateBuildingRoute, +} from '#modules/navigator/buildings'; +import { + createClassroomTypeRoute, + deleteClassroomTypeRoute, + listClassroomTypesRoute, + updateClassroomTypeRoute, +} from '#modules/navigator/classroom-types'; +import { + createClassroomRoute, + deleteClassroomRoute, + listClassroomsRoute, + updateClassroomRoute, +} from '#modules/navigator/classrooms'; +import { + createCorridorRoute, + deleteCorridorRoute, + listCorridorsRoute, + updateCorridorRoute, +} from '#modules/navigator/corridors'; +import { graphRoute } from '#modules/navigator/graph'; +import { + createLiftRoute, + deleteLiftRoute, + listLiftsRoute, + updateLiftRoute, +} from '#modules/navigator/lifts'; +import { + createStairRoute, + deleteStairRoute, + listStairsRoute, + updateStairRoute, +} from '#modules/navigator/stairs'; +import { + exportNavigatorRoute, + importNavigatorRoute, +} from '#modules/navigator/transfer'; +import { + createTranslationRoute, + deleteTranslationRoute, + getTranslationBundleRoute, + listTranslationLanguagesRoute, + listTranslationsRoute, + updateTranslationRoute, +} from '#modules/navigator/translations'; + +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/modules/navigator/buildings.ts b/apps/chronos/src/modules/navigator/buildings.ts new file mode 100644 index 00000000..b5c2b984 --- /dev/null +++ b/apps/chronos/src/modules/navigator/buildings.ts @@ -0,0 +1,88 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { asc, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { isReferencedRowError } from '#modules/navigator/utils/errors'; +import { building as buildingTable } from '#modules/timetable/schema'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; + +export const listBuildingsRoute = base.navigator.buildings.list.handler( + async () => { + const buildings = await db + .select() + .from(buildingTable) + .orderBy(asc(buildingTable.name)); + + return { items: buildings, total: buildings.length }; + } +); + +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, ...input }) + .returning(); + + if (!building) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create building', + }); + } + + 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 + // placing the building, so it is mapped from then on. + const [building] = + Object.keys(payload).length > 0 + ? await db + .update(buildingTable) + .set({ ...payload, mapped: true }) + .where(eq(buildingTable.id, id)) + .returning() + : await db.select().from(buildingTable).where(eq(buildingTable.id, id)); + + if (!building) { + throw notFound('Building not found'); + } + + return building; + }); + +export const deleteBuildingRoute = base.navigator.buildings.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; + + try { + const [building] = await db + .delete(buildingTable) + .where(eq(buildingTable.id, id)) + .returning(); + + if (!building) { + throw notFound('Building not found'); + } + + 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 + // concurrent insert. + if (isReferencedRowError(error)) { + throw conflict('Building still has rooms; delete them first', error); + } + throw error; + } + }); diff --git a/apps/chronos/src/modules/navigator/classroom-types.ts b/apps/chronos/src/modules/navigator/classroom-types.ts new file mode 100644 index 00000000..a585b42b --- /dev/null +++ b/apps/chronos/src/modules/navigator/classroom-types.ts @@ -0,0 +1,91 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { asc, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { isReferencedRowError } from '#modules/navigator/utils/errors'; +import { classroomType as classroomTypeTable } from '#modules/timetable/schema'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; + +export const listClassroomTypesRoute = + base.navigator.classroomTypes.list.handler(async () => { + const classroomTypes = await db + .select() + .from(classroomTypeTable) + .orderBy(asc(classroomTypeTable.name)); + + return { items: classroomTypes, total: classroomTypes.length }; + }); + +export const createClassroomTypeRoute = base.navigator.classroomTypes.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const [classroomType] = await db + .insert(classroomTypeTable) + .values({ id: crypto.randomUUID(), ...input }) + .returning(); + + if (!classroomType) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create classroom type', + }); + } + + return classroomType; + }); + +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. + const [classroomType] = + Object.keys(payload).length > 0 + ? await db + .update(classroomTypeTable) + .set(payload) + .where(eq(classroomTypeTable.id, id)) + .returning() + : await db + .select() + .from(classroomTypeTable) + .where(eq(classroomTypeTable.id, id)); + + if (!classroomType) { + throw notFound('Classroom type not found'); + } + + return classroomType; + }); + +export const deleteClassroomTypeRoute = base.navigator.classroomTypes.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; + + try { + const [classroomType] = await db + .delete(classroomTypeTable) + .where(eq(classroomTypeTable.id, id)) + .returning(); + + if (!classroomType) { + throw notFound('Classroom type not found'); + } + + return { id }; + } catch (error) { + // `classroom.type_id` restricts the delete, so the FK error is what says + // a classroom still uses this type. + if (isReferencedRowError(error)) { + throw conflict( + 'Classrooms still use this type; reassign them first', + error + ); + } + throw error; + } + }); diff --git a/apps/chronos/src/modules/navigator/classrooms.ts b/apps/chronos/src/modules/navigator/classrooms.ts new file mode 100644 index 00000000..997be283 --- /dev/null +++ b/apps/chronos/src/modules/navigator/classrooms.ts @@ -0,0 +1,116 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { asc, eq, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { + classroom as classroomTable, + cohort, + lesson, + movedLesson, +} from '#modules/timetable/schema'; +import { base } from '#orpc'; +import { conflict, notFound } from '#utils/http'; + +export const listClassroomsRoute = base.navigator.classrooms.list.handler( + async () => { + const classrooms = await db + .select() + .from(classroomTable) + .orderBy(asc(classroomTable.name)); + + return { items: classrooms, total: classrooms.length }; + } +); + +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, ...input }) + .returning(); + + if (!classroom) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create classroom', + }); + } + + return classroom; + }); + +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 + // placing the room, so it is mapped from then on. + const [classroom] = + Object.keys(payload).length > 0 + ? await db + .update(classroomTable) + .set({ ...payload, mapped: true }) + .where(eq(classroomTable.id, id)) + .returning() + : await db + .select() + .from(classroomTable) + .where(eq(classroomTable.id, id)); + + if (!classroom) { + throw notFound('Classroom not found'); + } + + return classroom; + }); + +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, + // naming the table that blocks it. + const [moved, scheduled, registered] = await Promise.all([ + db + .select({ id: movedLesson.id }) + .from(movedLesson) + .where(eq(movedLesson.room, id)) + .limit(1), + db + .select({ id: lesson.id }) + .from(lesson) + .where(sql`${id} = ANY(${lesson.classroomIds})`) + .limit(1), + db + .select({ id: cohort.id }) + .from(cohort) + .where(sql`${id} = ANY(${cohort.classroomIds})`) + .limit(1), + ]); + + if (moved.length > 0) { + throw conflict('A moved lesson points at this room'); + } + if (scheduled.length > 0) { + throw conflict('Lessons are scheduled in this room'); + } + if (registered.length > 0) { + throw conflict('A class is registered to this room'); + } + + const [classroom] = await db + .delete(classroomTable) + .where(eq(classroomTable.id, id)) + .returning(); + + if (!classroom) { + throw notFound('Classroom not found'); + } + + return { id }; + }); diff --git a/apps/chronos/src/modules/navigator/corridors.ts b/apps/chronos/src/modules/navigator/corridors.ts new file mode 100644 index 00000000..eb4c9bc2 --- /dev/null +++ b/apps/chronos/src/modules/navigator/corridors.ts @@ -0,0 +1,79 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { asc, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { navigatorCorridor } from '#modules/navigator/schema'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +export const listCorridorsRoute = base.navigator.corridors.list.handler( + async () => { + const corridors = await db + .select() + .from(navigatorCorridor) + .orderBy(asc(navigatorCorridor.name)); + + return { items: corridors, total: corridors.length }; + } +); + +export const createCorridorRoute = base.navigator.corridors.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const [corridor] = await db + .insert(navigatorCorridor) + .values({ id: crypto.randomUUID(), ...input }) + .returning(); + + if (!corridor) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create corridor', + }); + } + + 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. + const [corridor] = + Object.keys(payload).length > 0 + ? await db + .update(navigatorCorridor) + .set(payload) + .where(eq(navigatorCorridor.id, id)) + .returning() + : await db + .select() + .from(navigatorCorridor) + .where(eq(navigatorCorridor.id, id)); + + if (!corridor) { + throw notFound('Corridor not found'); + } + + return corridor; + }); + +export const deleteCorridorRoute = base.navigator.corridors.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [corridor] = await db + .delete(navigatorCorridor) + .where(eq(navigatorCorridor.id, id)) + .returning(); + + if (!corridor) { + throw notFound('Corridor not found'); + } + + return { id }; + }); diff --git a/apps/chronos/src/modules/navigator/graph.ts b/apps/chronos/src/modules/navigator/graph.ts new file mode 100644 index 00000000..cbe58bf7 --- /dev/null +++ b/apps/chronos/src/modules/navigator/graph.ts @@ -0,0 +1,58 @@ +import { and, asc, eq, getTableColumns } from 'drizzle-orm'; +import { db } from '#database'; +import { + navigatorCorridor, + navigatorLift, + navigatorStair, +} from '#modules/navigator/schema'; +import { building, classroom, classroomType } from '#modules/timetable/schema'; +import { base } from '#orpc'; + +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 { + buildings, + classroom_types: classroomTypes, + // 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/lifts.ts b/apps/chronos/src/modules/navigator/lifts.ts new file mode 100644 index 00000000..863c75ba --- /dev/null +++ b/apps/chronos/src/modules/navigator/lifts.ts @@ -0,0 +1,72 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { asc, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { navigatorLift } from '#modules/navigator/schema'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +export const listLiftsRoute = base.navigator.lifts.list.handler(async () => { + const lifts = await db + .select() + .from(navigatorLift) + .orderBy(asc(navigatorLift.name)); + + return { items: lifts, total: lifts.length }; +}); + +export const createLiftRoute = base.navigator.lifts.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const [lift] = await db + .insert(navigatorLift) + .values({ id: crypto.randomUUID(), ...input }) + .returning(); + + if (!lift) { + throw new ORPCError('INTERNAL', { message: 'Failed to create lift' }); + } + + return lift; + }); + +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. + const [lift] = + Object.keys(payload).length > 0 + ? await db + .update(navigatorLift) + .set(payload) + .where(eq(navigatorLift.id, id)) + .returning() + : await db.select().from(navigatorLift).where(eq(navigatorLift.id, id)); + + if (!lift) { + throw notFound('Lift not found'); + } + + return lift; + }); + +export const deleteLiftRoute = base.navigator.lifts.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [lift] = await db + .delete(navigatorLift) + .where(eq(navigatorLift.id, id)) + .returning(); + + if (!lift) { + throw notFound('Lift not found'); + } + + return { id }; + }); diff --git a/apps/chronos/src/database/schema/navigator.ts b/apps/chronos/src/modules/navigator/schema.ts similarity index 98% rename from apps/chronos/src/database/schema/navigator.ts rename to apps/chronos/src/modules/navigator/schema.ts index eb42f565..70e65dab 100644 --- a/apps/chronos/src/database/schema/navigator.ts +++ b/apps/chronos/src/modules/navigator/schema.ts @@ -8,7 +8,7 @@ import { text, } from 'drizzle-orm/pg-core'; import { timestamps } from '#database/helpers'; -import { building } from '#database/schema/timetable'; +import { building } from '#modules/timetable/schema'; // Entities in this file keep upstream Navigator's snake_case field names as // their TypeScript property names, so selected rows are already the wire shape diff --git a/apps/chronos/src/modules/navigator/stairs.ts b/apps/chronos/src/modules/navigator/stairs.ts new file mode 100644 index 00000000..506b630f --- /dev/null +++ b/apps/chronos/src/modules/navigator/stairs.ts @@ -0,0 +1,75 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { asc, eq } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { navigatorStair } from '#modules/navigator/schema'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +export const listStairsRoute = base.navigator.stairs.list.handler(async () => { + const stairs = await db + .select() + .from(navigatorStair) + .orderBy(asc(navigatorStair.name)); + + return { items: stairs, total: stairs.length }; +}); + +export const createStairRoute = base.navigator.stairs.create + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const [stair] = await db + .insert(navigatorStair) + .values({ id: crypto.randomUUID(), ...input }) + .returning(); + + if (!stair) { + throw new ORPCError('INTERNAL', { message: 'Failed to create stair' }); + } + + return stair; + }); + +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. + const [stair] = + Object.keys(payload).length > 0 + ? await db + .update(navigatorStair) + .set(payload) + .where(eq(navigatorStair.id, id)) + .returning() + : await db + .select() + .from(navigatorStair) + .where(eq(navigatorStair.id, id)); + + if (!stair) { + throw notFound('Staircase not found'); + } + + return stair; + }); + +export const deleteStairRoute = base.navigator.stairs.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [stair] = await db + .delete(navigatorStair) + .where(eq(navigatorStair.id, id)) + .returning(); + + if (!stair) { + throw notFound('Staircase not found'); + } + + return { id }; + }); diff --git a/apps/chronos/src/routes/navigator/transfer.ts b/apps/chronos/src/modules/navigator/transfer.ts similarity index 74% rename from apps/chronos/src/routes/navigator/transfer.ts rename to apps/chronos/src/modules/navigator/transfer.ts index ae45066e..6cc83ad8 100644 --- a/apps/chronos/src/routes/navigator/transfer.ts +++ b/apps/chronos/src/modules/navigator/transfer.ts @@ -1,35 +1,30 @@ import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; +import { ORPCError } from '@orpc/server'; import { asc, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; import { navigatorCorridor, navigatorLift, navigatorStair, navigatorTranslation, -} from '#database/schema/navigator'; -import { - building as buildingTable, - 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 { isIntegrityViolation } from '#utils/navigator/errors'; +} from '#modules/navigator/schema'; +import { isIntegrityViolation } from '#modules/navigator/utils/errors'; import { type NavigatorTransfer, - navigatorImportResponseSchema, navigatorImportSchema, - navigatorTransferResponseSchema, navigatorTransferSchema, -} from '#utils/navigator/transfer'; -import { filcExt } from '#utils/openapi'; +} from '#modules/navigator/utils/transfer'; +import { + building as buildingTable, + classroom as classroomTable, + classroomType as classroomTypeTable, +} from '#modules/timetable/schema'; +import { base } from '#orpc'; +import { badRequest } from '#utils/http'; -const { schema: navigatorImportRequestSchema } = await resolver( - navigatorImportSchema -).toOpenAPISchema(); +/** 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]; @@ -107,27 +102,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 +160,37 @@ 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. + 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( + JSON.parse(await input.file.text()) + ); + } catch (error) { + throw badRequest('Navigator import payload is invalid', error); + } try { await db.transaction(async (tx) => { @@ -291,7 +264,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 +272,5 @@ export const importNavigatorRoute = navigatorFactory.createHandlers( lifts: payload.lifts.length, stairs: payload.stairs.length, translations: payload.translations.length, - }); - } -); + }; + }); diff --git a/apps/chronos/src/modules/navigator/translations.ts b/apps/chronos/src/modules/navigator/translations.ts new file mode 100644 index 00000000..edbafc60 --- /dev/null +++ b/apps/chronos/src/modules/navigator/translations.ts @@ -0,0 +1,123 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { and, asc, eq, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { navigatorTranslation } from '#modules/navigator/schema'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; + +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)); + + const languages = rows.map((row) => row.lang_key); + + return { items: languages, total: languages.length }; + }); + +export const getTranslationBundleRoute = + base.navigator.translations.lang.handler(async ({ input }) => { + const { lang } = input; + + const rows = await db + .select({ + text: navigatorTranslation.text, + text_key: navigatorTranslation.text_key, + }) + .from(navigatorTranslation) + .where(eq(navigatorTranslation.lang_key, lang)); + + return Object.fromEntries(rows.map((row) => [row.text_key, row.text])); + }); + +export const listTranslationsRoute = base.navigator.translations.list + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async () => { + const translations = await db + .select() + .from(navigatorTranslation) + .orderBy( + asc(navigatorTranslation.text_key), + asc(navigatorTranslation.lang_key) + ); + + return { items: translations, total: translations.length }; + }); + +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, + text, + text_key, + })); + + if (values.length === 0) { + throw badRequest('At least one translation is required'); + } + + // A single INSERT … ON CONFLICT statement writes every language or, if the + // statement fails, none of them. + const rows = await db + .insert(navigatorTranslation) + .values(values) + .onConflictDoUpdate({ + set: { text: sql`excluded.text` }, + target: [navigatorTranslation.lang_key, navigatorTranslation.text_key], + }) + .returning(); + + return { items: rows, total: rows.length }; + }); + +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) + .values({ lang_key: lang, text, text_key: key }) + .onConflictDoUpdate({ + set: { text }, + target: [navigatorTranslation.lang_key, navigatorTranslation.text_key], + }) + .returning(); + + if (!translation) { + throw new ORPCError('INTERNAL', { + message: 'Failed to write translation', + }); + } + + return translation; + }); + +export const deleteTranslationRoute = base.navigator.translations.delete + .use(requireAuthorization(permissions.navigatorManage)) + .handler(async ({ input }) => { + const { key, lang } = input; + + const [translation] = await db + .delete(navigatorTranslation) + .where( + and( + eq(navigatorTranslation.lang_key, lang), + eq(navigatorTranslation.text_key, key) + ) + ) + .returning(); + + if (!translation) { + throw notFound('Translation not found'); + } + + return { id: key }; + }); diff --git a/apps/chronos/src/modules/navigator/utils/errors.ts b/apps/chronos/src/modules/navigator/utils/errors.ts new file mode 100644 index 00000000..4aca46c7 --- /dev/null +++ b/apps/chronos/src/modules/navigator/utils/errors.ts @@ -0,0 +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. + */ +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, ...). + */ +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 new file mode 100644 index 00000000..280f845a --- /dev/null +++ b/apps/chronos/src/modules/navigator/utils/schemas.ts @@ -0,0 +1,17 @@ +import { + navigatorCorridor, + navigatorLift, + navigatorStair, + navigatorTranslation, +} from '#modules/navigator/schema'; +import { building, classroom, classroomType } from '#modules/timetable/schema'; +import { createSelectSchema } from '#utils/zod'; + +/** Row schemas derived from the tables, so the docs always match the wire. */ +export const buildingSelectSchema = createSelectSchema(building); +export const classroomSelectSchema = createSelectSchema(classroom); +export const classroomTypeSelectSchema = createSelectSchema(classroomType); +export const corridorSelectSchema = createSelectSchema(navigatorCorridor); +export const liftSelectSchema = createSelectSchema(navigatorLift); +export const stairSelectSchema = createSelectSchema(navigatorStair); +export const translationSelectSchema = createSelectSchema(navigatorTranslation); diff --git a/apps/chronos/src/utils/navigator/transfer.ts b/apps/chronos/src/modules/navigator/utils/transfer.ts similarity index 85% rename from apps/chronos/src/utils/navigator/transfer.ts rename to apps/chronos/src/modules/navigator/utils/transfer.ts index acbc692a..c0726ee5 100644 --- a/apps/chronos/src/utils/navigator/transfer.ts +++ b/apps/chronos/src/modules/navigator/utils/transfer.ts @@ -7,7 +7,7 @@ import { liftSelectSchema, stairSelectSchema, translationSelectSchema, -} from '#utils/navigator/schemas'; +} from '#modules/navigator/utils/schemas'; /** * Transfer schemas for navigator export/import. Each strips the audit @@ -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/_module.ts b/apps/chronos/src/modules/news/_module.ts new file mode 100644 index 00000000..87099d38 --- /dev/null +++ b/apps/chronos/src/modules/news/_module.ts @@ -0,0 +1,7 @@ +import type { Module } from '#modules/module'; +import { newsNotifications } from '#modules/news/_notifications'; + +/** News: announcements, blog posts, system notices — and their notifications. */ +export const newsModule = { + notifications: newsNotifications, +} satisfies Module; diff --git a/apps/chronos/src/modules/news/_notifications.ts b/apps/chronos/src/modules/news/_notifications.ts new file mode 100644 index 00000000..42e73ecd --- /dev/null +++ b/apps/chronos/src/modules/news/_notifications.ts @@ -0,0 +1,81 @@ +import { env } from '#utils/environment'; +import { resolveAudienceByCohortOrAll } from '#utils/notifications/engine'; +import { isHungarianLocale } from '#utils/notifications/locale'; +import type { NotificationHandler } from '#utils/notifications/types'; + +/** The payload is the record this module's own handlers dispatch with. */ +function byCohortOrAll(payload: unknown) { + const p = payload as { cohortIds?: string[] }; + return resolveAudienceByCohortOrAll(p.cohortIds); +} + +/** + * The notifications the news module owns: an announcement, a system notice and + * a published blog post. Each is written in the recipient's language, because + * every notification goes out per user rather than once per event. + */ +export const newsNotifications: readonly NotificationHandler[] = [ + { + buildContent: (payload, locale) => { + const p = payload as { title: string }; + const title = p.title || ''; + const prefix = isHungarianLocale(locale) + ? 'Új bejelentés' + : 'New announcement'; + const content = `${prefix}: ${title}`; + return { + content, + title: isHungarianLocale(locale) ? 'Új bejelentés' : 'New Announcement', + }; + }, + getAudience: byCohortOrAll, + getDelay: () => env.notificationDelayAnnouncement, + preferenceKey: 'announcement', + type: 'announcement', + }, + { + buildContent: (payload, locale) => { + const p = payload as { title: string }; + const title = p.title || ''; + const prefix = isHungarianLocale(locale) + ? 'Rendszerüzenet' + : 'System notice'; + const content = `${prefix}: ${title}`; + return { + content, + title: isHungarianLocale(locale) ? 'Rendszerüzenet' : 'System Message', + }; + }, + getAudience: byCohortOrAll, + getDelay: () => env.notificationDelaySystemMessage, + preferenceKey: 'systemMessage', + type: 'system_message', + }, + { + buildContent: (payload, locale) => { + const p = payload as { title: string; slug?: string }; + const title = p.title || ''; + const cta = isHungarianLocale(locale) + ? 'koppints az olvasáshoz' + : 'tap to read'; + const prefix = isHungarianLocale(locale) ? 'Új bejegyzés' : 'New post'; + const content = `${prefix}: ${title} — ${cta}`; + const result: { + content: string; + title: string; + metadata?: Record; + } = { + content, + title: isHungarianLocale(locale) ? 'Új blogbejegyzés' : 'New Blog Post', + }; + if (p.slug) { + result.metadata = { slug: p.slug }; + } + return result; + }, + getAudience: () => resolveAudienceByCohortOrAll(), + getDelay: () => env.notificationDelayBlogPost, + preferenceKey: 'blogPost', + type: 'blog_post', + }, +]; diff --git a/apps/chronos/src/modules/news/_router.ts b/apps/chronos/src/modules/news/_router.ts new file mode 100644 index 00000000..705619f8 --- /dev/null +++ b/apps/chronos/src/modules/news/_router.ts @@ -0,0 +1,57 @@ +import { + createAnnouncement, + deleteAnnouncement, + deleteAnnouncementImage, + getAnnouncement, + listAnnouncements, + updateAnnouncement, + uploadAnnouncementImage, +} from '#modules/news/announcements'; +import { + createBlog, + deleteBlog, + getBlogById, + getBlogBySlug, + listDrafts, + listPublishedBlogs, + publishBlog, + unpublishBlog, + updateBlog, +} from '#modules/news/blogs'; +import { + createSystemMessage, + deleteSystemMessage, + getSystemMessage, + listSystemMessages, + updateSystemMessage, +} from '#modules/news/system-messages'; + +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/modules/news/announcements.ts similarity index 54% rename from apps/chronos/src/routes/news/announcements.ts rename to apps/chronos/src/modules/news/announcements.ts index 219df2f5..4d8ee72d 100644 --- a/apps/chronos/src/routes/news/announcements.ts +++ b/apps/chronos/src/modules/news/announcements.ts @@ -1,45 +1,33 @@ -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 { requireAuthentication, requireAuthorization } from '#middleware/auth'; import { announcement, 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 { activeAnnouncementConditions } from '#utils/news/announcements'; -import { validateCohortIds } from '#utils/news/cohort'; -import { validateKioskIds } from '#utils/news/kiosks'; +} from '#modules/news/schema'; +import { activeAnnouncementConditions } from '#modules/news/utils/announcements'; +import { + cohortVisibilityCondition, + validateCohortIds, +} from '#modules/news/utils/cohort'; +import { validateKioskIds } from '#modules/news/utils/kiosks'; import { - announcementBaseDetailResponseSchema, - announcementDetailResponseSchema, - announcementListResponseSchema, announcementSelect, authorSelect, resolveTitle, - successResponseSchema, -} from '#utils/news/shared'; +} from '#modules/news/utils/shared'; +import { base } from '#orpc'; +import { badRequest, notFound, serviceUnavailable } from '#utils/http'; import { cancelPendingNotification, dispatchPendingNotification, } from '#utils/notifications/engine'; -import { filcExt } from '#utils/openapi'; import { announcementImageKey, deleteObject, @@ -142,69 +130,42 @@ 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 }))); + } } - } + }); } -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. @@ -223,22 +184,18 @@ export const listAnnouncements = newsFactory.createHandlers( 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; @@ -282,35 +239,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 { items: 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 +254,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 +276,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 +286,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 +304,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 +323,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 +354,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 +374,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 +388,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 +406,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 +424,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 +473,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 +522,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/modules/news/blogs.ts b/apps/chronos/src/modules/news/blogs.ts new file mode 100644 index 00000000..5f054679 --- /dev/null +++ b/apps/chronos/src/modules/news/blogs.ts @@ -0,0 +1,313 @@ +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 { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { requireAuthorization } from '#middleware/auth'; +import { blogPost } from '#modules/news/schema'; +import { ensureUniqueSlug, generateSlug } from '#modules/news/utils/schemas'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; +import { + cancelPendingNotification, + dispatchPendingNotification, +} from '#utils/notifications/engine'; + +const authorSelect = { + id: user.id, + image: user.image, + name: user.name, +}; + +const checkSlugExists = async (slug: string, excludeId?: string) => { + const conditions = [eq(blogPost.slug, slug)]; + if (excludeId) { + conditions.push(sql`${blogPost.id} != ${excludeId}`); + } + const [existing] = await db + .select({ id: blogPost.id }) + .from(blogPost) + .where(and(...conditions)); + return !!existing; +}; + +export const listPublishedBlogs = base.news.blogs.list.handler( + async ({ input }) => { + const { limit, offset } = input; + + const where = eq(blogPost.status, 'published'); + + const [items, totalResult] = await Promise.all([ + 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(where) + .orderBy(sql`${blogPost.publishedAt} DESC NULLS LAST`) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(blogPost).where(where), + ]); + + return { items, total: totalResult[0]?.count ?? 0 }; + } +); + +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'))); + + if (!item) { + throw notFound('Blog post not found'); + } + + 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 + .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)) + .orderBy(sql`${blogPost.publishedAt} DESC NULLS LAST`) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(blogPost), + ]); + + return { items, total: totalResult[0]?.count ?? 0 }; + }); + +export const getBlogById = base.news.blogs.getById + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = 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(eq(blogPost.id, id)); + + if (!item) { + throw notFound('Blog post not found'); + } + + return item; + }); + +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)); + + const publishedAt = body.status === 'published' ? new Date() : null; + + const [row] = await db + .insert(blogPost) + .values({ + authorId, + content: body.content, + publishedAt, + slug, + status: body.status, + title: body.title, + }) + .returning(); + + if (!row) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create blog post', + }); + } + + 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() + .from(blogPost) + .where(eq(blogPost.id, id)); + + if (!existing) { + throw notFound('Blog post not found'); + } + + const updateData: Record = {}; + if (body.title !== undefined) { + updateData.title = body.title; + } + if (body.content !== undefined) { + updateData.content = body.content; + } + + if (body.slug !== undefined) { + const slug = await ensureUniqueSlug(body.slug, (s) => + checkSlugExists(s, id) + ); + updateData.slug = slug; + } + + const [updated] = await db + .update(blogPost) + .set(updateData) + .where(eq(blogPost.id, id)) + .returning(); + + if (!updated) { + throw notFound('Blog post not found'); + } + + return updated; + }); + +export const publishBlog = base.news.blogs.publish + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [existing] = await db + .select() + .from(blogPost) + .where(eq(blogPost.id, id)); + + if (!existing) { + throw notFound('Blog post not found'); + } + + if (existing.status === 'published') { + throw badRequest('Blog post is already published'); + } + + const [updated] = await db + .update(blogPost) + .set({ publishedAt: new Date(), status: 'published' }) + .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 updated; + }); + +export const unpublishBlog = base.news.blogs.unpublish + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [existing] = await db + .select() + .from(blogPost) + .where(eq(blogPost.id, id)); + + if (!existing) { + throw notFound('Blog post not found'); + } + + if (existing.status === 'draft') { + throw badRequest('Blog post is already a draft'); + } + + const [updated] = await db + .update(blogPost) + .set({ publishedAt: null, status: 'draft' }) + .where(eq(blogPost.id, id)) + .returning(); + + if (!updated) { + throw notFound('Blog post not found'); + } + + cancelPendingNotification(id, 'blog_post'); + + return updated; + }); + +export const deleteBlog = base.news.blogs.delete + .use(requireAuthorization(permissions.newsBlogsManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [deleted] = await db + .delete(blogPost) + .where(eq(blogPost.id, id)) + .returning(); + + if (!deleted) { + throw notFound('Blog post not found'); + } + + return { id }; + }); diff --git a/apps/chronos/src/database/schema/news.ts b/apps/chronos/src/modules/news/schema.ts similarity index 93% rename from apps/chronos/src/database/schema/news.ts rename to apps/chronos/src/modules/news/schema.ts index 8a7efb18..e1efa616 100644 --- a/apps/chronos/src/database/schema/news.ts +++ b/apps/chronos/src/modules/news/schema.ts @@ -11,10 +11,11 @@ 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 '#database/schema/kiosk'; -import { cohort } from '#database/schema/timetable'; +import { kiosk } from '#modules/kiosk/schema'; +import { cohort } from '#modules/timetable/schema'; export const announcement = pgTable( 'announcement', @@ -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 new file mode 100644 index 00000000..22e25281 --- /dev/null +++ b/apps/chronos/src/modules/news/system-messages.ts @@ -0,0 +1,278 @@ +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 { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { requireAuthorization } from '#middleware/auth'; +import { systemMessage, systemMessageCohortMtm } from '#modules/news/schema'; +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'; +import { + cancelPendingNotification, + dispatchPendingNotification, +} from '#utils/notifications/engine'; + +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[] = []; + + if (!includeExpired) { + conditions.push(lte(systemMessage.validFrom, now)); + conditions.push(gte(systemMessage.validUntil, now)); + } + + // A message with no cohort rows is global; a targeted one is only visible + // 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; + + const [items, totalResult] = await Promise.all([ + db + .select({ + author: authorSelect, + authorId: systemMessage.authorId, + content: systemMessage.content, + createdAt: systemMessage.createdAt, + id: systemMessage.id, + title: systemMessage.title, + updatedAt: systemMessage.updatedAt, + validFrom: systemMessage.validFrom, + validUntil: systemMessage.validUntil, + }) + .from(systemMessage) + .leftJoin(user, eq(systemMessage.authorId, user.id)) + .where(where) + .orderBy(sql`${systemMessage.validFrom} DESC`) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(systemMessage).where(where), + ]); + + const itemIds = items.map((i) => i.id); + const cohortMappings = + itemIds.length > 0 + ? await db + .select() + .from(systemMessageCohortMtm) + .where(sql`${systemMessageCohortMtm.systemMessageId} IN ${itemIds}`) + : []; + + const data = items.map((item) => ({ + ...item, + cohortIds: cohortMappings + .filter((m) => m.systemMessageId === item.id) + .map((m) => m.cohortId), + })); + + return { items: data, total: totalResult[0]?.count ?? 0 }; + } +); + +export const getSystemMessage = base.news.systemMessages.get.handler( + async ({ input }) => { + const { id } = input; + + const [item] = await db + .select({ + author: authorSelect, + authorId: systemMessage.authorId, + content: systemMessage.content, + createdAt: systemMessage.createdAt, + id: systemMessage.id, + title: systemMessage.title, + updatedAt: systemMessage.updatedAt, + validFrom: systemMessage.validFrom, + validUntil: systemMessage.validUntil, + }) + .from(systemMessage) + .leftJoin(user, eq(systemMessage.authorId, user.id)) + .where(eq(systemMessage.id, id)); + + if (!item) { + throw notFound('System message not found'); + } + + const cohortIds = ( + await db + .select() + .from(systemMessageCohortMtm) + .where(eq(systemMessageCohortMtm.systemMessageId, id)) + ).map((m) => m.cohortId); + + return { ...item, cohortIds }; + } +); + +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); + } + + // 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 (!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 ?? [], + title: body.title, + }); + + return { ...created, cohortIds: body.cohortIds ?? [] }; + }); + +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() + .from(systemMessage) + .where(eq(systemMessage.id, id)); + + if (!existing) { + throw notFound('System message not found'); + } + + const validFrom = body.validFrom ?? existing.validFrom; + const validUntil = body.validUntil ?? existing.validUntil; + if (validUntil <= validFrom) { + throw badRequest('validUntil must be after validFrom'); + } + + if (body.cohortIds && body.cohortIds.length > 0) { + await validateCohortIds(body.cohortIds); + } + + cancelPendingNotification(id, 'system_message'); + + const updateData: Record = {}; + if (body.title !== undefined) { + updateData.title = body.title; + } + if (body.content !== undefined) { + updateData.content = body.content; + } + if (body.validFrom !== undefined) { + updateData.validFrom = body.validFrom; + } + if (body.validUntil !== undefined) { + updateData.validUntil = body.validUntil; + } + + const [updated] = await db + .update(systemMessage) + .set(updateData) + .where(eq(systemMessage.id, id)) + .returning(); + if (!updated) { + throw notFound('System message not found'); + } + + if (body.cohortIds !== undefined) { + // 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 = + body.cohortIds ?? + ( + await db + .select() + .from(systemMessageCohortMtm) + .where(eq(systemMessageCohortMtm.systemMessageId, id)) + ).map((m) => m.cohortId); + + dispatchPendingNotification(id, 'system_message', { + cohortIds, + title: updated.title, + }); + + return { ...updated, cohortIds }; + }); + +export const deleteSystemMessage = base.news.systemMessages.delete + .use(requireAuthorization(permissions.systemMessagesManage)) + .handler(async ({ input }) => { + const { id } = input; + + const [deleted] = await db + .delete(systemMessage) + .where(eq(systemMessage.id, id)) + .returning(); + + if (!deleted) { + throw notFound('System message not found'); + } + + cancelPendingNotification(id, 'system_message'); + + return { id }; + }); diff --git a/apps/chronos/src/utils/news/announcements.ts b/apps/chronos/src/modules/news/utils/announcements.ts similarity index 96% rename from apps/chronos/src/utils/news/announcements.ts rename to apps/chronos/src/modules/news/utils/announcements.ts index 2c72cbe4..157c7418 100644 --- a/apps/chronos/src/utils/news/announcements.ts +++ b/apps/chronos/src/modules/news/utils/announcements.ts @@ -1,5 +1,5 @@ import { gte, lte, type SQL } from 'drizzle-orm'; -import { announcement } from '#database/schema/news'; +import { announcement } from '#modules/news/schema'; /** How far in advance (days) a future announcement should be visible. */ export const ANNOUNCEMENT_LEAD_DAYS = 7; diff --git a/apps/chronos/src/modules/news/utils/cohort.ts b/apps/chronos/src/modules/news/utils/cohort.ts new file mode 100644 index 00000000..7c17a406 --- /dev/null +++ b/apps/chronos/src/modules/news/utils/cohort.ts @@ -0,0 +1,49 @@ +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'; + +/** Throw 400 if any of the provided cohort IDs do not exist. */ +export const validateCohortIds = async (cohortIds: string[]) => { + const existingCohorts = await db + .select({ id: cohort.id }) + .from(cohort) + .where(sql`${cohort.id} IN ${cohortIds}`); + const existingIds = new Set(existingCohorts.map((co) => co.id)); + const invalid = cohortIds.filter((cid) => !existingIds.has(cid)); + if (invalid.length > 0) { + 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/utils/news/kiosks.ts b/apps/chronos/src/modules/news/utils/kiosks.ts similarity index 64% rename from apps/chronos/src/utils/news/kiosks.ts rename to apps/chronos/src/modules/news/utils/kiosks.ts index a34da203..b0ef4625 100644 --- a/apps/chronos/src/utils/news/kiosks.ts +++ b/apps/chronos/src/modules/news/utils/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 { kiosk } from '#modules/kiosk/schema'; +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/news/schemas.ts b/apps/chronos/src/modules/news/utils/schemas.ts similarity index 100% rename from apps/chronos/src/utils/news/schemas.ts rename to apps/chronos/src/modules/news/utils/schemas.ts diff --git a/apps/chronos/src/modules/news/utils/shared.ts b/apps/chronos/src/modules/news/utils/shared.ts new file mode 100644 index 00000000..646c6ba4 --- /dev/null +++ b/apps/chronos/src/modules/news/utils/shared.ts @@ -0,0 +1,34 @@ +import { user } from '#database/schema/authentication'; +import { announcement } from '#modules/news/schema'; + +/** Shared `author` projection used by announcement/system-message list & detail. */ +export const authorSelect = { + id: user.id, + image: user.image, + name: user.name, +}; + +/** + * Shared column projection for announcement list & detail, so a new column + * cannot be forgotten in one of the hand-written selects. Spread the author + * projection next to it: `{ ...announcementSelect, author: authorSelect }`. + */ +export const announcementSelect = { + authorId: announcement.authorId, + content: announcement.content, + createdAt: announcement.createdAt, + highlighted: announcement.highlighted, + id: announcement.id, + imageByteSize: announcement.imageByteSize, + imageContentType: announcement.imageContentType, + imageKey: announcement.imageKey, + imageUpdatedAt: announcement.imageUpdatedAt, + kioskOnly: announcement.kioskOnly, + title: announcement.title, + updatedAt: announcement.updatedAt, + validFrom: announcement.validFrom, + validUntil: announcement.validUntil, +}; + +export const resolveTitle = (title: string | null | undefined): string => + title && title.length > 0 ? title : 'Untitled'; diff --git a/apps/chronos/src/modules/notifications/_module.ts b/apps/chronos/src/modules/notifications/_module.ts new file mode 100644 index 00000000..745f9b4d --- /dev/null +++ b/apps/chronos/src/modules/notifications/_module.ts @@ -0,0 +1,17 @@ +import type { Module } from '#modules/module'; +import { cleanUpOldNotifications } from '#utils/notifications/cleanup'; + +/** + * The notification log itself: the table, the preference column and the daily + * trim of delivered rows. What each *type* says is owned by the module that + * raises it, not here. + */ +export const notificationsModule = { + jobs: [ + { + callback: cleanUpOldNotifications, + cron: '@daily', + name: 'clean-up-old-notifications', + }, + ], +} satisfies Module; diff --git a/apps/chronos/src/modules/notifications/_router.ts b/apps/chronos/src/modules/notifications/_router.ts new file mode 100644 index 00000000..a688b5f0 --- /dev/null +++ b/apps/chronos/src/modules/notifications/_router.ts @@ -0,0 +1,33 @@ +import { + getNotificationSettings, + getUnreadCount, + listNotifications, + markAllAsRead, + markAsRead, + previewTestNotification, + registerFcmToken, + sendTestNotification, + testNotification, + unregisterFcmToken, + updateNotificationSettings, +} from '#modules/notifications/notifications'; + +/** + * 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/modules/notifications/notifications.ts b/apps/chronos/src/modules/notifications/notifications.ts new file mode 100644 index 00000000..c26406e4 --- /dev/null +++ b/apps/chronos/src/modules/notifications/notifications.ts @@ -0,0 +1,451 @@ +import { ORPCError } from '@orpc/server'; +import { and, count, desc, eq, sql } from 'drizzle-orm'; +import { db } from '#database'; +import { user as userTable } from '#database/schema/authentication'; +import { requireAuthentication } from '#middleware/auth'; +import { + fcmToken, + notification, + userPreferences, +} from '#modules/notifications/schema'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { forbidden, notFound } from '#utils/http'; +import { sendPush } from '#utils/notifications/providers/fcm'; +import { + renderPreviewEmail, + 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) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + const { limit, offset, type, unread, dateFrom, dateTo } = input; + + const conditions = [eq(notification.userId, userId)]; + + if (type) { + conditions.push(eq(notification.type, type)); + } + if (unread !== undefined) { + conditions.push(eq(notification.read, !unread)); + } + if (dateFrom) { + conditions.push(sql`${notification.createdAt} >= ${new Date(dateFrom)}`); + } + if (dateTo) { + const endDate = new Date(dateTo); + endDate.setHours(23, 59, 59, 999); + conditions.push(sql`${notification.createdAt} <= ${endDate}`); + } + + const [items, totalResult] = await Promise.all([ + db + .select() + .from(notification) + .where(and(...conditions)) + .orderBy(desc(notification.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: count() }) + .from(notification) + .where(and(...conditions)), + ]); + + return { items, total: totalResult[0]?.count ?? 0 }; + }); + +export const getUnreadCount = base.notifications.unreadCount + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; + + const [result] = await db + .select({ count: count() }) + .from(notification) + .where( + sql`${notification.userId} = ${userId} AND ${notification.read} = false` + ); + + return { count: result?.count ?? 0 }; + }); + +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) + .set({ read: true }) + .where( + sql`${notification.id} = ${id} AND ${notification.userId} = ${userId}` + ) + .returning(); + + if (!updated) { + throw notFound('Notification not found'); + } + + return updated; + }); + +export const markAllAsRead = base.notifications.markAllAsRead + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; + + await db + .update(notification) + .set({ read: true }) + .where( + sql`${notification.userId} = ${userId} AND ${notification.read} = false` + ); + + 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 }) => { + const userId = context.session.userId; + + const [prefs] = await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)); + + if (prefs) { + return withCompletePreferences(prefs); + } + + const [created] = await db + .insert(userPreferences) + .values({ userId }) + .returning(); + + if (!created) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create user preferences', + }); + } + + return withCompletePreferences(created); + }); + +export const updateNotificationSettings = base.notifications.updateSettings + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + const body = input; + + const values: Record = {}; + + if (body.language !== undefined) { + values.language = body.language; + } + if (body.theme !== undefined) { + values.theme = body.theme; + } + if (body.timetableView !== undefined) { + values.timetableView = body.timetableView; + } + if (body.timetableClassColors !== undefined) { + values.timetableClassColors = body.timetableClassColors; + } + if (body.timetableGroupDisplay !== undefined) { + 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) + .where(eq(userPreferences.userId, userId)) + .returning(); + + if (updated) { + return withCompletePreferences(updated); + } + + const [created] = await db + .insert(userPreferences) + .values({ userId, ...values }) + .returning(); + + if (!created) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create user preferences', + }); + } + + return withCompletePreferences(created); + }); + +export const registerFcmToken = base.notifications.fcmTokens.register + .use(requireAuthentication) + .handler(async ({ context, input }) => { + const userId = context.session.userId; + + const { token: fcmTokenValue } = input; + + // 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 }; + }); + +export const testNotification = base.notifications.test + .use(requireAuthentication) + .handler(async ({ context }) => { + if (!env.enableTestNotifications) { + throw forbidden( + 'Test notifications are disabled (set CHRONOS_ENABLE_TEST_NOTIFICATIONS=true)' + ); + } + + const userId = context.session.userId; + + const [notif] = await db + .insert(notification) + .values({ + content: + 'This is a test notification. If you received this, everything works.', + title: 'Test Notification', + type: 'test', + userId, + }) + .returning(); + + if (!notif) { + throw new ORPCError('INTERNAL', { + message: 'Failed to create test notification', + }); + } + + const [prefs] = await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .limit(1); + + const userRecords = await db + .select({ email: userTable.email }) + .from(userTable) + .where(eq(userTable.id, userId)) + .limit(1); + + enqueue({ + channelsEnabled: prefs?.notificationPreferences?.channelsEnabled ?? true, + content: notif.content, + email: userRecords[0]?.email ?? '', + language: prefs?.language ?? 'hu', + notificationId: notif.id, + title: notif.title, + type: 'test', + userId, + }); + + return notif; + }); + +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) + .where( + sql`${fcmToken.userId} = ${userId} AND ${fcmToken.token} = ${token}` + ); + + return { ok: true as const }; + }); + +type TestTarget = { + email: string; + id: string; + isUser: boolean; + language: string; +}; + +async function resolveTestTarget( + email: string | undefined, + language: 'en' | 'hu' | undefined, + currentUserId: string +): Promise { + let target: TestTarget = { + email: '', + id: currentUserId, + isUser: true, + language: 'hu', + }; + + if (email) { + const [byEmail] = await db + .select({ email: userTable.email, id: userTable.id }) + .from(userTable) + .where(sql`lower(${userTable.email}) = ${email.toLowerCase()}`) + .limit(1); + if (byEmail) { + target = { + email: byEmail.email, + id: byEmail.id, + isUser: true, + language: 'hu', + }; + } else { + // Unmatched address: a pure SMTP delivery test. Keep the caller out of + // it — no unsubscribe token for the caller, no caller greeting/prefs. + target = { + email, + id: '', + isUser: false, + language: 'hu', + }; + } + } else { + const [me] = await db + .select({ email: userTable.email }) + .from(userTable) + .where(eq(userTable.id, currentUserId)) + .limit(1); + target.email = me?.email ?? ''; + } + + const [prefs] = await db + .select({ language: userPreferences.language }) + .from(userPreferences) + .where(eq(userPreferences.userId, target.id)) + .limit(1); + target.language = language ?? prefs?.language ?? 'hu'; + return target; +} + +export const sendTestNotification = base.notifications.sendTest + .use(requireAuthentication) + .handler(async ({ context, input }) => { + if (!env.enableTestNotifications) { + throw forbidden( + 'Test notifications are disabled (set CHRONOS_ENABLE_TEST_NOTIFICATIONS=true)' + ); + } + + const currentUserId = context.session.userId; + const body = input; + + const target = await resolveTestTarget( + body.email, + body.language, + currentUserId + ); + const subject = body.subject ?? 'Filc test message'; + const content = + body.content ?? + 'This is a development test message. If you received this, everything works.'; + + const results = { email: false, inApp: false, push: false }; + + if (target.isUser && body.channels.inApp) { + const [notif] = await db + .insert(notification) + .values({ content, title: subject, type: body.type, userId: target.id }) + .returning(); + results.inApp = Boolean(notif); + } + if (body.channels.email) { + results.email = await sendEmail( + target.email, + body.type, + target.language, + { content, title: subject }, + target.id + ); + } + if (target.isUser && body.channels.push) { + results.push = await sendPush(target.id, subject, content); + } + + return results; + }); + +export const previewTestNotification = base.notifications.previewTest + .use(requireAuthentication) + .handler(async ({ input }) => { + if (!env.enableTestNotifications) { + throw forbidden( + 'Test notifications are disabled (set CHRONOS_ENABLE_TEST_NOTIFICATIONS=true)' + ); + } + + const body = input; + const subject = body.subject ?? 'Filc test message'; + const content = + body.content ?? + 'This is a development test message. If you received this, everything works.'; + + const html = await renderPreviewEmail(body.type, body.language ?? 'hu', { + content, + title: subject, + }); + + return { html }; + }); diff --git a/apps/chronos/src/database/schema/notifications.ts b/apps/chronos/src/modules/notifications/schema.ts similarity index 76% rename from apps/chronos/src/database/schema/notifications.ts rename to apps/chronos/src/modules/notifications/schema.ts index 8ea7cf6d..5ec2d956 100644 --- a/apps/chronos/src/database/schema/notifications.ts +++ b/apps/chronos/src/modules/notifications/schema.ts @@ -5,11 +5,13 @@ import { pgTable, text, timestamp, + uniqueIndex, uuid, } from 'drizzle-orm/pg-core'; 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 +19,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>() @@ -66,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/notifications/unsubscribe-html.ts b/apps/chronos/src/modules/notifications/unsubscribe-html.ts new file mode 100644 index 00000000..e8b60602 --- /dev/null +++ b/apps/chronos/src/modules/notifications/unsubscribe-html.ts @@ -0,0 +1,138 @@ +import { unsubscribeSchema } from '@filcdev/api/domains/notifications'; +import { db } from '#database'; +import { userPreferences } from '#modules/notifications/schema'; +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/modules/ping/_router.ts b/apps/chronos/src/modules/ping/_router.ts new file mode 100644 index 00000000..c1dcc3d9 --- /dev/null +++ b/apps/chronos/src/modules/ping/_router.ts @@ -0,0 +1,4 @@ +import { ping } from '#modules/ping/index'; +import { uptime } from '#modules/ping/uptime'; + +export const pingRouter = { ping, uptime }; diff --git a/apps/chronos/src/modules/ping/index.ts b/apps/chronos/src/modules/ping/index.ts new file mode 100644 index 00000000..763c6998 --- /dev/null +++ b/apps/chronos/src/modules/ping/index.ts @@ -0,0 +1,5 @@ +import { base } from '#orpc'; + +export const ping = base.ping.ping.handler(() => ({ + message: 'pong' as const, +})); diff --git a/apps/chronos/src/modules/ping/uptime.ts b/apps/chronos/src/modules/ping/uptime.ts new file mode 100644 index 00000000..518307aa --- /dev/null +++ b/apps/chronos/src/modules/ping/uptime.ts @@ -0,0 +1,17 @@ +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { base } from '#orpc'; + +dayjs.extend(relativeTime); +dayjs.extend(duration); + +export const uptime = base.ping.uptime.handler(() => { + const NANOSECONDS_IN_MILLISECOND = 1_000_000; + const uptime_ms = Bun.nanoseconds() / NANOSECONDS_IN_MILLISECOND; + + return { + pretty: dayjs.duration(uptime_ms, 'millisecond').humanize(), + uptime_ms, + }; +}); diff --git a/apps/chronos/src/modules/roles/_router.ts b/apps/chronos/src/modules/roles/_router.ts new file mode 100644 index 00000000..af673055 --- /dev/null +++ b/apps/chronos/src/modules/roles/_router.ts @@ -0,0 +1,15 @@ +import { + createRole, + deleteRole, + listPermissions, + listRoles, + updateRole, +} from '#modules/roles/index'; + +export const rolesRouter = { + create: createRole, + delete: deleteRole, + list: listRoles, + permissions: listPermissions, + update: updateRole, +}; diff --git a/apps/chronos/src/modules/roles/index.ts b/apps/chronos/src/modules/roles/index.ts new file mode 100644 index 00000000..3a6bee4b --- /dev/null +++ b/apps/chronos/src/modules/roles/index.ts @@ -0,0 +1,85 @@ +import { permissions } from '@filcdev/api/permissions'; +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(() => { + const allPermissions = rbac.getAllPermissions(); + + return { items: allPermissions, total: allPermissions.length }; + }); + +export const listRoles = base.roles.list + .use(requireAuthorization(permissions.rolesRead)) + .handler(() => { + const allRoles = rbac.getAllRoles(); + + const roles = Object.entries(allRoles).map(([name, def]) => ({ + can: def.can, + name, + })); + + return { items: roles, total: roles.length }; + }); + +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 conflict(`Role "${name}" already exists`); + } + + try { + await rbac.createRole(name, rolePermissions); + } catch (error) { + // 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; + } + + 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 badRequest('Role name is required'); + } + + const existingRoles = rbac.getAllRoles(); + if (!(roleName in existingRoles)) { + throw notFound(`Role "${roleName}" not found`); + } + + 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 notFound(`Role "${roleName}" not found`); + } + + await rbac.deleteRole(roleName); + + return { ok: true as const }; + }); diff --git a/apps/chronos/src/modules/schemas.ts b/apps/chronos/src/modules/schemas.ts new file mode 100644 index 00000000..b80320bd --- /dev/null +++ b/apps/chronos/src/modules/schemas.ts @@ -0,0 +1,32 @@ +import { bugReportSchema } from '#modules/bug-report/schema'; +import { doorlockSchema } from '#modules/doorlock/schema'; +import { kioskSchema } from '#modules/kiosk/schema'; +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. + * + * This list is separate from `modules` because it has to be importable on its + * own: `src/database/index.ts` constructs `db` at module load, so pulling it + * in must not drag a module's notification handlers — and through them the + * notification engine, which queries `db` — back into the cycle. Schema files + * import nothing but other schemas and the column helpers, so this list stays + * a leaf. + * + * A module with tables adds itself here; a module with scheduled work or + * notifications adds itself to `modules`. + */ +export const moduleSchemas = { + ...bugReportSchema, + ...doorlockSchema, + ...kioskSchema, + ...navigatorSchema, + ...newsSchema, + ...notificationsSchema, + ...timetableSchema, + ...wifiSchema, +}; diff --git a/apps/chronos/src/modules/timetable/_curriculum-router.ts b/apps/chronos/src/modules/timetable/_curriculum-router.ts new file mode 100644 index 00000000..a6d56520 --- /dev/null +++ b/apps/chronos/src/modules/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/modules/timetable/_module.ts b/apps/chronos/src/modules/timetable/_module.ts new file mode 100644 index 00000000..80c56e27 --- /dev/null +++ b/apps/chronos/src/modules/timetable/_module.ts @@ -0,0 +1,21 @@ +import type { Module } from '#modules/module'; +import { timetableNotifications } from '#modules/timetable/_notifications'; +import { cleanupOrphanedCohorts } from '#modules/timetable/utils/cleanup'; + +/** + * The timetable: lessons, substitutions and moved lessons, the daily trim of + * cohorts no lesson refers to, and the four notifications it raises. + */ +export const timetableModule = { + jobs: [ + { + // The cleanup reports how many rows it removed; the baker ignores it. + callback: async () => { + await cleanupOrphanedCohorts(); + }, + cron: '@daily', + name: 'clean-up-orphaned-cohorts', + }, + ], + notifications: timetableNotifications, +} satisfies Module; diff --git a/apps/chronos/src/modules/timetable/_notifications.ts b/apps/chronos/src/modules/timetable/_notifications.ts new file mode 100644 index 00000000..a8500cc4 --- /dev/null +++ b/apps/chronos/src/modules/timetable/_notifications.ts @@ -0,0 +1,237 @@ +import { env } from '#utils/environment'; +import { + resolveAudienceForLessonChange, + resolveAudienceForUser, +} from '#utils/notifications/engine'; +import { isHungarianLocale } from '#utils/notifications/locale'; +import { + buildSubstitutionTeacherContent, + resolveSubstituteTeacherAudience, + type SubstitutionTeacherPayload, +} from '#utils/notifications/substitution-teacher'; +import type { NotificationHandler } from '#utils/notifications/types'; + +const DAY_NAMES_EN = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; +const DAY_NAMES_HU = ['Hétfő', 'Kedd', 'Szerda', 'Csütörtök', 'Péntek']; +const WEEKDAY_NAMES_EN = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; +const WEEKDAY_NAMES_HU = [ + 'Vasárnap', + 'Hétfő', + 'Kedd', + 'Szerda', + 'Csütörtök', + 'Péntek', + 'Szombat', +]; + +function dayName(day: string, locale: string): string { + const names = isHungarianLocale(locale) ? DAY_NAMES_HU : DAY_NAMES_EN; + const num = Number.parseInt(day, 10); + if (num >= 1 && num <= names.length) { + return names[num - 1] ?? day; + } + return day; +} + +function formatDate(date: Date, locale: string): string { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + const weekday = isHungarianLocale(locale) + ? WEEKDAY_NAMES_HU[date.getDay()] + : WEEKDAY_NAMES_EN[date.getDay()]; + if (isHungarianLocale(locale)) { + return `${weekday}, ${year}. ${month}. ${day}.`; + } + return `${weekday}, ${year}-${month}-${day}`; +} + +function substitutionContentHu( + dateStr: string, + subName: string, + lessonCount: number +): string { + const timetableCta = ' Nézd meg az órarendedben a részletekért!'; + if (subName && lessonCount > 0) { + return `${subName} helyettesít ${dateStr}, ${lessonCount} órádat tartja meg.${lessonCount > 1 ? timetableCta : ''}`; + } + if (subName) { + return `${subName} helyettesít ${dateStr}.${timetableCta}`; + } + if (lessonCount > 0) { + return `Helyettesítés ${dateStr} — ${lessonCount} órádat érinti.${lessonCount > 1 ? timetableCta : ''}`; + } + return `Új helyettesítés ${dateStr}.${timetableCta}`; +} + +function substitutionContentEn( + dateStr: string, + subName: string, + lessonCount: number +): string { + const timetableCta = ' Check your timetable for details.'; + if (subName && lessonCount > 0) { + const lessonWord = lessonCount > 1 ? 'lessons' : 'lesson'; + return `${subName} is covering ${lessonCount} of your ${lessonWord} on ${dateStr}.${lessonCount > 1 ? timetableCta : ''}`; + } + if (subName) { + return `${subName} will be covering your lesson(s) on ${dateStr}.${timetableCta}`; + } + if (lessonCount > 0) { + const lessonWord = lessonCount > 1 ? 'lessons' : 'lesson'; + return `A substitution on ${dateStr} affects ${lessonCount} of your ${lessonWord}.${lessonCount > 1 ? timetableCta : ''}`; + } + return `A new substitution has been posted for ${dateStr}.${timetableCta}`; +} + +function buildSubstitutionContent( + p: { date?: Date; substituter?: string | null; lessonIds?: string[] }, + locale: string +): string { + const date = p.date ? new Date(p.date) : null; + const dateStr = date ? formatDate(date, locale) : ''; + const subName = p.substituter ?? ''; + const lessonCount = p.lessonIds?.length ?? 0; + return isHungarianLocale(locale) + ? substitutionContentHu(dateStr, subName, lessonCount) + : substitutionContentEn(dateStr, subName, lessonCount); +} + +function periodLabel(period: string, locale: string): string { + return isHungarianLocale(locale) ? `${period}. óra` : `Period ${period}`; +} + +function movedLessonContentHu( + p: { + room?: string; + startingDay?: string; + startingPeriod?: string; + }, + multiple: boolean +): string { + const roomPart = p.room ? `, ${p.room} terem` : ''; + const dayPart = p.startingDay ? dayName(p.startingDay, 'hu') : ''; + const periodPart = p.startingPeriod + ? periodLabel(p.startingPeriod, 'hu') + : ''; + const timePart = [dayPart, periodPart].filter(Boolean).join(', '); + const suffix = multiple + ? ' Nézd meg az órarendedben a további változásokat!' + : ''; + const location = timePart + ? `${timePart}${roomPart}` + : `másik időpontra${roomPart ? ` (${roomPart.slice(2)})` : ''}`; + return `Az órád át lett helyezve: ${location}.${suffix}`; +} + +function movedLessonContentEn( + p: { + room?: string; + startingDay?: string; + startingPeriod?: string; + }, + multiple: boolean +): string { + const roomPart = p.room ? ` in room ${p.room}` : ''; + const dayPart = p.startingDay ? dayName(p.startingDay, 'en') : ''; + const periodPart = p.startingPeriod + ? periodLabel(p.startingPeriod, 'en') + : ''; + const atPart = + dayPart && periodPart ? `${dayPart}, ${periodPart}` : dayPart || periodPart; + const suffix = multiple ? ' Check your timetable for all updates.' : ''; + const location = atPart + ? `${atPart}${roomPart}` + : `a new time slot${roomPart ? ` (${roomPart.slice(1)})` : ''}`; + return `Your lesson has been moved to ${location}.${suffix}`; +} + +/** + * The notifications the timetable owns. A substitution or a moved lesson goes + * to the cohorts that own the affected lessons, the substitute is told what + * they now cover, and a user whose cohort disappeared is asked to pick a new + * one — that last one reaches a single addressed user and is not gated on a + * notification preference. + */ +export const timetableNotifications: readonly NotificationHandler[] = [ + { + buildContent: (payload, locale) => { + const p = payload as { + date?: Date; + substituter?: string | null; + lessonIds?: string[]; + }; + return { + content: buildSubstitutionContent(p, locale), + title: isHungarianLocale(locale) + ? 'Új helyettesítés' + : 'New Substitution', + }; + }, + getAudience: (payload) => resolveAudienceForLessonChange(payload), + getDelay: () => env.notificationDelaySubstitution, + preferenceKey: 'substitution', + type: 'substitution', + }, + { + buildContent: (payload, locale) => + buildSubstitutionTeacherContent( + payload as SubstitutionTeacherPayload, + locale + ), + getAudience: (payload) => + resolveSubstituteTeacherAudience(payload as SubstitutionTeacherPayload), + getDelay: () => env.notificationDelaySubstitution, + preferenceKey: 'substitution', + type: 'substitution_teacher', + }, + { + buildContent: (payload, locale) => { + const p = payload as { + date?: Date; + lessonIds?: string[]; + room?: string; + startingDay?: string; + startingPeriod?: string; + }; + const multiple = (p.lessonIds?.length ?? 0) > 1; + const content = isHungarianLocale(locale) + ? movedLessonContentHu(p, multiple) + : movedLessonContentEn(p, multiple); + return { + content, + title: isHungarianLocale(locale) ? 'Áthelyezett óra' : 'Moved Lesson', + }; + }, + getAudience: (payload) => resolveAudienceForLessonChange(payload), + getDelay: () => env.notificationDelayMovedLesson, + preferenceKey: 'movedLesson', + type: 'moved_lesson', + }, + { + buildContent: (payload, locale) => { + const p = payload as { userId: string }; + const content = isHungarianLocale(locale) + ? 'A csoportod már nem elérhető. Kérjük, válassz új csoportot a Beállítások menüben.' + : 'Your cohort is no longer available. Please go to Settings to select a new cohort.'; + return { + content, + metadata: { action: 'cohort_reselection', userId: p.userId }, + title: isHungarianLocale(locale) + ? 'Csoport újraválasztása szükséges' + : 'Cohort Reselection Required', + }; + }, + getAudience: (payload) => resolveAudienceForUser(payload), + getDelay: () => 0, + type: 'cohort_reselection_required', + }, +]; diff --git a/apps/chronos/src/modules/timetable/_router.ts b/apps/chronos/src/modules/timetable/_router.ts new file mode 100644 index 00000000..71e05a5d --- /dev/null +++ b/apps/chronos/src/modules/timetable/_router.ts @@ -0,0 +1,72 @@ +import { curriculumRouter } from './_curriculum-router'; +import { getCohortsForTimetable } from './cohort'; +import { getGroupsForCohort, selectGroup } from './groups'; +import { importRoute } from './import'; +import { + cleanupOrphanedCohortsHandler, + deleteTimetable, + getAllTimetables, + getAllValidTimetables, + getLatestValidTimetable, + previewDeleteTimetable, + updateTimetable, +} from './index'; +import { getSubjects } from './lesson'; +import { getPeriodsForTimetable } from './period'; +import { getAvailableClassrooms, getClassrooms } from './room'; +import { + getMyTeacher, + getTeachers, + listTeachersAdmin, + updateTeacher, +} from './teacher'; + +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/modules/timetable/cohort.ts b/apps/chronos/src/modules/timetable/cohort.ts new file mode 100644 index 00000000..97ea9750 --- /dev/null +++ b/apps/chronos/src/modules/timetable/cohort.ts @@ -0,0 +1,19 @@ +import { eq } from 'drizzle-orm'; +import { db } from '#database'; +import { cohort, cohortTimetableMtm } from '#modules/timetable/schema'; +import { base } from '#orpc'; + +export const getCohortsForTimetable = + base.timetable.cohorts.getAllForTimetable.handler(async ({ input }) => { + const { timetableId } = input; + + const cohortRows = await db + .select() + .from(cohort) + .innerJoin(cohortTimetableMtm, eq(cohort.id, cohortTimetableMtm.cohortId)) + .where(eq(cohortTimetableMtm.timetableId, timetableId)); + + const cohorts = cohortRows.map((r) => r.cohort); + + return { items: cohorts, total: cohorts.length }; + }); diff --git a/apps/chronos/src/routes/timetable/export.ts b/apps/chronos/src/modules/timetable/export.ts similarity index 75% rename from apps/chronos/src/routes/timetable/export.ts rename to apps/chronos/src/modules/timetable/export.ts index 6b9a99b8..218949b1 100644 --- a/apps/chronos/src/routes/timetable/export.ts +++ b/apps/chronos/src/modules/timetable/export.ts @@ -1,11 +1,8 @@ -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 { desc, eq, gte, inArray, lte, type SQL } from 'drizzle-orm'; import { db } from '#database'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; import { classroom, cohort, @@ -19,10 +16,9 @@ import { substitution, substitutionLessonMTM, teacher, -} from '#database/schema/timetable'; -import { requireAuthentication, requireAuthorization } from '#middleware/auth'; -import { filcExt } from '#utils/openapi'; -import { timetableFactory } from './_factory'; +} from '#modules/timetable/schema'; +import { base } from '#orpc'; +import { andAll } from '#utils/drizzle'; const buildDateFilters = ( filters: SQL[], @@ -61,29 +57,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( @@ -92,10 +70,7 @@ export const exportSubstitutionsRoute = timetableFactory.createHandlers( 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({ @@ -191,35 +166,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( @@ -228,10 +182,7 @@ export const exportMovedLessonsRoute = timetableFactory.createHandlers( 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({ @@ -330,8 +281,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/modules/timetable/groups.ts similarity index 53% rename from apps/chronos/src/routes/timetable/groups.ts rename to apps/chronos/src/modules/timetable/groups.ts index d223f2d8..e754e469 100644 --- a/apps/chronos/src/routes/timetable/groups.ts +++ b/apps/chronos/src/modules/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 { cohort, cohortGroup, userGroup } from '#modules/timetable/schema'; +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 { items: data, total: data.length }; } ); -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/modules/timetable/import.ts similarity index 60% rename from apps/chronos/src/routes/timetable/import.ts rename to apps/chronos/src/modules/timetable/import.ts index 185c2fe1..f46730f0 100644 --- a/apps/chronos/src/routes/timetable/import.ts +++ b/apps/chronos/src/modules/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/modules/timetable/index.ts similarity index 52% rename from apps/chronos/src/routes/timetable/index.ts rename to apps/chronos/src/modules/timetable/index.ts index 9d778269..74c4256b 100644 --- a/apps/chronos/src/routes/timetable/index.ts +++ b/apps/chronos/src/modules/timetable/index.ts @@ -1,18 +1,9 @@ -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 { requireAuthorization } from '#middleware/auth'; import { cohort, cohortTimetableMtm, @@ -22,75 +13,28 @@ import { substitution, substitutionLessonMTM, timetable, -} from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { ok } from '#utils/http'; +} from '#modules/timetable/schema'; +import { getActiveTimetableId } from '#modules/timetable/utils/active'; +import { cleanupOrphanedCohorts } from '#modules/timetable/utils/cleanup'; +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'; -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 { items: timetables, total: timetables.length }; } ); -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,33 +44,15 @@ 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) => { - const today = dateToYYYYMMDD(new Date()); +export const getAllValidTimetables = base.timetable.timetables.valid.handler( + async () => { + const today = toDateOnlyString(new Date()); const timetables = await db .select() @@ -138,37 +64,14 @@ export const getAllValidTimetables = timetableFactory.createHandlers( ) ); - return ok(c, timetables); + return { items: timetables, total: timetables.length }; } ); -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/modules/timetable/lesson.ts b/apps/chronos/src/modules/timetable/lesson.ts new file mode 100644 index 00000000..99081f53 --- /dev/null +++ b/apps/chronos/src/modules/timetable/lesson.ts @@ -0,0 +1,599 @@ +import { getLogger } from '@logtape/logtape'; +import { ORPCError } from '@orpc/server'; +import { + and, + arrayContains, + arrayOverlaps, + eq, + inArray, + or, +} from 'drizzle-orm'; +import { db } from '#database'; +import { + classroom, + cohort, + lesson, + lessonCohortMTM, + subject, + substitutionLessonMTM, + teacher, +} from '#modules/timetable/schema'; +import { + getActiveTimetableId, + getTimetableIdForDate, +} from '#modules/timetable/utils/active'; +import { enrichLessons } from '#modules/timetable/utils/enrich-lessons'; +import { + getWeekdayInBudapest, + isMatchingWeekday, +} from '#modules/timetable/utils/weekday'; +import { base } from '#orpc'; +import { notFound } from '#utils/http'; + +const logger = getLogger(['chronos', 'lesson']); + +export const getLessonsForCohort = base.timetable.lessons.getForCohort.handler( + async ({ input }) => { + const { cohortId, timetableId } = input; + + const [existingCohort] = await db + .select() + .from(cohort) + .where(eq(cohort.id, cohortId)) + .limit(1); + + if (!existingCohort) { + throw notFound('Cohort not found'); + } + + const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); + + // No active timetable: yield no lessons rather than every timetable. + if (!effectiveTimetableId) { + return { items: [], total: 0 }; + } + + const whereClause = and( + eq(lessonCohortMTM.cohortId, cohortId), + eq(lesson.timetableId, effectiveTimetableId) + ); + + const lessonRows = await db + .select({ lesson }) + .from(lesson) + .innerJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) + .where(whereClause); + + const lessons = lessonRows.map((r) => r.lesson); + + if (lessons.length === 0) { + return { items: [], total: 0 }; + } + + const enriched = await enrichLessons(lessons); + + return { items: enriched, total: enriched.length }; + } +); + +export const getLessonsForTeacher = + base.timetable.lessons.getForTeacher.handler(async ({ input }) => { + const { teacherId, timetableId } = input; + + const [existingTeacher] = await db + .select() + .from(teacher) + .where(eq(teacher.id, teacherId)) + .limit(1); + + if (!existingTeacher) { + throw notFound('Teacher not found'); + } + + const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); + + // No active timetable: yield no lessons rather than every timetable. + if (!effectiveTimetableId) { + return { items: [], total: 0 }; + } + + const whereClause = and( + arrayContains(lesson.teacherIds, [teacherId]), + eq(lesson.timetableId, effectiveTimetableId) + ); + + const lessons = await db.select().from(lesson).where(whereClause); + + if (lessons.length === 0) { + return { items: [], total: 0 }; + } + + const enriched = await enrichLessons(lessons); + + return { items: enriched, total: enriched.length }; + }); + +type CandidateLessonEntry = { + period: number; + subjectShort: string | null; +}; + +// The aSc timetable export encodes substitution ("Helyettesítés") lessons as a +// single subject with short "H"; older exports used "H1"/"H2". +const SUBSTITUTION_SUBJECT_SHORTS = new Set(['H', 'H1', 'H2']); + +function computeCandidateFlags( + teacherLessons: CandidateLessonEntry[], + selectedPeriods: number[] +): { hasConflict: boolean; hasH1: boolean; hasH2: boolean } { + let hasH1 = false; + let hasH2 = false; + + for (const selectedPeriod of selectedPeriods) { + const lessonsAtPeriod = teacherLessons.filter( + (l) => l.period === selectedPeriod + ); + + if (lessonsAtPeriod.length === 0) { + continue; + } + + const hasH1AtPeriod = lessonsAtPeriod.some( + (l) => l.subjectShort === 'H' || l.subjectShort === 'H1' + ); + const hasH2AtPeriod = lessonsAtPeriod.some((l) => l.subjectShort === 'H2'); + + if (hasH1AtPeriod) { + hasH1 = true; + } + if (hasH2AtPeriod) { + hasH2 = true; + } + + const hasConflictLesson = lessonsAtPeriod.some( + (l) => + l.subjectShort === null || + !SUBSTITUTION_SUBJECT_SHORTS.has(l.subjectShort) + ); + if (hasConflictLesson) { + return { hasConflict: true, hasH1, hasH2 }; + } + } + + return { hasConflict: false, hasH1, hasH2 }; +} + +type SubstitutionCandidate = { + hasH1: boolean; + hasH2: boolean; + teacher: { firstName: string; id: string; lastName: string; short: string }; +}; + +function compareSubstituteCandidates( + a: SubstitutionCandidate, + b: SubstitutionCandidate +): number { + if (a.hasH1 && !b.hasH1) { + return -1; + } + if (!a.hasH1 && b.hasH1) { + return 1; + } + if (a.hasH2 && !b.hasH2) { + return -1; + } + if (!a.hasH2 && b.hasH2) { + return 1; + } + const aName = `${a.teacher.lastName} ${a.teacher.firstName}`; + const bName = `${b.teacher.lastName} ${b.teacher.firstName}`; + return aName.localeCompare(bName); +} + +async function buildCandidateLessonsMap( + candidateTeacherIds: string[], + weekday: number, + timetableId: string +): Promise> { + const candidateLessons = await db + .select() + .from(lesson) + .where( + and( + arrayOverlaps(lesson.teacherIds, candidateTeacherIds), + eq(lesson.timetableId, timetableId) + ) + ); + + const enrichedCandidateLessons = await enrichLessons(candidateLessons); + const map = new Map(); + const candidateTeacherIdSet = new Set(candidateTeacherIds); + + for (const candidateLesson of enrichedCandidateLessons) { + if ( + !( + candidateLesson.day && + isMatchingWeekday( + weekday, + candidateLesson.day.name, + candidateLesson.day.short + ) + ) + ) { + continue; + } + + const currentPeriod = candidateLesson.period?.period; + if (typeof currentPeriod !== 'number') { + continue; + } + + const subjectShort = candidateLesson.subject?.short ?? null; + + for (const lessonTeacher of candidateLesson.teachers) { + if (!candidateTeacherIdSet.has(lessonTeacher.id)) { + continue; + } + + const lessons = map.get(lessonTeacher.id) ?? []; + lessons.push({ period: currentPeriod, subjectShort }); + map.set(lessonTeacher.id, lessons); + } + } + + return map; +} + +async function getParallelLessons( + selectedLessons: Awaited>, + missingTeacherId: string, + timetableId: string +): Promise>> { + const periodIds = [ + ...new Set( + selectedLessons + .map((l) => l.period?.id) + .filter((id): id is string => !!id) + ), + ]; + const dayIds = [ + ...new Set( + selectedLessons.map((l) => l.day?.id).filter((id): id is string => !!id) + ), + ]; + + if (periodIds.length === 0 || dayIds.length === 0) { + return []; + } + + const selectedLessonIds = selectedLessons.map((l) => l.id); + + const cohortRows = await db + .select({ cohortId: lessonCohortMTM.cohortId }) + .from(lessonCohortMTM) + .where(inArray(lessonCohortMTM.lessonId, selectedLessonIds)); + + const cohortIds = [...new Set(cohortRows.map((r) => r.cohortId))]; + if (cohortIds.length === 0) { + return []; + } + + const parallelLinkRows = await db + .select({ lessonId: lessonCohortMTM.lessonId }) + .from(lessonCohortMTM) + .where(inArray(lessonCohortMTM.cohortId, cohortIds)); + + const parallelLessonIds = [ + ...new Set( + parallelLinkRows + .map((r) => r.lessonId) + .filter((id) => !selectedLessonIds.includes(id)) + ), + ]; + if (parallelLessonIds.length === 0) { + return []; + } + + const rows = await db + .select() + .from(lesson) + .where( + and( + inArray(lesson.id, parallelLessonIds), + inArray(lesson.periodId, periodIds), + inArray(lesson.dayDefinitionId, dayIds), + eq(lesson.timetableId, timetableId) + ) + ); + + const enriched = await enrichLessons(rows); + return enriched.filter( + (l) => + l.teachers.length > 0 && + !l.teachers.some((t) => t.id === missingTeacherId) + ); +} + +export const getLessonsForTeachers = + base.timetable.lessons.getForTeachers.handler(async ({ input }) => { + const { teacherIds } = input; + const normalizedTeacherIds = Array.from(new Set(teacherIds)); + + const existingTeachers = await db + .select({ id: teacher.id }) + .from(teacher) + .where(inArray(teacher.id, normalizedTeacherIds)); + + const existingTeacherIds = existingTeachers.map((t) => t.id); + + if (existingTeacherIds.length === 0) { + return { items: [], total: 0 }; + } + + const timetableId = await getActiveTimetableId(); + + // No active timetable: yield no lessons rather than every timetable. + if (!timetableId) { + return { items: [], total: 0 }; + } + + const lessons = await db + .select() + .from(lesson) + .where( + and( + eq(lesson.timetableId, timetableId), + or( + ...existingTeacherIds.map((id) => + arrayContains(lesson.teacherIds, [id]) + ) + ) + ) + ); + + const enrichedLessons = await enrichLessons(lessons); + const lessonsByTeacherId = new Map< + string, + (typeof enrichedLessons)[number][] + >(existingTeacherIds.map((id) => [id, []])); + + for (const enrichedLesson of enrichedLessons) { + for (const lessonTeacher of enrichedLesson.teachers) { + if (!lessonsByTeacherId.has(lessonTeacher.id)) { + continue; + } + + lessonsByTeacherId.get(lessonTeacher.id)?.push(enrichedLesson); + } + } + + const data = existingTeacherIds.map((teacherId) => ({ + lessons: lessonsByTeacherId.get(teacherId) ?? [], + teacherId, + })); + + return { items: data, total: data.length }; + }); + +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); + + if (!missingTeacher) { + throw notFound('Teacher not found'); + } + + const weekday = getWeekdayInBudapest(date); + const timetableId = await getTimetableIdForDate(date); + + if (!timetableId) { + return { + availableLessons: [], + parallelLessons: [], + substituteCandidates: [], + }; + } + + const missingTeacherLessons = await db + .select() + .from(lesson) + .where( + and( + arrayContains(lesson.teacherIds, [missingTeacherId]), + eq(lesson.timetableId, timetableId) + ) + ); + + const enrichedMissingTeacherLessons = await enrichLessons( + missingTeacherLessons + ); + + 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 selectedPeriods = selectedLessons + .map((currentLesson) => currentLesson.period?.period) + .filter( + (currentPeriod): currentPeriod is number => + typeof currentPeriod === 'number' + ); + + if (selectedPeriods.length === 0) { + return { + availableLessons, + parallelLessons: [], + substituteCandidates: [], + }; + } + + const parallelLessons = await getParallelLessons( + selectedLessons, + missingTeacherId, + timetableId + ); + + const candidateTeacherIds = normalizedTeacherIds.filter( + (teacherId) => teacherId !== missingTeacherId + ); + + if (candidateTeacherIds.length === 0) { + return { + availableLessons, + parallelLessons, + 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 { + availableLessons, + parallelLessons, + substituteCandidates, + }; + } + ); + +export const getLessonsForRoom = base.timetable.lessons.getForRoom.handler( + async ({ input }) => { + const { classroomId, timetableId } = input; + + const [existingClassroom] = await db + .select() + .from(classroom) + .where(eq(classroom.id, classroomId)) + .limit(1); + + if (!existingClassroom) { + throw notFound('Classroom not found'); + } + + const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); + + // No active timetable: yield no lessons rather than every timetable. + if (!effectiveTimetableId) { + return { items: [], total: 0 }; + } + + const whereClause = and( + arrayContains(lesson.classroomIds, [classroomId]), + eq(lesson.timetableId, effectiveTimetableId) + ); + + const lessons = await db.select().from(lesson).where(whereClause); + + if (lessons.length === 0) { + return { items: [], total: 0 }; + } + + const enriched = await enrichLessons(lessons); + + return { items: enriched, total: enriched.length }; + } +); + +export const getLessonForId = base.timetable.lessons.getForId.handler( + async ({ input }) => { + const { lessonId } = input; + + const lessonRow = await db + .select() + .from(lesson) + .where(eq(lesson.id, lessonId)) + .limit(1); + + if (!lessonRow) { + return null; + } + + const substitutionCohortRow = await db + .select({ name: cohort.name }) + .from(substitutionLessonMTM) + .innerJoin( + lessonCohortMTM, + eq(substitutionLessonMTM.lessonId, lessonCohortMTM.lessonId) + ) + .innerJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) + .where(eq(substitutionLessonMTM.lessonId, lessonId)) + .limit(1); + + const [enriched] = await enrichLessons(lessonRow); + + if (!enriched) { + return null; + } + + return { + ...enriched, + substitutionCohortName: + substitutionCohortRow.length > 0 + ? (substitutionCohortRow[0]?.name ?? null) + : null, + }; + } +); + +export const getSubjects = base.timetable.subjects.handler(async () => { + try { + const subjects = await db.select().from(subject); + 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 new file mode 100644 index 00000000..fd1045f7 --- /dev/null +++ b/apps/chronos/src/modules/timetable/moved-lesson.ts @@ -0,0 +1,482 @@ +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'; +import { db } from '#database'; +import { requireAuthorization } from '#middleware/auth'; +import { + classroom, + dayDefinition, + lesson, + lessonCohortMTM, + movedLesson, + movedLessonLessonMTM, + period, +} from '#modules/timetable/schema'; +import { getActiveTimetableId } from '#modules/timetable/utils/active'; +import { + enrichLessons, + loadLessonsByIds, +} from '#modules/timetable/utils/enrich-lessons'; +import { base } from '#orpc'; +import { badRequest, notFound } from '#utils/http'; +import { + cancelPendingNotification, + dispatchPendingNotification, +} from '#utils/notifications/engine'; + +const ensurePeriodExists = async (periodId: string) => { + const [existingPeriod] = await db + .select({ periodId: period.id }) + .from(period) + .where(eq(period.id, periodId)); + + if (!existingPeriod) { + throw badRequest('Invalid starting period provided'); + } +}; + +const ensureDayDefinitionExists = async (dayId: string) => { + const [existingDay] = await db + .select({ dayId: dayDefinition.id }) + .from(dayDefinition) + .where(eq(dayDefinition.id, dayId)); + + if (!existingDay) { + throw badRequest('Invalid starting day provided'); + } +}; + +const ensureClassroomExists = async (classroomId: string) => { + const [existingRoom] = await db + .select({ classroomId: classroom.id }) + .from(classroom) + .where(eq(classroom.id, classroomId)); + + if (!existingRoom) { + throw badRequest('Invalid classroom provided'); + } +}; + +const ensureLessonsExist = async ( + lessonIds: string[] +): Promise<{ periodId: string }[]> => { + const timetableId = await getActiveTimetableId(); + + const lessonRecords = await db + .select({ lessonId: lesson.id, periodId: lesson.periodId }) + .from(lesson) + .where( + timetableId + ? and( + inArray(lesson.id, lessonIds), + eq(lesson.timetableId, timetableId) + ) + : sql`false` + ); + + const foundLessonIds = new Set(lessonRecords.map(({ lessonId }) => lessonId)); + const missingLessonIds = lessonIds.filter( + (lessonId) => !foundLessonIds.has(lessonId) + ); + + if (missingLessonIds.length > 0) { + throw badRequest( + `Invalid lesson ids provided: ${missingLessonIds.join(', ')}` + ); + } + + return lessonRecords; +}; + +const normalizeOptionalString = ( + value: unknown, + label: string +): string | undefined => { + if (value === undefined || value === null) { + return; + } + + if (typeof value !== 'string') { + throw badRequest(`${label} must be a string`); + } + + return value; +}; + +const normalizeOptionalStringArray = ( + value: unknown, + label: string +): string[] | undefined => { + if (value === undefined || value === null) { + return; + } + + if (!Array.isArray(value)) { + throw badRequest(`${label} must be an array`); + } + + return value.map((entry) => { + if (typeof entry !== 'string') { + throw badRequest(`${label} must contain only strings`); + } + + return entry; + }); +}; + +const validateMovedLessonReferences = async (options: { + startingPeriod?: unknown; + startingDay?: unknown; + room?: unknown; + lessonIds?: unknown; +}) => { + const { startingPeriod, startingDay, room, lessonIds } = options; + + const normalizedStartingPeriod = normalizeOptionalString( + startingPeriod, + 'Starting period' + ); + if (normalizedStartingPeriod) { + await ensurePeriodExists(normalizedStartingPeriod); + } + + const normalizedStartingDay = normalizeOptionalString( + startingDay, + 'Starting day' + ); + if (normalizedStartingDay) { + await ensureDayDefinitionExists(normalizedStartingDay); + } + + const normalizedRoom = normalizeOptionalString(room, 'Classroom'); + if (normalizedRoom) { + await ensureClassroomExists(normalizedRoom); + } + + const normalizedLessonIds = normalizeOptionalStringArray( + lessonIds, + 'Lesson ids' + ); + if (normalizedLessonIds && normalizedLessonIds.length > 0) { + const lessonRecords = await ensureLessonsExist(normalizedLessonIds); + + if ( + normalizedStartingPeriod && + lessonRecords.some( + ({ periodId }) => periodId !== normalizedStartingPeriod + ) + ) { + 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. +// Row shape returned by each moved-lesson query before lesson enrichment. +type MovedLessonRow = { + classroom: typeof classroom.$inferSelect | null; + dayDefinition: typeof dayDefinition.$inferSelect | null; + lessons: string[]; + movedLesson: typeof movedLesson.$inferSelect; + period: typeof period.$inferSelect | null; +}; + +// Enrich the linked lesson ids of a batch of moved-lesson rows, preserving the +// target joins and the per-moved-lesson lesson order. A moved lesson 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. +async function attachEnrichedLessons(rows: MovedLessonRow[]) { + const allLessonIds = Array.from(new Set(rows.flatMap((r) => r.lessons))); + const enriched = await enrichLessons(await loadLessonsByIds(allLessonIds)); + const lessonMap = new Map(enriched.map((l) => [l.id, l])); + + return rows.map((r) => ({ + classroom: r.classroom, + dayDefinition: r.dayDefinition, + lessons: r.lessons + .map((id) => lessonMap.get(id)) + .filter((l): l is Lesson => l !== undefined), + movedLesson: r.movedLesson, + period: r.period, + })); +} + +export const getAllMovedLessons = base.timetable.movedLessons.list.handler( + async () => { + const movedLessons = await db + .select({ + classroom, + dayDefinition, + lessons: sql`COALESCE( + ARRAY_AGG(DISTINCT ${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), + ARRAY[]::text[] + )`.as('lessons'), + movedLesson, + period, + }) + .from(movedLesson) + .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) + .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) + .leftJoin(classroom, eq(movedLesson.room, classroom.id)) + .leftJoin( + movedLessonLessonMTM, + eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) + ) + .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); + + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; + } +); + +export const getRelevantMovedLessons = + base.timetable.movedLessons.relevant.handler(async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const movedLessons = await db + .select({ + classroom, + dayDefinition, + lessons: sql`COALESCE( + ARRAY_AGG(${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), + ARRAY[]::text[] + )`.as('lessons'), + movedLesson, + period, + }) + .from(movedLesson) + .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) + .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) + .leftJoin(classroom, eq(movedLesson.room, classroom.id)) + .leftJoin( + movedLessonLessonMTM, + eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) + ) + .where(gte(movedLesson.date, today)) + .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); + + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; + }); + +export const getMovedLessonsForCohort = + base.timetable.movedLessons.forCohort.handler(async ({ input }) => { + const { cohortId } = input; + + const movedLessons = await db + .select({ + classroom, + dayDefinition, + lessons: sql`COALESCE( + ARRAY_AGG(DISTINCT ${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), + ARRAY[]::text[] + )`.as('lessons'), + movedLesson, + period, + }) + .from(movedLesson) + .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) + .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) + .leftJoin(classroom, eq(movedLesson.room, classroom.id)) + .leftJoin( + movedLessonLessonMTM, + eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) + ) + .leftJoin(lesson, eq(movedLessonLessonMTM.lessonId, lesson.id)) + .leftJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) + .where(eq(lessonCohortMTM.cohortId, cohortId)) + .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); + + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; + }); + +export const getRelevantMovedLessonsForCohort = + base.timetable.movedLessons.relevantForCohort.handler(async ({ input }) => { + const { cohortId } = input; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const movedLessons = await db + .select({ + classroom, + dayDefinition, + lessons: sql`COALESCE( + ARRAY_AGG(DISTINCT ${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), + ARRAY[]::text[] + )`.as('lessons'), + movedLesson, + period, + }) + .from(movedLesson) + .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) + .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) + .leftJoin(classroom, eq(movedLesson.room, classroom.id)) + .leftJoin( + movedLessonLessonMTM, + eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) + ) + .leftJoin(lesson, eq(movedLessonLessonMTM.lessonId, lesson.id)) + .leftJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) + .where( + and( + eq(lessonCohortMTM.cohortId, cohortId), + gte(movedLesson.date, today) + ) + ) + .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); + + const items = await attachEnrichedLessons(movedLessons); + return { items, total: items.length }; + }); + +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 badRequest('Date is required'); + } + + await validateMovedLessonReferences({ + lessonIds, + room, + startingDay, + startingPeriod, + }); + + // 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 && lessonIds.length > 0) { + await tx.insert(movedLessonLessonMTM).values( + lessonIds.map((lessonId: string) => ({ + lessonId, + movedLessonId: row.id, + })) + ); + } + + return row; + }); + + dispatchPendingNotification(newMovedLesson.id, 'moved_lesson', { + date: newMovedLesson.date, + lessonIds: lessonIds ?? [], + room: newMovedLesson.room, + startingDay: newMovedLesson.startingDay, + startingPeriod: newMovedLesson.startingPeriod, + }); + + return newMovedLesson; + }); + +export const updateMovedLesson = base.timetable.movedLessons.update + .use(requireAuthorization(permissions.movedLessonUpdate)) + .handler(async ({ input }) => { + const { id, lessonIds, ...columns } = input; + + await validateMovedLessonReferences({ ...columns, lessonIds }); + + cancelPendingNotification(id, 'moved_lesson'); + + // 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 (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, + })) + ); + } + } + + return row; + }); + + // 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: updatedMovedLesson.date, + lessonIds: effectiveLessonIds, + room: updatedMovedLesson.room, + startingDay: updatedMovedLesson.startingDay, + startingPeriod: updatedMovedLesson.startingPeriod, + }); + + return updatedMovedLesson; + }); + +export const deleteMovedLesson = base.timetable.movedLessons.delete + .use(requireAuthorization(permissions.movedLessonDelete)) + .handler(async ({ input }) => { + const { id } = input; + + const [deletedMovedLesson] = await db + .delete(movedLesson) + .where(eq(movedLesson.id, id)) + .returning(); + + if (!deletedMovedLesson) { + throw notFound('Moved lesson not found'); + } + + cancelPendingNotification(id, 'moved_lesson'); + + return { id: deletedMovedLesson.id }; + }); diff --git a/apps/chronos/src/modules/timetable/period.ts b/apps/chronos/src/modules/timetable/period.ts new file mode 100644 index 00000000..27a33b50 --- /dev/null +++ b/apps/chronos/src/modules/timetable/period.ts @@ -0,0 +1,43 @@ +import { asc, eq, inArray } from 'drizzle-orm'; +import { db } from '#database'; +import { lesson, period } from '#modules/timetable/schema'; +import { base } from '#orpc'; + +export const getPeriodsForTimetable = base.timetable.periods.getAll.handler( + async ({ input }) => { + const { timetableId } = input; + + let periods: (typeof period.$inferSelect)[]; + + if (timetableId) { + // Get all distinct period IDs used in lessons for this timetable + const usedPeriodIds = await db + .selectDistinct({ periodId: lesson.periodId }) + .from(lesson) + .where(eq(lesson.timetableId, timetableId)); + + const ids = usedPeriodIds.map((r) => r.periodId); + + if (ids.length === 0) { + return { items: [], total: 0 }; + } + + periods = await db + .select() + .from(period) + .where(inArray(period.id, ids)) + .orderBy(asc(period.period)); + } else { + periods = await db.select().from(period).orderBy(asc(period.period)); + } + + const data = periods.map((p) => ({ + endTime: String(p.endTime), + id: p.id, + period: p.period, + startTime: String(p.startTime), + })); + + return { items: data, total: data.length }; + } +); diff --git a/apps/chronos/src/routes/timetable/room.ts b/apps/chronos/src/modules/timetable/room.ts similarity index 50% rename from apps/chronos/src/routes/timetable/room.ts rename to apps/chronos/src/modules/timetable/room.ts index 7a0344d0..7d2544ac 100644 --- a/apps/chronos/src/routes/timetable/room.ts +++ b/apps/chronos/src/modules/timetable/room.ts @@ -1,37 +1,22 @@ -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, lesson, movedLesson, movedLessonLessonMTM, -} from '#database/schema/timetable'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { getTimetableIdForDate } from '#utils/timetable/active'; -import { createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; - -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(), -}); +} from '#modules/timetable/schema'; +import { getTimetableIdForDate } from '#modules/timetable/utils/active'; +import { base } from '#orpc'; +/** + * Legacy room rows carry an empty `short` and duplicate a room that has one, so + * they surface twice in the room pickers: drop them, and give the remaining + * short-less rooms the `A.123` spelling the rest of the data uses. + */ const normalizeRoomKey = (value: string): string => value.toLowerCase().replace(/[^a-z0-9]/g, ''); + const legacyRoomNamePattern = /^([a-z])(\d{3})$/i; function removeLegacyRoomDuplicates( @@ -62,89 +47,20 @@ function removeLegacyRoomDuplicates( }); } -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, removeLegacyRoomDuplicates(classrooms)); + return { items: classrooms, total: classrooms.length }; } ); -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; + + // A request without an explicit timetable falls back to the one that owns + // the requested date. When neither resolves, no lesson may count as + // occupying a room — filtering by nothing would mark every room busy. const effectiveTimetableId = timetableId ?? (await getTimetableIdForDate(date)); @@ -205,6 +121,6 @@ export const getAvailableClassrooms = timetableFactory.createHandlers( ) ); - return ok(c, removeLegacyRoomDuplicates(availableClassrooms)); - } -); + const items = removeLegacyRoomDuplicates(availableClassrooms); + return { items, total: items.length }; + }); diff --git a/apps/chronos/src/database/schema/timetable.ts b/apps/chronos/src/modules/timetable/schema.ts similarity index 100% rename from apps/chronos/src/database/schema/timetable.ts rename to apps/chronos/src/modules/timetable/schema.ts diff --git a/apps/chronos/src/routes/timetable/substitution.ts b/apps/chronos/src/modules/timetable/substitution.ts similarity index 67% rename from apps/chronos/src/routes/timetable/substitution.ts rename to apps/chronos/src/modules/timetable/substitution.ts index 3739060b..f2edae5b 100644 --- a/apps/chronos/src/routes/timetable/substitution.ts +++ b/apps/chronos/src/modules/timetable/substitution.ts @@ -1,16 +1,9 @@ -import { - cohortIdParamsSchema, - manualCreateSchema, - substitutionIdParamsSchema, -} from '@filcdev/api/domains/timetable/substitution'; -import { zValidator } from '@hono/zod-validator'; +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'; -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 { requireAuthorization } from '#middleware/auth'; import { cohort, dayDefinition, @@ -23,63 +16,24 @@ import { teacher, termDefinition, weekDefinition, -} from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { env } from '#utils/environment'; -import { ok } 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'; +} from '#modules/timetable/schema'; +import { getActiveTimetableId } from '#modules/timetable/utils/active'; import { - enrichedLessonSchema, enrichLessons, -} from '#utils/timetable/enrich-lessons'; + loadLessonsByIds, +} from '#modules/timetable/utils/enrich-lessons'; import { getWeekdayInBudapest, isMatchingWeekday, -} from '#utils/timetable/weekday'; +} from '#modules/timetable/utils/weekday'; +import { base } from '#orpc'; +import { env } from '#utils/environment'; +import { badRequest, conflict, notFound } from '#utils/http'; 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(), -}); + cancelPendingNotification, + dispatchPendingNotification, +} from '#utils/notifications/engine'; +import { loadSubstitutionTeacherPayload } from '#utils/notifications/substitution-teacher'; // Type for both database and transaction instances used by helpers type TxOrDb = typeof db | Parameters[0]>[0]; @@ -163,10 +117,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 +261,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 +302,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 +327,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 +345,7 @@ export const getAllSubstitutions = timetableFactory.createHandlers( .groupBy( substitution.id, teacher.id, + teacher.email, teacher.firstName, teacher.lastName, teacher.short, @@ -423,12 +361,16 @@ export const getAllSubstitutions = timetableFactory.createHandlers( // 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(Boolean), + lessons: s.lessonIds + .map((id) => lessonMap.get(id)) + .filter((l): l is Lesson => l !== undefined), substitution: s.substitution, teacher: s.teacher, })); @@ -452,27 +394,12 @@ export const getAllSubstitutions = timetableFactory.createHandlers( return minPeriod(a) - minPeriod(b); }); - return ok(c, result); + return { items: result, total: result.length }; } ); -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 +424,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 { items: substitutions, total: substitutions.length }; + }); 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 +496,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 +534,8 @@ export const createSubstitution = timetableFactory.createHandlers( }) ); - return ok(c, result); - } -); + return result; + }); // Manual substitution creation. // @@ -688,11 +545,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 +637,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 +664,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 +675,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 +697,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 +712,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 +723,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 +745,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 +783,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 +818,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 +833,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 +842,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 +886,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 +921,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 +936,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 +945,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/modules/timetable/teacher.ts b/apps/chronos/src/modules/timetable/teacher.ts new file mode 100644 index 00000000..e3d616ff --- /dev/null +++ b/apps/chronos/src/modules/timetable/teacher.ts @@ -0,0 +1,171 @@ +import type { TeacherListItem } from '@filcdev/api/domains/timetable/teacher'; +import { permissions } from '@filcdev/api/permissions'; +import { eq } from 'drizzle-orm'; +import { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { requireAuthentication, requireAuthorization } from '#middleware/auth'; +import { teacher } from '#modules/timetable/schema'; +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 = 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 { items: teachers, total: teachers.length }; +}); + +/** + * 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 = base.timetable.teachers.me + .use(requireAuthentication) + .handler(async ({ context }) => { + const userId = context.session.userId; + + const [row] = await db + .select({ + firstName: teacher.firstName, + id: teacher.id, + lastName: teacher.lastName, + short: teacher.short, + }) + .from(teacher) + .where(eq(teacher.userId, userId)) + .orderBy(teacher.id) + .limit(1); + + context.resHeaders?.set('Cache-Control', 'no-store'); + return row ?? null; + }); + +/** Admin teacher list, including email and the linked user account. */ +export const listTeachersAdmin = base.timetable.teachers.list + .use(requireAuthorization(permissions.teacherManage)) + .handler(async () => { + const rows = await db + .select({ + email: teacher.email, + firstName: teacher.firstName, + id: teacher.id, + lastName: teacher.lastName, + short: teacher.short, + userEmail: user.email, + userId: teacher.userId, + userName: user.name, + }) + .from(teacher) + .leftJoin(user, eq(user.id, teacher.userId)); + + const data: TeacherListItem[] = rows.map((row) => ({ + email: row.email, + firstName: row.firstName, + id: row.id, + lastName: row.lastName, + short: row.short, + user: row.userId + ? { + email: row.userEmail ?? '', + id: row.userId, + name: row.userName ?? '', + } + : null, + userId: row.userId, + })); + + return { items: data, total: data.length }; + }); + +/** Manually set a teacher's email and/or linked user. */ +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 }) + .from(teacher) + .where(eq(teacher.id, id)) + .limit(1); + if (!existing) { + throw notFound('Teacher not found'); + } + + if (body.userId !== undefined && body.userId !== null) { + const [linkedUser] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.id, body.userId)) + .limit(1); + if (!linkedUser) { + throw badRequest('The linked user does not exist'); + } + } + + let email: string | null | undefined; + if (body.email === undefined) { + email = undefined; + } else if (body.email === null) { + email = null; + } else { + email = body.email.toLowerCase(); + } + + await db + .update(teacher) + .set({ + email, + userId: body.userId, + }) + .where(eq(teacher.id, id)); + + const [updated] = await db + .select({ + email: teacher.email, + firstName: teacher.firstName, + id: teacher.id, + lastName: teacher.lastName, + short: teacher.short, + userEmail: user.email, + userId: teacher.userId, + userName: user.name, + }) + .from(teacher) + .leftJoin(user, eq(user.id, teacher.userId)) + .where(eq(teacher.id, id)) + .limit(1); + + if (!updated) { + throw notFound('Teacher not found'); + } + + const data: TeacherListItem = { + email: updated.email, + firstName: updated.firstName, + id: updated.id, + lastName: updated.lastName, + short: updated.short, + user: updated.userId + ? { + email: updated.userEmail ?? '', + id: updated.userId, + name: updated.userName ?? '', + } + : null, + userId: updated.userId, + }; + + return data; + }); diff --git a/apps/chronos/src/utils/timetable/active.ts b/apps/chronos/src/modules/timetable/utils/active.ts similarity index 80% rename from apps/chronos/src/utils/timetable/active.ts rename to apps/chronos/src/modules/timetable/utils/active.ts index 6d8d2c7d..ca6a9092 100644 --- a/apps/chronos/src/utils/timetable/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 '#database/schema/timetable'; -import { dateToYYYYMMDD } from './date'; +import { timetable } from '#modules/timetable/schema'; +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/utils/timetable/cleanup.ts b/apps/chronos/src/modules/timetable/utils/cleanup.ts similarity index 99% rename from apps/chronos/src/utils/timetable/cleanup.ts rename to apps/chronos/src/modules/timetable/utils/cleanup.ts index dc9ca2ac..03678587 100644 --- a/apps/chronos/src/utils/timetable/cleanup.ts +++ b/apps/chronos/src/modules/timetable/utils/cleanup.ts @@ -9,7 +9,7 @@ import { lesson, substitution, teacher, -} from '#database/schema/timetable'; +} from '#modules/timetable/schema'; const logger = getLogger(['chronos', 'timetable', 'cleanup']); diff --git a/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts b/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts new file mode 100644 index 00000000..13b31094 --- /dev/null +++ b/apps/chronos/src/modules/timetable/utils/enrich-lessons.ts @@ -0,0 +1,213 @@ +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 { distinctIds } from '#utils/collections'; + +/** 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([]); + } + + 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 = 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); + + 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, + }; + }); +} diff --git a/apps/chronos/src/utils/timetable/remap-substitutions.ts b/apps/chronos/src/modules/timetable/utils/remap-substitutions.ts similarity index 99% rename from apps/chronos/src/utils/timetable/remap-substitutions.ts rename to apps/chronos/src/modules/timetable/utils/remap-substitutions.ts index 4631a145..3938ac6a 100644 --- a/apps/chronos/src/utils/timetable/remap-substitutions.ts +++ b/apps/chronos/src/modules/timetable/utils/remap-substitutions.ts @@ -4,7 +4,7 @@ import { lesson, lessonCohortMTM, substitutionLessonMTM, -} from '#database/schema/timetable'; +} from '#modules/timetable/schema'; type TxClient = Parameters[0]>[0]; diff --git a/apps/chronos/src/utils/timetable/weekday.ts b/apps/chronos/src/modules/timetable/utils/weekday.ts similarity index 100% rename from apps/chronos/src/utils/timetable/weekday.ts rename to apps/chronos/src/modules/timetable/utils/weekday.ts diff --git a/apps/chronos/src/modules/users/_router.ts b/apps/chronos/src/modules/users/_router.ts new file mode 100644 index 00000000..8110dd0b --- /dev/null +++ b/apps/chronos/src/modules/users/_router.ts @@ -0,0 +1,6 @@ +import { listUsers, updateUser } from '#modules/users/index'; + +export const usersRouter = { + list: listUsers, + update: updateUser, +}; diff --git a/apps/chronos/src/modules/users/index.ts b/apps/chronos/src/modules/users/index.ts new file mode 100644 index 00000000..c5ca0084 --- /dev/null +++ b/apps/chronos/src/modules/users/index.ts @@ -0,0 +1,80 @@ +import { permissions } from '@filcdev/api/permissions'; +import { arrayContains, count, desc, eq, ilike, or } from 'drizzle-orm'; +import { db } from '#database'; +import { user } from '#database/schema/authentication'; +import { requireAuthorization } from '#middleware/auth'; +import { base } from '#orpc'; +import { getUserPermissionsBulk } from '#utils/authorization'; +import { notFound } from '#utils/http'; + +export const listUsers = base.users.list + .use(requireAuthorization(permissions.usersRead)) + .handler(async ({ input }) => { + const { limit, offset, role, search } = input; + + const whereClause = or( + search + ? or( + ilike(user.name, `%${search}%`), + ilike(user.email, `%${search}%`), + ilike(user.nickname, `%${search}%`) + ) + : undefined, + role ? arrayContains(user.roles, [role]) : undefined + ); + + const usersQuery = await db + .select() + .from(user) + .where(whereClause) + .limit(limit) + .offset(offset) + .orderBy(desc(user.createdAt)); + + const permissionsByUser = await getUserPermissionsBulk( + usersQuery.map((u) => u.id) + ); + + const users = usersQuery.map((u) => ({ + ...u, + displayName: u.nickname ? u.nickname : u.name || 'Unknown user', + permissions: permissionsByUser.get(u.id) ?? [], + })); + + const [countResult] = await db + .select({ count: count() }) + .from(user) + .where(whereClause); + + return { items: users, total: countResult?.count ?? 0 }; + }); + +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) + .set({ + ...(cohortId === undefined ? {} : { cohortId }), + ...(nickname === undefined ? {} : { nickname }), + ...(roles === undefined ? {} : { roles }), + }) + .where(eq(user.id, userId)) + .returning(); + + if (!updatedUser) { + throw notFound('User not found'); + } + + 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/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..10a85dd9 --- /dev/null +++ b/apps/chronos/src/modules/wifi/admin.ts @@ -0,0 +1,575 @@ +import { permissions } from '@filcdev/api/permissions'; +import { ORPCError } from '@orpc/server'; +import { + and, + asc, + count, + 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 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 + .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 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 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 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 + .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 () => { + 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) + .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)) + ).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 + .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 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 serializeSpeedProfile(profile, updated?.isWlanDefault ?? false); + }); + +export const deleteWifiSpeedProfile = base.wifi.speedProfiles.delete + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiWrite)) + .handler(async ({ input }) => { + // 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 }; + }); + +export const listWifiRoleProfiles = base.wifi.roleSpeedProfiles.list + .use(requireWifiEnabled) + .use(requireAuthorization(permissions.wifiRead)) + .handler(async () => { + const roleSpeedProfiles = await db + .select() + .from(wifiRoleSpeedProfile) + .orderBy(desc(wifiRoleSpeedProfile.priority)); + return { items: roleSpeedProfiles, total: roleSpeedProfiles.length }; + }); + +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 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) + ) + ) + .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/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..8a6451cd --- /dev/null +++ b/apps/chronos/src/modules/wifi/self.ts @@ -0,0 +1,326 @@ +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 (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; +}; + +/** + * 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 { + 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 { + 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..c36b6af3 --- /dev/null +++ b/apps/chronos/src/modules/wifi/stats.ts @@ -0,0 +1,53 @@ +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 { + 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/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..0e6c9bcf --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/controller.ts @@ -0,0 +1,78 @@ +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; + 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 + ) + ) { + // 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; + } + 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..ee5aac81 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/radius.ts @@ -0,0 +1,337 @@ +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 { + // 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(); + } +}; + +const normalizeNasMac = (value: string | undefined): string | null => { + if (!value) { + return 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; + } +}; + +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) => + // 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), + ]) + .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..1e7b7cb1 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/sync.ts @@ -0,0 +1,89 @@ +import { getLogger } from '@logtape/logtape'; +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'; + +const logger = getLogger(['chronos', 'unifi', 'sync']); + +/** + * 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(); + + 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( + 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..7eea60ba --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/unifi-api.ts @@ -0,0 +1,473 @@ +import { getLogger } from '@logtape/logtape'; + +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'; + +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; + +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; + } + + logger.debug('Probing UniFi controller at {baseUrl}', { + baseUrl: this.config.baseUrl, + }); + + 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; + + // 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 + 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 { + 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'; + 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) { + 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') { + 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'); + + 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 startedAt = Date.now(); + const response = await fetch( + targetUrl, + this.baseInit({ ...init, headers }) + ); + const status = response.status; + + this.updateTokens(response); + + if (status === 401) { + throw new Error(LOGIN_REQUIRED); + } + if (!response.ok) { + // 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; + if (body.meta?.rc === 'error') { + 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; + }; + + if (this.cookies.length === 0) { + await this.login(); + } + + try { + 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; + } + } + + // --- Endpoints --- + + getClientDevice = async (macAddress: string): Promise => + 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}`, { + body: JSON.stringify({ name }), + method: 'PUT', + }); + + getAccessDevices = async (): Promise => + dataArray( + await this.request( + `/api/s/${this.config.site}/stat/device` + ), + 'GET 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 => + dataArray( + await this.request( + `/api/s/${this.config.site}/rest/usergroup` + ), + 'GET rest/usergroup' + ); + + getWlanConf = async (): Promise => + 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 = 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 UnifiApiError( + 'UniFi accepted the usergroup create but returned no row', + 'api.err.NoData' + ); + } + return res[0]; + }; + + editUserGroup = async ( + id: string, + siteId: string, + name: string, + downloadMbps: number, + uploadMbps: number + ): Promise => { + 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 UnifiApiError( + 'UniFi accepted the usergroup edit but returned no row', + 'api.err.NoData' + ); + } + 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); + 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 ( + macAddress: string, + speedProfileId: string + ): 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}`, { + 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..6a6dd0f4 --- /dev/null +++ b/apps/chronos/src/modules/wifi/utils/unifi-controller.ts @@ -0,0 +1,132 @@ +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); + 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( + 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/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..baafda9c --- /dev/null +++ b/apps/chronos/src/router.ts @@ -0,0 +1,43 @@ +import { rateLimit } from '#middleware/rate-limit'; +import { apiKeysRouter } from '#modules/api-keys/_router'; +import { bugReportRouter } from '#modules/bug-report/_router'; +import { cohortRouter } from '#modules/cohort/_router'; +import { dashboardRouter } from '#modules/dashboard/_router'; +import { doorlockRouter } from '#modules/doorlock/_router'; +import { healthRouter } from '#modules/health/_router'; +import { kioskRouter } from '#modules/kiosk/_router'; +import { navigatorRouter } from '#modules/navigator/_router'; +import { newsRouter } from '#modules/news/_router'; +import { notificationsRouter } from '#modules/notifications/_router'; +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'; + +/** + * 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({ + adminApiKeys: apiKeysRouter, + 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, + wifi: wifiRouter, +}); 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 deleted file mode 100644 index d5f996ed..00000000 --- a/apps/chronos/src/routes/bug-report/_router.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { bugReportFactory } from '#routes/bug-report/_factory'; -import { - createBugReport, - deleteBugReport, - listBugReports, - updateBugReportStatus, -} from '#routes/bug-report/index'; - -export const bugReportRouter = bugReportFactory - .createApp() - .post('/', ...createBugReport) - .get('/', ...listBugReports) - .patch('/:id/status', ...updateBugReportStatus) - .delete('/:id', ...deleteBugReport); diff --git a/apps/chronos/src/routes/bug-report/index.ts b/apps/chronos/src/routes/bug-report/index.ts deleted file mode 100644 index e4b57851..00000000 --- a/apps/chronos/src/routes/bug-report/index.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { createBugReportSchema } from '@filcdev/api/domains/bug-report'; -import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; -import { - and, - count, - desc, - eq, - gte, - ilike, - lte, - 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'; - -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; - - const [inserted] = await db - .insert(bugReport) - .values({ - description, - metadata, - page, - reporterEmail: user?.email ?? null, - reporterId: user?.id ?? null, - subject, - }) - .returning({ id: bugReport.id }); - - if (!inserted) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to store bug report', - }); - } - - return created(c, { 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'); - - const conditions: (SQL | undefined)[] = []; - - if (status) { - conditions.push(eq(bugReport.status, status)); - } - if (dateFrom) { - conditions.push(gte(bugReport.createdAt, new Date(dateFrom))); - } - if (dateTo) { - conditions.push(lte(bugReport.createdAt, new Date(dateTo))); - } - if (search) { - const pattern = `%${search}%`; - conditions.push( - or( - ilike(bugReport.subject, pattern), - ilike(bugReport.description, pattern), - ilike(bugReport.reporterEmail, pattern) - ) - ); - } - - const where = conditions.length > 0 ? and(...conditions) : undefined; - const offset = (page - 1) * limit; - - const [reports, totalResult] = await Promise.all([ - db - .select() - .from(bugReport) - .where(where) - .orderBy(desc(bugReport.createdAt)) - .limit(limit) - .offset(offset), - db.select({ count: count() }).from(bugReport).where(where), - ]); - - return ok(c, { 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'); - - const [existing] = await db - .select() - .from(bugReport) - .where(eq(bugReport.id, id)); - - if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Bug report not found', - }); - } - - const [updated] = await db - .update(bugReport) - .set({ status }) - .where(eq(bugReport.id, id)) - .returning(); - - if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Bug report not found', - }); - } - - return ok(c, 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'); - - const [deleted] = await db - .delete(bugReport) - .where(eq(bugReport.id, id)) - .returning(); - - if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Bug report not found', - }); - } - - return ok(c, undefined); - } -); 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 deleted file mode 100644 index 554964dc..00000000 --- a/apps/chronos/src/routes/cohort/_router.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { cohortFactory } from '#routes/cohort/_factory'; -import { listCohorts } from '#routes/cohort/index'; - -export const cohortRouter = cohortFactory.createApp().get('/', ...listCohorts); diff --git a/apps/chronos/src/routes/cohort/index.ts b/apps/chronos/src/routes/cohort/index.ts deleted file mode 100644 index db78eb4b..00000000 --- a/apps/chronos/src/routes/cohort/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -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'; - -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); - - return ok(c, 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 deleted file mode 100644 index f681e60b..00000000 --- a/apps/chronos/src/routes/dashboard/_router.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { dashboardFactory } from '#routes/dashboard/_factory'; -import { getDashboardStats } from '#routes/dashboard/index'; - -export const dashboardRouter = dashboardFactory - .createApp() - .get('/stats', ...getDashboardStats); 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 deleted file mode 100644 index c5ad0ba1..00000000 --- a/apps/chronos/src/routes/doorlock/_router.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { doorlockFactory } from '#routes/doorlock/_factory'; -import { - createCardRoute, - deleteCardRoute, - listCardsRoute, - listDoorlockUsersRoute, - updateCardRoute, -} from '#routes/doorlock/cards'; -import { - createDeviceRoute, - deleteDeviceRoute, - listDevicesRoute, - updateDeviceRoute, -} from '#routes/doorlock/devices'; -import { exportLogsRoute } from '#routes/doorlock/export'; -import { listLogsRoute } from '#routes/doorlock/logs'; -import { - triggerBulkOtaRoute, - triggerDeviceOtaRoute, -} from '#routes/doorlock/ota'; -import { - activateVirtualCardRoute, - listSelfCardsRoute, - updateSelfCardFrozenRoute, -} from '#routes/doorlock/self'; -import { deviceStatsRoute, doorlockStatsRoute } from '#routes/doorlock/stats'; -import { websocketHandler } from '#routes/doorlock/websocket-handler'; - -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); diff --git a/apps/chronos/src/routes/doorlock/cards.ts b/apps/chronos/src/routes/doorlock/cards.ts deleted file mode 100644 index f6cefaba..00000000 --- a/apps/chronos/src/routes/doorlock/cards.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - createCardSchema, - updateCardSchema, -} from '@filcdev/api/domains/doorlock/cards'; -import { idParamSchema } from '@filcdev/api/domains/doorlock/devices'; -import { zValidator } from '@hono/zod-validator'; -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 { - type DoorlockCardWithRelations, - fetchCardById, - fetchCards, - migrateAuditLogsForNewCard, - 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(); - -const assertCardExists = (cardRecord?: DoorlockCardWithRelations | null) => { - if (!cardRecord) { - throw notFound('Card not found'); - } - 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) => { - const cards = await fetchCards(); - - return ok(c, { 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) => { - const usersList = await db - .select({ - email: user.email, - id: user.id, - name: user.name, - nickname: user.nickname, - }) - .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'); - - const cardId = await db.transaction(async (tx) => { - const [inserted] = await tx - .insert(card) - .values({ - cardData: payload.cardData, - enabled: payload.enabled, - frozen: payload.frozen, - name: payload.name, - userId: payload.userId ?? null, - }) - .returning({ id: card.id }); - - if (!inserted) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to create card', - }); - } - - await replaceCardDevices(tx, inserted.id, payload.authorizedDeviceIds); - if (payload.userId) { - await migrateAuditLogsForNewCard( - tx, - inserted.id, - payload.userId, - payload.cardData - ); - } - return inserted.id; - }); - - const createdCard = assertCardExists(await fetchCardById(cardId)); - - await syncDevicesByIds( - createdCard.authorizedDevices.map( - (authorizedDevice) => authorizedDevice.id - ) - ); - - return created(c, { 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'); - - await db.transaction(async (tx) => { - const [updated] = await tx - .update(card) - .set({ - enabled: payload.enabled, - frozen: payload.frozen, - name: payload.name, - userId: payload.userId ?? null, - }) - .where(eq(card.id, cardId)) - .returning({ id: card.id }); - - if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Card not found', - }); - } - - await replaceCardDevices(tx, cardId, payload.authorizedDeviceIds); - }); - - const updatedCard = assertCardExists(await fetchCardById(cardId)); - - await syncDevicesByIds( - updatedCard.authorizedDevices.map( - (authorizedDevice) => authorizedDevice.id - ) - ); - - return ok(c, { 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'); - - const existingCard = await fetchCardById(cardId); - if (!existingCard) { - throw notFound('Card not found'); - } - const [deleted] = await db - .delete(card) - .where(eq(card.id, cardId)) - .returning({ id: card.id }); - - if (!deleted) { - throw notFound('Card not found'); - } - - await syncDevicesByIds( - existingCard.authorizedDevices.map( - (authorizedDevice) => authorizedDevice.id - ) - ); - - return ok(c, undefined); - } -); diff --git a/apps/chronos/src/routes/doorlock/devices.ts b/apps/chronos/src/routes/doorlock/devices.ts deleted file mode 100644 index 19fa10f7..00000000 --- a/apps/chronos/src/routes/doorlock/devices.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { - type DevicePayloadInput, - devicePayloadSchema, - idParamSchema, -} from '@filcdev/api/domains/doorlock/devices'; -import { zValidator } from '@hono/zod-validator'; -import { getLogger } from '@logtape/logtape'; -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'; - -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, - lastResetReason: payload.lastResetReason ?? null, - location: payload.location ?? null, - name: payload.name, - } satisfies typeof device.$inferInsert; -} - -function buildConstraintError(error: unknown) { - if ( - typeof error === 'object' && - error !== null && - '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 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) => { - 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'); - - try { - const [inserted] = await db - .insert(device) - .values(mapDevicePayload(payload)) - .returning(); - - return created(c, { 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, { - 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'); - - try { - const [updated] = await db - .update(device) - .set(mapDevicePayload(payload)) - .where(eq(device.id, id)) - .returning(); - - if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Device not found', - }); - } - - return ok(c, { device: updated }); - } catch (error) { - logger.error('Failed to update device', { error }); - const knownError = buildConstraintError(error); - if (knownError) { - throw knownError; - } - if (error instanceof HTTPException) { - throw error; - } - throw new HTTPException(StatusCodes.INTERNAL_SERVER_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'); - - const [deleted] = await db - .delete(device) - .where(eq(device.id, id)) - .returning(); - - if (!deleted) { - throw notFound('Device not found'); - } - - return ok(c, undefined); - } -); diff --git a/apps/chronos/src/routes/doorlock/logs.ts b/apps/chronos/src/routes/doorlock/logs.ts deleted file mode 100644 index 453ffaa3..00000000 --- a/apps/chronos/src/routes/doorlock/logs.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { - type LogsQueryInput, - logsQuerySchema, -} from '@filcdev/api/domains/doorlock/logs'; -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, -}); - -type DeviceSummary = Pick; -type CardSummary = Pick; -type UserSummary = Pick< - typeof user.$inferSelect, - 'id' | 'name' | 'email' | 'nickname' ->; - -export type DoorlockLogEntry = typeof auditLog.$inferSelect & { - device?: DeviceSummary | null; - card?: CardSummary | null; - 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)); - } - if (query.deviceId) { - filters.push(eq(auditLog.deviceId, query.deviceId)); - } - if (query.userId) { - filters.push(eq(auditLog.userId, query.userId)); - } - if (typeof query.granted === 'boolean') { - filters.push(eq(auditLog.result, query.granted)); - } - if (query.from) { - filters.push(gte(auditLog.timestamp, new Date(query.from))); - } - if (query.to) { - filters.push(lte(auditLog.timestamp, new Date(query.to))); - } -}; - -const appendSearchFilters = (query: LogsQueryInput) => { - const { search: searchTerm } = query; - if (!searchTerm) { - return; - } - - const pattern = `%${searchTerm}%`; - - return or( - ilike(device.name, pattern), - ilike(card.name, pattern), - ilike(user.name, pattern), - ilike(user.nickname, pattern), - ilike(auditLog.cardData, pattern) - ); -}; - -const buildWhereClause = (query: LogsQueryInput): SQL | undefined => { - const filters: SQL[] = []; - - appendBaseFilters(filters, query); - const filter = appendSearchFilters(query); - if (filter) { - filters.push(filter); - } - - if (!filters.length) { - return; - } - - let combined: SQL | undefined; - for (const clause of filters) { - combined = combined ? and(combined, clause) : clause; - } - return combined; -}; - -const mapRowsToLogs = ( - rows: Array<{ - buttonPressed: boolean; - cardData: string | null; - cardId: string | null; - deviceId: string; - id: number; - logCardId: string | null; - logCardName: string | null; - logDeviceId: string | null; - logDeviceName: string | null; - ownerEmail: string | null; - ownerId: string | null; - ownerName: string | null; - ownerNickname: string | null; - result: boolean; - timestamp: Date; - userId: string | null; - }> -) => - rows.map((row) => ({ - buttonPressed: row.buttonPressed, - card: row.logCardId - ? { id: row.logCardId, name: row.logCardName ?? 'Unknown card' } - : null, - cardData: row.cardData, - cardId: row.cardId, - device: row.logDeviceId - ? { id: row.logDeviceId, name: row.logDeviceName ?? 'Unknown device' } - : null, - deviceId: row.deviceId, - id: row.id, - owner: row.ownerId - ? { - email: row.ownerEmail ?? 'unknown@example.com', - id: row.ownerId, - name: row.ownerName ?? 'Unknown user', - nickname: row.ownerNickname, - } - : null, - result: row.result, - timestamp: row.timestamp, - 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); - - 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 logs = mapRowsToLogs(rows); - - return ok(c, { logs }); - } -); diff --git a/apps/chronos/src/routes/doorlock/ota.ts b/apps/chronos/src/routes/doorlock/ota.ts deleted file mode 100644 index e7363c14..00000000 --- a/apps/chronos/src/routes/doorlock/ota.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { idParamSchema } from '@filcdev/api/domains/doorlock/devices'; -import { otaPayloadSchema } from '@filcdev/api/domains/doorlock/ota'; -import { zValidator } from '@hono/zod-validator'; -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'; - -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'); - - const [dev] = await db - .select({ id: lockDevice.id, name: lockDevice.name }) - .from(lockDevice) - .where(eq(lockDevice.id, id)) - .limit(1); - - if (!dev) { - throw notFound('Device not found'); - } - - logger.info('Triggering OTA update for device', { - device: dev, - url, - }); - - sendMessage({ type: 'update', url }, dev.id); - - return ok(c, undefined); - } -); - -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'); - - const devices = await db - .select({ id: lockDevice.id, name: lockDevice.name }) - .from(lockDevice); - - logger.info('Triggering bulk OTA update', { - count: devices.length, - url, - }); - - for (const dev of devices) { - sendMessage({ type: 'update', url }, dev.id); - } - - return ok(c, { count: devices.length }); - } -); diff --git a/apps/chronos/src/routes/doorlock/self.ts b/apps/chronos/src/routes/doorlock/self.ts deleted file mode 100644 index 9b0cf928..00000000 --- a/apps/chronos/src/routes/doorlock/self.ts +++ /dev/null @@ -1,250 +0,0 @@ -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 { 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 { 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'; - -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); - } - - 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'); - - const [updated] = await db - .update(card) - .set({ frozen: payload.frozen }) - .where(and(eq(card.id, cardId), eq(card.userId, session.userId))) - .returning({ id: card.id }); - - if (!updated) { - throw notFound('Card not found'); - } - - const updatedCard = await fetchCardById(cardId); - - if (!updatedCard || updatedCard.userId !== session.userId) { - logger.warn('Unexpected card fetch after self-update', { - cardId, - userId: session.userId, - }); - throw notFound('Card not found'); - } - - await syncDevicesByIds( - updatedCard.authorizedDevices.map((device) => device.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'); - - const cardRecord = await fetchCardById(cardId); - if (!cardRecord || cardRecord.userId !== session.userId) { - throw notFound('Card not found'); - } - - if (!cardRecord.enabled || cardRecord.frozen) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: 'Card is currently inactive', - }); - } - - if (!cardRecord.authorizedDevices.length) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: 'Card is not authorized on any devices', - }); - } - - const resolveTargetDevice = () => { - if (payload.deviceId) { - const matched = cardRecord.authorizedDevices.find( - (device) => device.id === payload.deviceId - ); - if (!matched) { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: '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', - }); - } - - return cardRecord.authorizedDevices[0]; - }; - - const targetDevice = resolveTargetDevice(); - - if (!targetDevice) { - throw notFound('Target device not found'); - } - - sendMessage( - { - name: - cardRecord.owner?.nickname ?? - cardRecord.owner?.name ?? - cardRecord.name, - type: 'open-door', - }, - targetDevice.id - ); - - const [logEntry] = await db - .insert(auditLog) - .values({ - buttonPressed: true, - cardData: cardRecord.cardData, - cardId: cardRecord.id, - deviceId: targetDevice.id, - result: true, - userId: session.userId, - }) - .returning(); - - return ok(c, { log: logEntry }); - } -); diff --git a/apps/chronos/src/routes/doorlock/websocket-handler.ts b/apps/chronos/src/routes/doorlock/websocket-handler.ts deleted file mode 100644 index f01b941b..00000000 --- a/apps/chronos/src/routes/doorlock/websocket-handler.ts +++ /dev/null @@ -1,312 +0,0 @@ -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 { - auditLog, - card, - cardDevice, - deviceHealth, - 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 { - incomingMessageSchema, - type OutgoingMessage, - outgoingMessageSchema, -} from '#utils/doorlock/schemas'; -import { unauthorized } from '#utils/http'; -import { dispatchImmediateNotification } from '#utils/notifications/engine'; - -const logger = getLogger(['chronos', 'doorlock', 'websocket']); - -const handleIncomingMessage = async ( - message: string, - device: { id: string; name: string } -) => { - try { - logger.trace('Received raw WebSocket message', { device, message }); - const parsed = JSON.parse(message); - const result = incomingMessageSchema.safeParse(parsed); - - if (!result.success) { - logger.warn('Invalid WebSocket message format', { - device, - error: z.treeifyError(result.error), - message, - }); - return { - details: result.error, - error: 'INVALID_MESSAGE_FORMAT', - }; - } - - const deserialized = result.data; - logger.trace('Deserialized WebSocket message', { - deserialized, - device, - message, - }); - - switch (deserialized.type) { - case 'ping': { - logger.trace('Handling ping message', { deserialized, device }); - const getDeviceState = () => { - switch (deserialized.data.debug.deviceState) { - case 0: - return 'booting'; - case 1: - return 'idle'; - case 2: - return 'error'; - case 3: - return 'updating'; - default: - logger.warn('Received unexpected device status', { - deserialized, - device, - }); - return 'error'; - } - }; - - logger.trace('Persisting device health ping', { device }); - await db.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)); - break; - } - case 'card-read': { - logger.trace('Handling card-read message', { deserialized, device }); - - const [cardUser] = await db - .select() - .from(card) - .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, - }); - - 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: - logger.warn('Unhandled message type received', { - deserialized, - device, - }); - } - return null; - } catch (e) { - const err = e instanceof Error ? e : new Error(String(e)); - logger.warn('Message handling failed', { device, error: err, message }); - return { details: err.message, error: 'MESSAGE_HANDLING_FAILED' }; - } -}; - -const normalizeName = (name: string) => - name - .trim() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, ''); - -export const sendMessage = (content: OutgoingMessage, deviceId: string) => { - const validated = outgoingMessageSchema.parse(content); - - const payload: OutgoingMessage = - validated.type === 'open-door' && validated.name - ? { ...validated, name: normalizeName(validated.name) } - : validated; - - logger.trace('Publishing WebSocket message', { deviceId, payload }); - server.publish(`device-${deviceId}`, JSON.stringify(payload)); -}; - -export const syncDatabase = async (deviceId: string) => { - logger.trace('Preparing database sync payload', { deviceId }); - const cards = await db - .select() - .from(card) - .innerJoin(cardDevice, eq(card.id, cardDevice.cardId)) - .where( - and( - eq(cardDevice.deviceId, deviceId), - eq(card.enabled, true), - eq(card.frozen, false) - ) - ); - - const database = cards.map((c) => ({ - name: normalizeName(c.card.name), - uid: c.card.cardData, - })); - - logger.trace('Sending database sync payload', { - count: database.length, - deviceId, - }); - sendMessage( - { - db: database, - type: 'sync-database', - }, - deviceId - ); -}; - -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); - } - - const d = { id: device.id, name: device.name }; - logger.debug('WebSocket connection authorized via device token', { - device: d, - }); - c.set('device', d); - return next(); - } - - // 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 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); - } - - const deviceId = c.req.query('deviceId'); - if (!deviceId) { - throw new HTTPException(400, { - message: 'deviceId query parameter is required when using an API key', - }); - } - - const [device] = await db - .select() - .from(lockDevice) - .where(eq(lockDevice.id, deviceId)) - .limit(1); - - if (!device) { - throw new HTTPException(404, { message: 'Device not found' }); - } - - 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(); - } - - logger.warn('WebSocket connection attempt without any credentials'); - throw new HTTPException(401, { - message: - 'Authentication required: provide X-Aegis-Device-Token or an API key', - }); - }, - upgradeWebSocket((c) => { - const device = c.get('device') as { id: string; name: string } | undefined; - - if (!device) { - throw unauthorized('Device not found in context'); - } - - 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); - }, - }; - }) -); 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 deleted file mode 100644 index b77b8052..00000000 --- a/apps/chronos/src/routes/health/_router.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { healthFactory } from '#routes/health/_factory'; -import { health } from '#routes/health/index'; - -export const healthRouter = healthFactory.createApp().get('/', ...health); diff --git a/apps/chronos/src/routes/health/index.ts b/apps/chronos/src/routes/health/index.ts deleted file mode 100644 index 8b8b3749..00000000 --- a/apps/chronos/src/routes/health/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -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'; - -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 }); - } -); 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 deleted file mode 100644 index 90f1f5f0..00000000 --- a/apps/chronos/src/routes/kiosk/_router.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { kioskFactory } from '#routes/kiosk/_factory'; -import { kioskDeparturesRoute } from '#routes/kiosk/departures'; -import { kioskHeartbeatRoute } from '#routes/kiosk/heartbeat'; -import { - createKioskRoute, - deleteKioskRoute, - listKiosksRoute, - updateKioskRoute, -} 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'; - -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); diff --git a/apps/chronos/src/routes/kiosk/heartbeat.ts b/apps/chronos/src/routes/kiosk/heartbeat.ts deleted file mode 100644 index 618e0e00..00000000 --- a/apps/chronos/src/routes/kiosk/heartbeat.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { kioskHeartbeatRequestSchema } from '@filcdev/api/domains/kiosk/heartbeat'; -import { zValidator } from '@hono/zod-validator'; -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'; - -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'); - - const [existing] = await db - .select() - .from(kiosk) - .where(eq(kiosk.machineId, machineId)); - - if (!existing) { - return ok(c, { registered: false }); - } - - const forwardedFor = c.req.header('x-forwarded-for'); - const lastSeenIp = - forwardedFor?.split(',')[0]?.trim() || getConnInfo(c).remote.address; - - await db - .update(kiosk) - .set({ - appVersion, - lastSeenAt: new Date(), - ...(lastSeenIp ? { lastSeenIp } : {}), - }) - .where(eq(kiosk.id, existing.id)); - - if (!existing.enabled) { - return ok(c, { - enabled: false, - kiosk: { id: existing.id, kind: existing.kind, name: existing.name }, - registered: true, - }); - } - - return ok(c, { - enabled: true, - kiosk: { - config: existing.config, - id: existing.id, - kind: existing.kind, - name: existing.name, - }, - registered: true, - }); - } -); diff --git a/apps/chronos/src/routes/kiosk/index.ts b/apps/chronos/src/routes/kiosk/index.ts deleted file mode 100644 index 4052f258..00000000 --- a/apps/chronos/src/routes/kiosk/index.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { - type KioskKind, - 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'; - -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 - * empty one: the navigator schema fills in its defaults, while a tv kiosk must - * at least carry a `departures` array. - */ -function parseKioskConfig(kind: KioskKind, config: unknown) { - const schema = - kind === 'tv' ? tvKioskConfigSchema : navigatorKioskConfigSchema; - const result = schema.safeParse(config ?? {}); - - if (!result.success) { - throw badRequest(`Invalid ${kind} kiosk config`, result.error); - } - - return result.data; -} - -/** - * 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. - */ -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; -} - -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) => { - const kiosks = await db.select().from(kiosk).orderBy(asc(kiosk.name)); - - return ok(c, { 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); - - try { - const [inserted] = await db - .insert(kiosk) - .values({ - config, - kind: payload.kind, - machineId: payload.machineId, - name: payload.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, { - 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'); - - const [existing] = await db.select().from(kiosk).where(eq(kiosk.id, id)); - - if (!existing) { - throw notFound('Kiosk not found'); - } - - const effectiveKind = payload.kind ?? (existing.kind as KioskKind); - const changes = { - ...(payload.config === undefined - ? {} - : { config: parseKioskConfig(effectiveKind, payload.config) }), - ...(payload.enabled === undefined ? {} : { enabled: payload.enabled }), - ...(payload.kind === undefined ? {} : { kind: payload.kind }), - ...(payload.name === undefined ? {} : { name: payload.name }), - }; - - if (Object.keys(changes).length === 0) { - return ok(c, { kiosk: existing }); - } - - const [updated] = await db - .update(kiosk) - .set(changes) - .where(eq(kiosk.id, id)) - .returning(); - - if (!updated) { - throw notFound('Kiosk not found'); - } - - return ok(c, { 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'); - - const [deleted] = await db - .delete(kiosk) - .where(eq(kiosk.id, id)) - .returning(); - - if (!deleted) { - throw notFound('Kiosk not found'); - } - - return ok(c, { kiosk: deleted }); - } -); diff --git a/apps/chronos/src/routes/kiosk/news-image.ts b/apps/chronos/src/routes/kiosk/news-image.ts deleted file mode 100644 index 1861ab8c..00000000 --- a/apps/chronos/src/routes/kiosk/news-image.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 { 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'); - - const [item] = await db - .select({ - imageContentType: announcement.imageContentType, - imageKey: announcement.imageKey, - }) - .from(announcement) - .where(eq(announcement.id, id)); - - if (!(item?.imageKey && item.imageContentType)) { - throw notFound('Image not found'); - } - - if (!isObjectStorageConfigured()) { - throw new ApiHttpError(StatusCodes.SERVICE_UNAVAILABLE, { - message: '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, - }); - } -); diff --git a/apps/chronos/src/routes/kiosk/news.ts b/apps/chronos/src/routes/kiosk/news.ts deleted file mode 100644 index fd847262..00000000 --- a/apps/chronos/src/routes/kiosk/news.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 { - 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(); - - 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) - ) - ) - .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}`) - : []; - - // 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, - })), - }); - } -); diff --git a/apps/chronos/src/routes/kiosk/weather.ts b/apps/chronos/src/routes/kiosk/weather.ts deleted file mode 100644 index 2da37cb3..00000000 --- a/apps/chronos/src/routes/kiosk/weather.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describeRoute, resolver } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -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'; - -const WEATHER_URL = 'https://api.weatherapi.com/v1/current.json'; -const WEATHER_CACHE_TTL_MS = 10 * 60 * 1000; -const WEATHER_TIMEOUT_MS = 10_000; - -type WeatherResponse = { - current: { - condition: { icon: string }; - precip_mm: number; - temp_c: number; - wind_kph: number; - }; -}; - -type KioskWeather = { - icon: string; - precipMm: number; - tempC: number; - windKph: number; -}; - -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); - } - - const url = new URL(WEATHER_URL); - url.searchParams.set('key', env.weatherApiKey); - url.searchParams.set('q', env.kioskWeatherLocation); - url.searchParams.set('lang', 'hu'); - - let payload: WeatherResponse; - try { - const response = await fetch(url, { - signal: AbortSignal.timeout(WEATHER_TIMEOUT_MS), - }); - - if (!response.ok) { - throw new Error(`Weather API responded with ${response.status}`); - } - - payload = (await response.json()) as WeatherResponse; - } catch (error) { - throw new ApiHttpError(StatusCodes.BAD_GATEWAY, { - cause: error, - message: 'Failed to fetch weather', - }); - } - - const value = { - icon: payload.current.condition.icon, - precipMm: payload.current.precip_mm, - tempC: Math.round(payload.current.temp_c), - windKph: payload.current.wind_kph, - }; - - cachedWeather = { - expiresAt: Date.now() + WEATHER_CACHE_TTL_MS, - value, - }; - - return ok(c, 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 deleted file mode 100644 index 04e3a1c2..00000000 --- a/apps/chronos/src/routes/navigator/_router.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { navigatorFactory } from '#routes/navigator/_factory'; -import { - createBuildingRoute, - deleteBuildingRoute, - listBuildingsRoute, - updateBuildingRoute, -} from '#routes/navigator/buildings'; -import { - createClassroomTypeRoute, - deleteClassroomTypeRoute, - listClassroomTypesRoute, - updateClassroomTypeRoute, -} from '#routes/navigator/classroom-types'; -import { - createClassroomRoute, - deleteClassroomRoute, - listClassroomsRoute, - updateClassroomRoute, -} from '#routes/navigator/classrooms'; -import { - createCorridorRoute, - deleteCorridorRoute, - listCorridorsRoute, - updateCorridorRoute, -} from '#routes/navigator/corridors'; -import { graphRoute } from '#routes/navigator/graph'; -import { - createLiftRoute, - deleteLiftRoute, - listLiftsRoute, - updateLiftRoute, -} from '#routes/navigator/lifts'; -import { - createStairRoute, - deleteStairRoute, - listStairsRoute, - updateStairRoute, -} from '#routes/navigator/stairs'; -import { - exportNavigatorRoute, - importNavigatorRoute, -} from '#routes/navigator/transfer'; -import { - createTranslationRoute, - deleteTranslationRoute, - getTranslationBundleRoute, - listTranslationLanguagesRoute, - listTranslationsRoute, - 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); diff --git a/apps/chronos/src/routes/navigator/buildings.ts b/apps/chronos/src/routes/navigator/buildings.ts deleted file mode 100644 index 2e75b52a..00000000 --- a/apps/chronos/src/routes/navigator/buildings.ts +++ /dev/null @@ -1,192 +0,0 @@ -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 { 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 { 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) => { - const buildings = await db - .select() - .from(buildingTable) - .orderBy(asc(buildingTable.name)); - - return ok(c, { 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'); - - const [building] = await db - .insert(buildingTable) - .values({ id: crypto.randomUUID(), mapped: true, ...payload }) - .returning(); - - return created(c, { 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'); - - // 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 - // placing the building, so it is mapped from then on. - const [building] = - Object.keys(payload).length > 0 - ? await db - .update(buildingTable) - .set({ ...payload, mapped: true }) - .where(eq(buildingTable.id, id)) - .returning() - : await db.select().from(buildingTable).where(eq(buildingTable.id, id)); - - if (!building) { - throw notFound('Building not found'); - } - - return ok(c, { 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'); - - try { - const [building] = await db - .delete(buildingTable) - .where(eq(buildingTable.id, id)) - .returning(); - - if (!building) { - throw notFound('Building not found'); - } - - return ok(c, { 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 - // concurrent insert. - if (isReferencedRowError(error)) { - throw conflict('Building still has rooms; delete them first', error); - } - throw error; - } - } -); diff --git a/apps/chronos/src/routes/navigator/classroom-types.ts b/apps/chronos/src/routes/navigator/classroom-types.ts deleted file mode 100644 index db7db4c5..00000000 --- a/apps/chronos/src/routes/navigator/classroom-types.ts +++ /dev/null @@ -1,196 +0,0 @@ -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 { 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 { 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) => { - 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'); - - const [classroomType] = await db - .insert(classroomTypeTable) - .values({ id: crypto.randomUUID(), ...payload }) - .returning(); - - return created(c, { 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'); - - // An empty patch is a legal (if pointless) request, and Drizzle refuses to - // build an `update … set` with no values. - const [classroomType] = - Object.keys(payload).length > 0 - ? await db - .update(classroomTypeTable) - .set(payload) - .where(eq(classroomTypeTable.id, id)) - .returning() - : await db - .select() - .from(classroomTypeTable) - .where(eq(classroomTypeTable.id, id)); - - if (!classroomType) { - throw notFound('Classroom type not found'); - } - - return ok(c, { 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'); - - try { - const [classroomType] = await db - .delete(classroomTypeTable) - .where(eq(classroomTypeTable.id, id)) - .returning(); - - if (!classroomType) { - throw notFound('Classroom type not found'); - } - - return ok(c, { 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. - if (isReferencedRowError(error)) { - throw conflict('Classroom type is still used by a classroom', error); - } - throw error; - } - } -); diff --git a/apps/chronos/src/routes/navigator/classrooms.ts b/apps/chronos/src/routes/navigator/classrooms.ts deleted file mode 100644 index 422da842..00000000 --- a/apps/chronos/src/routes/navigator/classrooms.ts +++ /dev/null @@ -1,223 +0,0 @@ -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 { asc, eq, sql } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import { db } from '#database'; -import { - classroom as classroomTable, - cohort, - 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'; - -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) => { - const classrooms = await db - .select() - .from(classroomTable) - .orderBy(asc(classroomTable.name)); - - return ok(c, { 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'); - - const [classroom] = await db - .insert(classroomTable) - .values({ id: crypto.randomUUID(), mapped: true, ...payload }) - .returning(); - - return created(c, { 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'); - - // 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 - // placing the room, so it is mapped from then on. - const [classroom] = - Object.keys(payload).length > 0 - ? await db - .update(classroomTable) - .set({ ...payload, mapped: true }) - .where(eq(classroomTable.id, id)) - .returning() - : await db - .select() - .from(classroomTable) - .where(eq(classroomTable.id, id)); - - if (!classroom) { - throw notFound('Classroom not found'); - } - - return ok(c, { 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'); - - // 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, - // naming the table that blocks it. - const [moved, scheduled, registered] = await Promise.all([ - db - .select({ id: movedLesson.id }) - .from(movedLesson) - .where(eq(movedLesson.room, id)) - .limit(1), - db - .select({ id: lesson.id }) - .from(lesson) - .where(sql`${id} = ANY(${lesson.classroomIds})`) - .limit(1), - db - .select({ id: cohort.id }) - .from(cohort) - .where(sql`${id} = ANY(${cohort.classroomIds})`) - .limit(1), - ]); - - if (moved.length > 0) { - throw conflict('A moved lesson points at this room'); - } - if (scheduled.length > 0) { - throw conflict('Lessons are scheduled in this room'); - } - if (registered.length > 0) { - throw conflict('A class is registered to this room'); - } - - const [classroom] = await db - .delete(classroomTable) - .where(eq(classroomTable.id, id)) - .returning(); - - if (!classroom) { - throw notFound('Classroom not found'); - } - - return ok(c, { classroom }); - } -); diff --git a/apps/chronos/src/routes/navigator/corridors.ts b/apps/chronos/src/routes/navigator/corridors.ts deleted file mode 100644 index b16e9822..00000000 --- a/apps/chronos/src/routes/navigator/corridors.ts +++ /dev/null @@ -1,181 +0,0 @@ -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 { 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'; - -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) => { - const corridors = await db - .select() - .from(navigatorCorridor) - .orderBy(asc(navigatorCorridor.name)); - - return ok(c, { 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'); - - const [corridor] = await db - .insert(navigatorCorridor) - .values({ id: crypto.randomUUID(), ...payload }) - .returning(); - - return created(c, { 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'); - - // An empty patch is a legal (if pointless) request, and Drizzle refuses to - // build an `update … set` with no values. - const [corridor] = - Object.keys(payload).length > 0 - ? await db - .update(navigatorCorridor) - .set(payload) - .where(eq(navigatorCorridor.id, id)) - .returning() - : await db - .select() - .from(navigatorCorridor) - .where(eq(navigatorCorridor.id, id)); - - if (!corridor) { - throw notFound('Corridor not found'); - } - - return ok(c, { 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'); - - const [corridor] = await db - .delete(navigatorCorridor) - .where(eq(navigatorCorridor.id, id)) - .returning(); - - if (!corridor) { - throw notFound('Corridor not found'); - } - - return ok(c, { corridor }); - } -); diff --git a/apps/chronos/src/routes/navigator/graph.ts b/apps/chronos/src/routes/navigator/graph.ts deleted file mode 100644 index 60a5a6a5..00000000 --- a/apps/chronos/src/routes/navigator/graph.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { and, asc, eq, getTableColumns } from 'drizzle-orm'; -import { describeRoute, resolver } from 'hono-openapi'; -import { db } from '#database'; -import { - navigatorCorridor, - navigatorLift, - 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'; - -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)), - ]); - - 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, - }); - } -); diff --git a/apps/chronos/src/routes/navigator/lifts.ts b/apps/chronos/src/routes/navigator/lifts.ts deleted file mode 100644 index 7a05ca5f..00000000 --- a/apps/chronos/src/routes/navigator/lifts.ts +++ /dev/null @@ -1,178 +0,0 @@ -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 { 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'; - -const { schema: createLiftRequestSchema } = - await resolver(createLiftSchema).toOpenAPISchema(); -const { schema: updateLiftRequestSchema } = - await resolver(updateLiftSchema).toOpenAPISchema(); - -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'); - - const [lift] = await db - .insert(navigatorLift) - .values({ id: crypto.randomUUID(), ...payload }) - .returning(); - - return created(c, { 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'); - - // An empty patch is a legal (if pointless) request, and Drizzle refuses to - // build an `update … set` with no values. - const [lift] = - Object.keys(payload).length > 0 - ? await db - .update(navigatorLift) - .set(payload) - .where(eq(navigatorLift.id, id)) - .returning() - : await db.select().from(navigatorLift).where(eq(navigatorLift.id, id)); - - if (!lift) { - throw notFound('Lift not found'); - } - - return ok(c, { 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'); - - const [lift] = await db - .delete(navigatorLift) - .where(eq(navigatorLift.id, id)) - .returning(); - - if (!lift) { - throw notFound('Lift not found'); - } - - return ok(c, { lift }); - } -); diff --git a/apps/chronos/src/routes/navigator/stairs.ts b/apps/chronos/src/routes/navigator/stairs.ts deleted file mode 100644 index 1f06c468..00000000 --- a/apps/chronos/src/routes/navigator/stairs.ts +++ /dev/null @@ -1,181 +0,0 @@ -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 { 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'; - -const { schema: createStairRequestSchema } = - await resolver(createStairSchema).toOpenAPISchema(); -const { schema: updateStairRequestSchema } = - await resolver(updateStairSchema).toOpenAPISchema(); - -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'); - - const [stair] = await db - .insert(navigatorStair) - .values({ id: crypto.randomUUID(), ...payload }) - .returning(); - - return created(c, { 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'); - - // An empty patch is a legal (if pointless) request, and Drizzle refuses to - // build an `update … set` with no values. - const [stair] = - Object.keys(payload).length > 0 - ? await db - .update(navigatorStair) - .set(payload) - .where(eq(navigatorStair.id, id)) - .returning() - : await db - .select() - .from(navigatorStair) - .where(eq(navigatorStair.id, id)); - - if (!stair) { - throw notFound('Staircase not found'); - } - - return ok(c, { 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'); - - const [stair] = await db - .delete(navigatorStair) - .where(eq(navigatorStair.id, id)) - .returning(); - - if (!stair) { - throw notFound('Staircase not found'); - } - - return ok(c, { stair }); - } -); diff --git a/apps/chronos/src/routes/navigator/translations.ts b/apps/chronos/src/routes/navigator/translations.ts deleted file mode 100644 index 499a03ab..00000000 --- a/apps/chronos/src/routes/navigator/translations.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { - createTranslationSchema, - langQuerySchema, - translationParamsSchema, - updateTranslationSchema, -} from '@filcdev/api/domains/navigator/translation'; -import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; -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'; - -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) => { - 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) }); - } -); - -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'); - - const rows = await db - .select({ - text: navigatorTranslation.text, - text_key: navigatorTranslation.text_key, - }) - .from(navigatorTranslation) - .where(eq(navigatorTranslation.lang_key, lang)); - - return ok( - c, - 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) => { - const translations = await db - .select() - .from(navigatorTranslation) - .orderBy( - asc(navigatorTranslation.text_key), - asc(navigatorTranslation.lang_key) - ); - - return ok(c, { 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'); - - const values = Object.entries(translations).map(([lang_key, text]) => ({ - lang_key, - text, - text_key, - })); - - if (values.length === 0) { - throw badRequest('At least one translation is required'); - } - - // A single INSERT … ON CONFLICT statement writes every language or, if the - // statement fails, none of them. - const rows = await db - .insert(navigatorTranslation) - .values(values) - .onConflictDoUpdate({ - set: { text: sql`excluded.text` }, - target: [navigatorTranslation.lang_key, navigatorTranslation.text_key], - }) - .returning(); - - return created(c, { 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'); - - const [translation] = await db - .insert(navigatorTranslation) - .values({ lang_key: lang, text, text_key: key }) - .onConflictDoUpdate({ - set: { text }, - target: [navigatorTranslation.lang_key, navigatorTranslation.text_key], - }) - .returning(); - - return ok(c, { 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'); - - const [translation] = await db - .delete(navigatorTranslation) - .where( - and( - eq(navigatorTranslation.lang_key, lang), - eq(navigatorTranslation.text_key, key) - ) - ) - .returning(); - - if (!translation) { - throw notFound('Translation not found'); - } - - return ok(c, { 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 deleted file mode 100644 index cac5ba3d..00000000 --- a/apps/chronos/src/routes/news/_router.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { newsFactory } from '#routes/news/_factory'; -import { - createAnnouncement, - deleteAnnouncement, - deleteAnnouncementImage, - getAnnouncement, - listAnnouncements, - updateAnnouncement, - uploadAnnouncementImage, -} from '#routes/news/announcements'; -import { - createBlog, - deleteBlog, - getBlogById, - getBlogBySlug, - listDrafts, - listPublishedBlogs, - publishBlog, - unpublishBlog, - updateBlog, -} from '#routes/news/blogs'; -import { - createSystemMessage, - deleteSystemMessage, - getSystemMessage, - listSystemMessages, - 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); diff --git a/apps/chronos/src/routes/news/blogs.ts b/apps/chronos/src/routes/news/blogs.ts deleted file mode 100644 index 5c48fe1e..00000000 --- a/apps/chronos/src/routes/news/blogs.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { paginationSchema } from '@filcdev/api/domains/news/announcements'; -import { - blogCreateSchema, - blogUpdateSchema, -} from '@filcdev/api/domains/news/blogs'; -import { zValidator } from '@hono/zod-validator'; -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 { 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, - image: user.image, - 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) { - conditions.push(sql`${blogPost.id} != ${excludeId}`); - } - const [existing] = await db - .select({ id: blogPost.id }) - .from(blogPost) - .where(and(...conditions)); - 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'); - - const where = eq(blogPost.status, 'published'); - - const [items, totalResult] = await Promise.all([ - 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(where) - .orderBy(sql`${blogPost.publishedAt} DESC NULLS LAST`) - .limit(limit) - .offset(offset), - db.select({ count: count() }).from(blogPost).where(where), - ]); - - return ok(c, items, StatusCodes.OK, { - 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'); - } - - return ok(c, item); - } -); - -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'); - - const [items, totalResult] = await Promise.all([ - 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)) - .orderBy(sql`${blogPost.publishedAt} DESC NULLS LAST`) - .limit(limit) - .offset(offset), - db.select({ count: count() }).from(blogPost), - ]); - - return ok(c, items, StatusCodes.OK, { - 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'); - - 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(eq(blogPost.id, id)); - - if (!item) { - throw notFound('Blog post not found'); - } - - return ok(c, 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; - - const baseSlug = body.slug ?? generateSlug(body.title); - const slug = await ensureUniqueSlug(baseSlug, (s) => checkSlugExists(s)); - - const publishedAt = body.status === 'published' ? new Date() : null; - - const [row] = await db - .insert(blogPost) - .values({ - authorId: currentUser.id, - content: body.content, - publishedAt, - slug, - status: body.status, - title: body.title, - }) - .returning(); - - return created(c, row); - } -); - -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'); - - const [existing] = await db - .select() - .from(blogPost) - .where(eq(blogPost.id, id)); - - if (!existing) { - throw notFound('Blog post not found'); - } - - const updateData: Record = {}; - if (body.title !== undefined) { - updateData.title = body.title; - } - if (body.content !== undefined) { - updateData.content = body.content; - } - - if (body.slug !== undefined) { - const slug = await ensureUniqueSlug(body.slug, (s) => - checkSlugExists(s, id) - ); - updateData.slug = slug; - } - - const [updated] = await db - .update(blogPost) - .set(updateData) - .where(eq(blogPost.id, id)) - .returning(); - - return ok(c, 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'); - - const [existing] = await db - .select() - .from(blogPost) - .where(eq(blogPost.id, id)); - - if (!existing) { - throw notFound('Blog post not found'); - } - - if (existing.status === 'published') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Blog post is already published', - }); - } - - const [updated] = await db - .update(blogPost) - .set({ publishedAt: new Date(), status: 'published' }) - .where(eq(blogPost.id, id)) - .returning(); - - dispatchPendingNotification(id, 'blog_post', { - slug: existing.slug, - title: existing.title, - }); - - return ok(c, 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'); - - const [existing] = await db - .select() - .from(blogPost) - .where(eq(blogPost.id, id)); - - if (!existing) { - throw notFound('Blog post not found'); - } - - if (existing.status === 'draft') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Blog post is already a draft', - }); - } - - const [updated] = await db - .update(blogPost) - .set({ publishedAt: null, status: 'draft' }) - .where(eq(blogPost.id, id)) - .returning(); - - cancelPendingNotification(id, 'blog_post'); - - return ok(c, 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'); - - const [deleted] = await db - .delete(blogPost) - .where(eq(blogPost.id, id)) - .returning(); - - if (!deleted) { - throw notFound('Blog post not found'); - } - - return ok(c, undefined); - } -); diff --git a/apps/chronos/src/routes/news/system-messages.ts b/apps/chronos/src/routes/news/system-messages.ts deleted file mode 100644 index 21e81f51..00000000 --- a/apps/chronos/src/routes/news/system-messages.ts +++ /dev/null @@ -1,407 +0,0 @@ -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 { 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 { validateCohortIds } from '#utils/news/cohort'; -import { - authorSelect, - successResponseSchema, - systemMessageBaseDetailResponseSchema, - systemMessageDetailResponseSchema, - systemMessageListResponseSchema, -} 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; - - const now = new Date(); - const conditions: SQL[] = []; - - if (!includeExpired) { - conditions.push(lte(systemMessage.validFrom, now)); - conditions.push(gte(systemMessage.validUntil, now)); - } - - 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}) - )` - ); - } - - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const [items, totalResult] = await Promise.all([ - db - .select({ - author: authorSelect, - authorId: systemMessage.authorId, - content: systemMessage.content, - createdAt: systemMessage.createdAt, - id: systemMessage.id, - title: systemMessage.title, - updatedAt: systemMessage.updatedAt, - validFrom: systemMessage.validFrom, - validUntil: systemMessage.validUntil, - }) - .from(systemMessage) - .leftJoin(user, eq(systemMessage.authorId, user.id)) - .where(where) - .orderBy(sql`${systemMessage.validFrom} DESC`) - .limit(limit) - .offset(offset), - db.select({ count: count() }).from(systemMessage).where(where), - ]); - - const itemIds = items.map((i) => i.id); - const cohortMappings = - itemIds.length > 0 - ? await db - .select() - .from(systemMessageCohortMtm) - .where(sql`${systemMessageCohortMtm.systemMessageId} IN ${itemIds}`) - : []; - - const data = items.map((item) => ({ - ...item, - cohortIds: cohortMappings - .filter((m) => m.systemMessageId === item.id) - .map((m) => m.cohortId), - })); - - return ok(c, data, StatusCodes.OK, { 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'); - - const [item] = await db - .select({ - author: authorSelect, - authorId: systemMessage.authorId, - content: systemMessage.content, - createdAt: systemMessage.createdAt, - id: systemMessage.id, - title: systemMessage.title, - updatedAt: systemMessage.updatedAt, - validFrom: systemMessage.validFrom, - validUntil: systemMessage.validUntil, - }) - .from(systemMessage) - .leftJoin(user, eq(systemMessage.authorId, user.id)) - .where(eq(systemMessage.id, id)); - - if (!item) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); - } - - const cohortIds = ( - await db - .select() - .from(systemMessageCohortMtm) - .where(eq(systemMessageCohortMtm.systemMessageId, id)) - ).map((m) => m.cohortId); - - return ok(c, { ...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; - - if (body.cohortIds && body.cohortIds.length > 0) { - await validateCohortIds(body.cohortIds); - } - - const [created] = await db - .insert(systemMessage) - .values({ - authorId: currentUser.id, - content: body.content, - title: body.title, - validFrom: body.validFrom, - validUntil: body.validUntil, - }) - .returning(); - if (!created) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to create system message', - }); - } - - if (body.cohortIds && body.cohortIds.length > 0) { - await db.insert(systemMessageCohortMtm).values( - body.cohortIds.map((cohortId) => ({ - cohortId, - systemMessageId: created.id, - })) - ); - } - - dispatchPendingNotification(created.id, 'system_message', { - cohortIds: body.cohortIds ?? [], - title: body.title, - }); - - return ok( - c, - { ...created, cohortIds: body.cohortIds ?? [] }, - StatusCodes.CREATED - ); - } -); - -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'); - - const [existing] = await db - .select() - .from(systemMessage) - .where(eq(systemMessage.id, id)); - - if (!existing) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: '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', - }); - } - - if (body.cohortIds && body.cohortIds.length > 0) { - await validateCohortIds(body.cohortIds); - } - - cancelPendingNotification(id, 'system_message'); - - const updateData: Record = {}; - if (body.title !== undefined) { - updateData.title = body.title; - } - if (body.content !== undefined) { - updateData.content = body.content; - } - if (body.validFrom !== undefined) { - updateData.validFrom = body.validFrom; - } - if (body.validUntil !== undefined) { - updateData.validUntil = body.validUntil; - } - - const [updated] = await db - .update(systemMessage) - .set(updateData) - .where(eq(systemMessage.id, id)) - .returning(); - if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); - } - - 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, - })) - ); - } - } - - const cohortIds = - body.cohortIds ?? - ( - await db - .select() - .from(systemMessageCohortMtm) - .where(eq(systemMessageCohortMtm.systemMessageId, id)) - ).map((m) => m.cohortId); - - dispatchPendingNotification(id, 'system_message', { - cohortIds, - title: updated.title, - }); - - return ok(c, { ...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'); - - const [deleted] = await db - .delete(systemMessage) - .where(eq(systemMessage.id, id)) - .returning(); - - if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'System message not found', - }); - } - - cancelPendingNotification(id, 'system_message'); - - return ok(c, undefined); - } -); 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 deleted file mode 100644 index 0356e6da..00000000 --- a/apps/chronos/src/routes/notifications/_router.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { notificationsFactory } from '#routes/notifications/_factory'; -import { - getNotificationSettings, - getUnreadCount, - getUnsubscribePage, - listNotifications, - markAllAsRead, - markAsRead, - previewTestNotification, - processUnsubscribe, - registerFcmToken, - sendTestNotification, - testNotification, - unregisterFcmToken, - 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); diff --git a/apps/chronos/src/routes/notifications/notifications.ts b/apps/chronos/src/routes/notifications/notifications.ts deleted file mode 100644 index cde57c72..00000000 --- a/apps/chronos/src/routes/notifications/notifications.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { - fcmTokenSchema, - notificationIdParamsSchema, - paginationSchema, - previewTestNotificationSchema, - sendTestNotificationSchema, - tokenDeleteSchema, - unsubscribeSchema, - updateSettingsSchema, -} from '@filcdev/api/domains/notifications'; -import { zValidator } from '@hono/zod-validator'; -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 { - fcmToken, - notification, - userPreferences, -} from '#database/schema/notifications'; -import { authRouter } from '#middleware/auth'; -import { notificationsFactory } from '#routes/notifications/_factory'; -import { env } from '#utils/environment'; -import { created as createdResponse, notFound, ok } from '#utils/http'; -import { sendPush } from '#utils/notifications/providers/fcm'; -import { - generateUnsubscribeToken, - 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'); - - const conditions = [eq(notification.userId, userId)]; - - if (type) { - conditions.push(eq(notification.type, type)); - } - if (unread !== undefined) { - conditions.push(eq(notification.read, !unread)); - } - if (dateFrom) { - conditions.push(sql`${notification.createdAt} >= ${new Date(dateFrom)}`); - } - if (dateTo) { - const endDate = new Date(dateTo); - endDate.setHours(23, 59, 59, 999); - conditions.push(sql`${notification.createdAt} <= ${endDate}`); - } - - const [items, totalResult] = await Promise.all([ - db - .select() - .from(notification) - .where(and(...conditions)) - .orderBy(desc(notification.createdAt)) - .limit(limit) - .offset(offset), - db - .select({ count: count() }) - .from(notification) - .where(and(...conditions)), - ]); - - return ok(c, items, StatusCodes.OK, { - total: totalResult[0]?.count ?? 0, - }); - } -); - -export const getUnreadCount = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - const { id: userId } = getUser(c); - - const [result] = await db - .select({ count: count() }) - .from(notification) - .where( - sql`${notification.userId} = ${userId} AND ${notification.read} = false` - ); - - return ok(c, { 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'); - - const [updated] = await db - .update(notification) - .set({ read: true }) - .where( - sql`${notification.id} = ${id} AND ${notification.userId} = ${userId}` - ) - .returning(); - - if (!updated) { - throw notFound('Notification not found'); - } - - return ok(c, updated); - } -); - -export const markAllAsRead = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - const { id: userId } = getUser(c); - - await db - .update(notification) - .set({ read: true }) - .where( - sql`${notification.userId} = ${userId} AND ${notification.read} = false` - ); - - return ok(c, undefined); - } -); - -export const getNotificationSettings = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - const { id: userId } = getUser(c); - - const [prefs] = await db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, userId)); - - if (prefs) { - return ok(c, prefs); - } - - const [created] = await db - .insert(userPreferences) - .values({ userId }) - .returning(); - - return ok(c, created); - } -); - -export const updateNotificationSettings = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', updateSettingsSchema), - async (c) => { - const { id: userId } = getUser(c); - const body = c.req.valid('json'); - - const values: Record = {}; - - if (body.language !== undefined) { - values.language = body.language; - } - if (body.theme !== undefined) { - values.theme = body.theme; - } - if (body.timetableView !== undefined) { - values.timetableView = body.timetableView; - } - if (body.notificationPreferences !== undefined) { - values.notificationPreferences = body.notificationPreferences; - } - if (body.timetableClassColors !== undefined) { - values.timetableClassColors = body.timetableClassColors; - } - if (body.timetableGroupDisplay !== undefined) { - values.timetableGroupDisplay = body.timetableGroupDisplay; - } - - const [updated] = await db - .update(userPreferences) - .set(values) - .where(eq(userPreferences.userId, userId)) - .returning(); - - if (updated) { - return ok(c, updated); - } - - const [created] = await db - .insert(userPreferences) - .values({ userId, ...values }) - .returning(); - - return ok(c, created); - } -); - -export const registerFcmToken = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', fcmTokenSchema), - async (c) => { - const { id: userId } = getUser(c); - - const { token: fcmTokenValue } = c.req.valid('json'); - - const [existing] = await db - .select() - .from(fcmToken) - .where( - and(eq(fcmToken.userId, userId), eq(fcmToken.token, fcmTokenValue)) - ); - - if (!existing) { - await db.insert(fcmToken).values({ - deviceInfo: c.req.valid('json').deviceInfo ?? null, - token: fcmTokenValue, - userId, - }); - } - - return createdResponse(c, undefined); - } -); - -export const testNotification = notificationsFactory.createHandlers( - ...authRouter(), - async (c) => { - if (env.mode !== 'development') { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Test notifications can only be sent in development mode', - }); - } - - const { id: userId } = getUser(c); - - const [notif] = await db - .insert(notification) - .values({ - content: - 'This is a test notification. If you received this, everything works.', - title: 'Test Notification', - type: 'test', - userId, - }) - .returning(); - - if (!notif) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to create test notification', - }); - } - - const [prefs] = await db - .select() - .from(userPreferences) - .where(eq(userPreferences.userId, userId)) - .limit(1); - - const userRecords = await db - .select({ email: userTable.email }) - .from(userTable) - .where(eq(userTable.id, userId)) - .limit(1); - - enqueue({ - channelsEnabled: prefs?.notificationPreferences?.channelsEnabled ?? true, - content: notif.content, - email: userRecords[0]?.email ?? '', - notificationId: notif.id, - title: notif.title, - type: 'test', - userId, - }); - - return ok(c, notif); - } -); - -export const unregisterFcmToken = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', tokenDeleteSchema), - async (c) => { - const { id: userId } = getUser(c); - const { token } = c.req.valid('json'); - - await db - .delete(fcmToken) - .where( - 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.

-
- -`); - } -); - -type TestTarget = { - email: string; - id: string; - isUser: boolean; - language: string; -}; - -async function resolveTestTarget( - email: string | undefined, - language: 'en' | 'hu' | undefined, - currentUserId: string -): Promise { - let target: TestTarget = { - email: '', - id: currentUserId, - isUser: true, - language: 'hu', - }; - - if (email) { - const [byEmail] = await db - .select({ email: userTable.email, id: userTable.id }) - .from(userTable) - .where(sql`lower(${userTable.email}) = ${email.toLowerCase()}`) - .limit(1); - if (byEmail) { - target = { - email: byEmail.email, - id: byEmail.id, - isUser: true, - language: 'hu', - }; - } else { - // Unmatched address: a pure SMTP delivery test. Keep the caller out of - // it — no unsubscribe token for the caller, no caller greeting/prefs. - target = { - email, - id: '', - isUser: false, - language: 'hu', - }; - } - } else { - const [me] = await db - .select({ email: userTable.email }) - .from(userTable) - .where(eq(userTable.id, currentUserId)) - .limit(1); - target.email = me?.email ?? ''; - } - - const [prefs] = await db - .select({ language: userPreferences.language }) - .from(userPreferences) - .where(eq(userPreferences.userId, target.id)) - .limit(1); - target.language = language ?? prefs?.language ?? 'hu'; - return target; -} - -export const sendTestNotification = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', sendTestNotificationSchema), - async (c) => { - if (env.mode !== 'development') { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Test notifications can only be sent in development mode', - }); - } - - const { id: currentUserId } = getUser(c); - const body = c.req.valid('json'); - - const target = await resolveTestTarget( - body.email, - body.language, - currentUserId - ); - const subject = body.subject ?? 'Filc test message'; - const content = - body.content ?? - 'This is a development test message. If you received this, everything works.'; - - const results = { email: false, inApp: false, push: false }; - - if (target.isUser && body.channels.inApp) { - const [notif] = await db - .insert(notification) - .values({ content, title: subject, type: body.type, userId: target.id }) - .returning(); - results.inApp = Boolean(notif); - } - if (body.channels.email) { - results.email = await sendEmail( - target.email, - subject, - body.type, - target.language, - { content, title: subject }, - target.id - ); - } - if (target.isUser && body.channels.push) { - results.push = await sendPush(target.id, subject, content); - } - - return ok(c, results); - } -); - -export const previewTestNotification = notificationsFactory.createHandlers( - ...authRouter(), - zValidator('json', previewTestNotificationSchema), - async (c) => { - if (env.mode !== 'development') { - throw new HTTPException(StatusCodes.FORBIDDEN, { - message: 'Test notifications can only be previewed in development mode', - }); - } - - const body = c.req.valid('json'); - const subject = body.subject ?? 'Filc test message'; - const content = - body.content ?? - 'This is a development test message. If you received this, everything works.'; - - const html = await renderEmail(body.type, body.language ?? 'hu', { - content, - title: subject, - }); - - return ok(c, { html }); - } -); 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 deleted file mode 100644 index 8d81d452..00000000 --- a/apps/chronos/src/routes/ping/_router.ts +++ /dev/null @@ -1,8 +0,0 @@ -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); diff --git a/apps/chronos/src/routes/ping/index.ts b/apps/chronos/src/routes/ping/index.ts deleted file mode 100644 index 643f7afb..00000000 --- a/apps/chronos/src/routes/ping/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -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'; - -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 }) -); diff --git a/apps/chronos/src/routes/ping/uptime.ts b/apps/chronos/src/routes/ping/uptime.ts deleted file mode 100644 index beff9c1b..00000000 --- a/apps/chronos/src/routes/ping/uptime.ts +++ /dev/null @@ -1,38 +0,0 @@ -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'; - -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; - - return ok(c, { - 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 deleted file mode 100644 index 42f54e10..00000000 --- a/apps/chronos/src/routes/roles/_router.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { rolesFactory } from '#routes/roles/_factory'; -import { - createRole, - deleteRole, - listPermissions, - listRoles, - updateRole, -} from '#routes/roles/index'; - -export const rolesRouter = rolesFactory - .createApp() - .get('/', ...listRoles) - .get('/permissions', ...listPermissions) - .post('/', ...createRole) - .patch('/:name', ...updateRole) - .delete('/:name', ...deleteRole); diff --git a/apps/chronos/src/routes/roles/index.ts b/apps/chronos/src/routes/roles/index.ts deleted file mode 100644 index 5e80d370..00000000 --- a/apps/chronos/src/routes/roles/index.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { - createRoleSchema, - roleNameParamsSchema, - updateRoleSchema, -} from '@filcdev/api/domains/roles'; -import { permissions as permissionNames } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; -import { SQL } from 'bun'; -import { arrayContains } from 'drizzle-orm'; -import { HTTPException } from 'hono/http-exception'; -import { describeRoute } from 'hono-openapi'; -import { StatusCodes } from 'http-status-codes'; -import { db } from '#database'; -import { user } from '#database/schema/authentication'; -import { authRouter } from '#middleware/auth'; -import { rolesFactory } from '#routes/roles/_factory'; -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(permissionNames.rolesRead), - (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(permissionNames.rolesRead), - (c) => { - const allRoles = rbac.getAllRoles(); - - const roles = Object.entries(allRoles).map(([name, def]) => ({ - can: def.can, - name, - })); - - return ok(c, { roles }); - } -); - -export const createRole = rolesFactory.createHandlers( - describeRoute({ - description: 'Create a new role', - responses: { - 201: { - description: 'Role created', - }, - }, - tags: ['Roles'], - }), - ...authRouter(permissionNames.rolesManage), - zValidator('json', createRoleSchema), - async (c) => { - const { name, permissions } = c.req.valid('json'); - - const existingRoles = rbac.getAllRoles(); - if (name in existingRoles) { - throw new HTTPException(StatusCodes.CONFLICT, { - message: `Role "${name}" already exists`, - }); - } - - try { - await rbac.createRole(name, permissions); - } 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`); - } - 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(permissionNames.rolesManage), - zValidator('json', updateRoleSchema), - zValidator('param', roleNameParamsSchema), - async (c) => { - const { name: roleName } = c.req.valid('param'); - if (!roleName) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Role name is required', - }); - } - - const existingRoles = rbac.getAllRoles(); - if (!(roleName in existingRoles)) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: `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(permissionNames.rolesManage), - zValidator('param', roleNameParamsSchema), - async (c) => { - const { name: roleName } = c.req.valid('param'); - - const existingRoles = rbac.getAllRoles(); - if (!(roleName in existingRoles)) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: `Role "${roleName}" not found`, - }); - } - - const [assignedUser] = await db - .select({ id: user.id }) - .from(user) - .where(arrayContains(user.roles, [roleName])) - .limit(1); - if (assignedUser) { - throw conflict(`Role "${roleName}" is still assigned to a user`); - } - - await rbac.deleteRole(roleName); - - return ok(c, undefined); - } -); 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 deleted file mode 100644 index 444f5ac9..00000000 --- a/apps/chronos/src/routes/timetable/_router.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 { - cleanupOrphanedCohortsHandler, - deleteTimetable, - getAllTimetables, - getAllValidTimetables, - 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'; -import { getAvailableClassrooms, getClassrooms } from './room'; -import { - getMyTeacher, - getTeachers, - listTeachersAdmin, - 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); diff --git a/apps/chronos/src/routes/timetable/cohort.ts b/apps/chronos/src/routes/timetable/cohort.ts deleted file mode 100644 index bca2bbf0..00000000 --- a/apps/chronos/src/routes/timetable/cohort.ts +++ /dev/null @@ -1,59 +0,0 @@ -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'; - -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'); - - const cohortRows = await db - .select() - .from(cohort) - .innerJoin(cohortTimetableMtm, eq(cohort.id, cohortTimetableMtm.cohortId)) - .where(eq(cohortTimetableMtm.timetableId, timetableId)); - - const cohorts = cohortRows.map((r) => r.cohort); - - return ok(c, cohorts); - } -); diff --git a/apps/chronos/src/routes/timetable/lesson.ts b/apps/chronos/src/routes/timetable/lesson.ts deleted file mode 100644 index 2d3d6bc6..00000000 --- a/apps/chronos/src/routes/timetable/lesson.ts +++ /dev/null @@ -1,1115 +0,0 @@ -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 { - and, - arrayContains, - arrayOverlaps, - eq, - 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, - cohort, - cohortGroup, - dayDefinition, - lesson, - lessonCohortMTM, - period, - subject, - substitutionLessonMTM, - teacher, - weekDefinition, -} from '#database/schema/timetable'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { - getActiveTimetableId, - getTimetableIdForDate, -} from '#utils/timetable/active'; -import { - getWeekdayInBudapest, - isMatchingWeekday, -} from '#utils/timetable/weekday'; -import { createSelectSchema } from '#utils/zod'; -import { timetableFactory } from './_factory'; - -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, - }; - }); -} - -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'); - - const [existingCohort] = await db - .select() - .from(cohort) - .where(eq(cohort.id, cohortId)) - .limit(1); - - if (!existingCohort) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Cohort not found', - }); - } - - const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); - - // No active timetable: yield no lessons rather than every timetable. - if (!effectiveTimetableId) { - return ok(c, []); - } - - const whereClause = and( - eq(lessonCohortMTM.cohortId, cohortId), - eq(lesson.timetableId, effectiveTimetableId) - ); - - const lessonRows = await db - .select({ lesson }) - .from(lesson) - .innerJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) - .where(whereClause); - - const lessons = lessonRows.map((r) => r.lesson); - - if (lessons.length === 0) { - return ok(c, []); - } - - const enriched = await enrichLessons(lessons); - - return ok(c, 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'); - - const [existingTeacher] = await db - .select() - .from(teacher) - .where(eq(teacher.id, teacherId)) - .limit(1); - - if (!existingTeacher) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Teacher not found', - }); - } - - const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); - - // No active timetable: yield no lessons rather than every timetable. - if (!effectiveTimetableId) { - return ok(c, []); - } - - const whereClause = and( - arrayContains(lesson.teacherIds, [teacherId]), - eq(lesson.timetableId, effectiveTimetableId) - ); - - const lessons = await db.select().from(lesson).where(whereClause); - - if (lessons.length === 0) { - return ok(c, []); - } - - 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)'; - -type CandidateLessonEntry = { - period: number; - subjectShort: string | null; -}; - -// The aSc timetable export encodes substitution ("Helyettesítés") lessons as a -// single subject with short "H"; older exports used "H1"/"H2". -const SUBSTITUTION_SUBJECT_SHORTS = new Set(['H', 'H1', 'H2']); - -function computeCandidateFlags( - teacherLessons: CandidateLessonEntry[], - selectedPeriods: number[] -): { hasConflict: boolean; hasH1: boolean; hasH2: boolean } { - let hasH1 = false; - let hasH2 = false; - - for (const selectedPeriod of selectedPeriods) { - const lessonsAtPeriod = teacherLessons.filter( - (l) => l.period === selectedPeriod - ); - - if (lessonsAtPeriod.length === 0) { - continue; - } - - const hasH1AtPeriod = lessonsAtPeriod.some( - (l) => l.subjectShort === 'H' || l.subjectShort === 'H1' - ); - const hasH2AtPeriod = lessonsAtPeriod.some((l) => l.subjectShort === 'H2'); - - if (hasH1AtPeriod) { - hasH1 = true; - } - if (hasH2AtPeriod) { - hasH2 = true; - } - - const hasConflictLesson = lessonsAtPeriod.some( - (l) => - l.subjectShort === null || - !SUBSTITUTION_SUBJECT_SHORTS.has(l.subjectShort) - ); - if (hasConflictLesson) { - return { hasConflict: true, hasH1, hasH2 }; - } - } - - return { hasConflict: false, hasH1, hasH2 }; -} - -type SubstitutionCandidate = { - hasH1: boolean; - hasH2: boolean; - teacher: { firstName: string; id: string; lastName: string; short: string }; -}; - -function compareSubstituteCandidates( - a: SubstitutionCandidate, - b: SubstitutionCandidate -): number { - if (a.hasH1 && !b.hasH1) { - return -1; - } - if (!a.hasH1 && b.hasH1) { - return 1; - } - if (a.hasH2 && !b.hasH2) { - return -1; - } - if (!a.hasH2 && b.hasH2) { - return 1; - } - const aName = `${a.teacher.lastName} ${a.teacher.firstName}`; - const bName = `${b.teacher.lastName} ${b.teacher.firstName}`; - return aName.localeCompare(bName); -} - -async function buildCandidateLessonsMap( - candidateTeacherIds: string[], - weekday: number, - timetableId: string -): Promise> { - const candidateLessons = await db - .select() - .from(lesson) - .where( - and( - arrayOverlaps(lesson.teacherIds, candidateTeacherIds), - eq(lesson.timetableId, timetableId) - ) - ); - - const enrichedCandidateLessons = await enrichLessons(candidateLessons); - const map = new Map(); - const candidateTeacherIdSet = new Set(candidateTeacherIds); - - for (const candidateLesson of enrichedCandidateLessons) { - if ( - !( - candidateLesson.day && - isMatchingWeekday( - weekday, - candidateLesson.day.name, - candidateLesson.day.short - ) - ) - ) { - continue; - } - - const currentPeriod = candidateLesson.period?.period; - if (typeof currentPeriod !== 'number') { - continue; - } - - const subjectShort = candidateLesson.subject?.short ?? null; - - for (const lessonTeacher of candidateLesson.teachers) { - if (!candidateTeacherIdSet.has(lessonTeacher.id)) { - continue; - } - - const lessons = map.get(lessonTeacher.id) ?? []; - lessons.push({ period: currentPeriod, subjectShort }); - map.set(lessonTeacher.id, lessons); - } - } - - return map; -} - -async function getParallelLessons( - selectedLessons: Awaited>, - missingTeacherId: string, - timetableId: string -): Promise>> { - const periodIds = [ - ...new Set( - selectedLessons - .map((l) => l.period?.id) - .filter((id): id is string => !!id) - ), - ]; - const dayIds = [ - ...new Set( - selectedLessons.map((l) => l.day?.id).filter((id): id is string => !!id) - ), - ]; - - if (periodIds.length === 0 || dayIds.length === 0) { - return []; - } - - const selectedLessonIds = selectedLessons.map((l) => l.id); - - const cohortRows = await db - .select({ cohortId: lessonCohortMTM.cohortId }) - .from(lessonCohortMTM) - .where(inArray(lessonCohortMTM.lessonId, selectedLessonIds)); - - const cohortIds = [...new Set(cohortRows.map((r) => r.cohortId))]; - if (cohortIds.length === 0) { - return []; - } - - const parallelLinkRows = await db - .select({ lessonId: lessonCohortMTM.lessonId }) - .from(lessonCohortMTM) - .where(inArray(lessonCohortMTM.cohortId, cohortIds)); - - const parallelLessonIds = [ - ...new Set( - parallelLinkRows - .map((r) => r.lessonId) - .filter((id) => !selectedLessonIds.includes(id)) - ), - ]; - if (parallelLessonIds.length === 0) { - return []; - } - - const rows = await db - .select() - .from(lesson) - .where( - and( - inArray(lesson.id, parallelLessonIds), - inArray(lesson.periodId, periodIds), - inArray(lesson.dayDefinitionId, dayIds), - eq(lesson.timetableId, timetableId) - ) - ); - - const enriched = await enrichLessons(rows); - return enriched.filter( - (l) => - l.teachers.length > 0 && - !l.teachers.some((t) => t.id === missingTeacherId) - ); -} - -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'); - const normalizedTeacherIds = Array.from(new Set(teacherIds)); - - const existingTeachers = await db - .select({ id: teacher.id }) - .from(teacher) - .where(inArray(teacher.id, normalizedTeacherIds)); - - const existingTeacherIds = existingTeachers.map((t) => t.id); - - if (existingTeacherIds.length === 0) { - return ok(c, []); - } - - const timetableId = await getActiveTimetableId(); - - // No active timetable: yield no lessons rather than every timetable. - if (!timetableId) { - return ok(c, []); - } - - const lessons = await db - .select() - .from(lesson) - .where( - and( - eq(lesson.timetableId, timetableId), - or( - ...existingTeacherIds.map((id) => - arrayContains(lesson.teacherIds, [id]) - ) - ) - ) - ); - - const enrichedLessons = await enrichLessons(lessons); - const lessonsByTeacherId = new Map< - string, - (typeof enrichedLessons)[number][] - >(existingTeacherIds.map((id) => [id, []])); - - for (const enrichedLesson of enrichedLessons) { - for (const lessonTeacher of enrichedLesson.teachers) { - if (!lessonsByTeacherId.has(lessonTeacher.id)) { - continue; - } - - lessonsByTeacherId.get(lessonTeacher.id)?.push(enrichedLesson); - } - } - - const data = existingTeacherIds.map((teacherId) => ({ - lessons: lessonsByTeacherId.get(teacherId) ?? [], - teacherId, - })); - - return ok(c, 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)); - - 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', - }); - } - - const weekday = getWeekdayInBudapest(date); - const timetableId = await getTimetableIdForDate(date); - - if (!timetableId) { - return ok(c, { - availableLessons: [], - parallelLessons: [], - substituteCandidates: [], - }); - } - - const missingTeacherLessons = await db - .select() - .from(lesson) - .where( - and( - arrayContains(lesson.teacherIds, [missingTeacherId]), - eq(lesson.timetableId, timetableId) - ) - ); - - const enrichedMissingTeacherLessons = await enrichLessons( - missingTeacherLessons - ); - - 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 selectedPeriods = selectedLessons - .map((currentLesson) => currentLesson.period?.period) - .filter( - (currentPeriod): currentPeriod is number => - typeof currentPeriod === 'number' - ); - - if (selectedPeriods.length === 0) { - return ok(c, { - availableLessons, - parallelLessons: [], - substituteCandidates: [], - }); - } - - const parallelLessons = await getParallelLessons( - selectedLessons, - missingTeacherId, - timetableId - ); - - const candidateTeacherIds = normalizedTeacherIds.filter( - (teacherId) => teacherId !== missingTeacherId - ); - - if (candidateTeacherIds.length === 0) { - return ok(c, { - availableLessons, - parallelLessons, - 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'); - - const [existingClassroom] = await db - .select() - .from(classroom) - .where(eq(classroom.id, classroomId)) - .limit(1); - - if (!existingClassroom) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Classroom not found', - }); - } - - const effectiveTimetableId = timetableId ?? (await getActiveTimetableId()); - - // No active timetable: yield no lessons rather than every timetable. - if (!effectiveTimetableId) { - return ok(c, []); - } - - const whereClause = and( - arrayContains(lesson.classroomIds, [classroomId]), - eq(lesson.timetableId, effectiveTimetableId) - ); - - const lessons = await db.select().from(lesson).where(whereClause); - - if (lessons.length === 0) { - return ok(c, []); - } - - const enriched = await enrichLessons(lessons); - - return ok(c, 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'); - - const lessonRow = await db - .select() - .from(lesson) - .where(eq(lesson.id, lessonId)) - .limit(1); - - if (!lessonRow) { - return ok(c, null); - } - - const substitutionCohortRow = await db - .select({ name: cohort.name }) - .from(substitutionLessonMTM) - .innerJoin( - lessonCohortMTM, - eq(substitutionLessonMTM.lessonId, lessonCohortMTM.lessonId) - ) - .innerJoin(cohort, eq(lessonCohortMTM.cohortId, cohort.id)) - .where(eq(substitutionLessonMTM.lessonId, lessonId)) - .limit(1); - - const [enriched] = await enrichLessons(lessonRow); - - return ok(c, { - ...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', - }); - } - } -); diff --git a/apps/chronos/src/routes/timetable/moved-lesson.ts b/apps/chronos/src/routes/timetable/moved-lesson.ts deleted file mode 100644 index f7b55e6c..00000000 --- a/apps/chronos/src/routes/timetable/moved-lesson.ts +++ /dev/null @@ -1,699 +0,0 @@ -import { - cohortIdParamsSchema, - movedLessonIdParamsSchema, - updateSchema, -} from '@filcdev/api/domains/timetable/moved-lesson'; -import { zValidator } from '@hono/zod-validator'; -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, - dayDefinition, - lesson, - lessonCohortMTM, - movedLesson, - movedLessonLessonMTM, - period, -} from '#database/schema/timetable'; -import { authRouter } from '#middleware/auth'; -import { created, ok } 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 - .select({ periodId: period.id }) - .from(period) - .where(eq(period.id, periodId)); - - if (!existingPeriod) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid starting period provided', - }); - } -}; - -const ensureDayDefinitionExists = async (dayId: string) => { - const [existingDay] = await db - .select({ dayId: dayDefinition.id }) - .from(dayDefinition) - .where(eq(dayDefinition.id, dayId)); - - if (!existingDay) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid starting day provided', - }); - } -}; - -const ensureClassroomExists = async (classroomId: string) => { - const [existingRoom] = await db - .select({ classroomId: classroom.id }) - .from(classroom) - .where(eq(classroom.id, classroomId)); - - if (!existingRoom) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Invalid classroom provided', - }); - } -}; - -const ensureLessonsExist = async ( - lessonIds: string[] -): Promise<{ periodId: string }[]> => { - const timetableId = await getActiveTimetableId(); - - const lessonRecords = await db - .select({ lessonId: lesson.id, periodId: lesson.periodId }) - .from(lesson) - .where( - timetableId - ? and( - inArray(lesson.id, lessonIds), - eq(lesson.timetableId, timetableId) - ) - : sql`false` - ); - - const foundLessonIds = new Set(lessonRecords.map(({ lessonId }) => lessonId)); - const missingLessonIds = lessonIds.filter( - (lessonId) => !foundLessonIds.has(lessonId) - ); - - if (missingLessonIds.length > 0) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `Invalid lesson ids provided: ${missingLessonIds.join(', ')}`, - }); - } - - return lessonRecords; -}; - -const normalizeOptionalString = ( - value: unknown, - label: string -): string | undefined => { - if (value === undefined || value === null) { - return; - } - - if (typeof value !== 'string') { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `${label} must be a string`, - }); - } - - return value; -}; - -const normalizeOptionalStringArray = ( - value: unknown, - label: string -): string[] | undefined => { - if (value === undefined || value === null) { - return; - } - - if (!Array.isArray(value)) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: `${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`, - }); - } - - return entry; - }); -}; - -const validateMovedLessonReferences = async (options: { - startingPeriod?: unknown; - startingDay?: unknown; - room?: unknown; - lessonIds?: unknown; -}) => { - const { startingPeriod, startingDay, room, lessonIds } = options; - - const normalizedStartingPeriod = normalizeOptionalString( - startingPeriod, - 'Starting period' - ); - if (normalizedStartingPeriod) { - await ensurePeriodExists(normalizedStartingPeriod); - } - - const normalizedStartingDay = normalizeOptionalString( - startingDay, - 'Starting day' - ); - if (normalizedStartingDay) { - await ensureDayDefinitionExists(normalizedStartingDay); - } - - const normalizedRoom = normalizeOptionalString(room, 'Classroom'); - if (normalizedRoom) { - await ensureClassroomExists(normalizedRoom); - } - - const normalizedLessonIds = normalizeOptionalStringArray( - lessonIds, - 'Lesson ids' - ); - if (normalizedLessonIds && normalizedLessonIds.length > 0) { - const lessonRecords = await ensureLessonsExist(normalizedLessonIds); - - if ( - normalizedStartingPeriod && - lessonRecords.some( - ({ periodId }) => periodId !== normalizedStartingPeriod - ) - ) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: '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; - dayDefinition: typeof dayDefinition.$inferSelect | null; - lessons: string[]; - movedLesson: typeof movedLesson.$inferSelect; - period: typeof period.$inferSelect | null; -}; - -// Enrich the linked lesson ids of a batch of moved-lesson rows, preserving the -// target joins and the per-moved-lesson lesson order. A moved lesson 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. -async function attachEnrichedLessons(rows: MovedLessonRow[]) { - const allLessonIds = Array.from(new Set(rows.flatMap((r) => r.lessons))); - const enriched = await enrichLessons(allLessonIds); - const lessonMap = new Map(enriched.map((l) => [l.id, l])); - - return rows.map((r) => ({ - classroom: r.classroom, - dayDefinition: r.dayDefinition, - lessons: r.lessons - .map((id) => lessonMap.get(id)) - .filter((l): l is EnrichedLesson => l !== undefined), - movedLesson: r.movedLesson, - period: r.period, - })); -} - -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) => { - const movedLessons = await db - .select({ - classroom, - dayDefinition, - lessons: sql`COALESCE( - ARRAY_AGG(DISTINCT ${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), - ARRAY[]::text[] - )`.as('lessons'), - movedLesson, - period, - }) - .from(movedLesson) - .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) - .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) - .leftJoin(classroom, eq(movedLesson.room, classroom.id)) - .leftJoin( - movedLessonLessonMTM, - eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) - ) - .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - - return ok(c, 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) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const movedLessons = await db - .select({ - classroom, - dayDefinition, - lessons: sql`COALESCE( - ARRAY_AGG(${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), - ARRAY[]::text[] - )`.as('lessons'), - movedLesson, - period, - }) - .from(movedLesson) - .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) - .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) - .leftJoin(classroom, eq(movedLesson.room, classroom.id)) - .leftJoin( - movedLessonLessonMTM, - eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) - ) - .where(gte(movedLesson.date, today)) - .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - - return ok(c, 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'); - - const movedLessons = await db - .select({ - classroom, - dayDefinition, - lessons: sql`COALESCE( - ARRAY_AGG(DISTINCT ${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), - ARRAY[]::text[] - )`.as('lessons'), - movedLesson, - period, - }) - .from(movedLesson) - .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) - .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) - .leftJoin(classroom, eq(movedLesson.room, classroom.id)) - .leftJoin( - movedLessonLessonMTM, - eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) - ) - .leftJoin(lesson, eq(movedLessonLessonMTM.lessonId, lesson.id)) - .leftJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) - .where(eq(lessonCohortMTM.cohortId, cohortId)) - .groupBy(movedLesson.id, period.id, dayDefinition.id, classroom.id); - - return ok(c, 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'); - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const movedLessons = await db - .select({ - classroom, - dayDefinition, - lessons: sql`COALESCE( - ARRAY_AGG(DISTINCT ${movedLessonLessonMTM.lessonId}) FILTER (WHERE ${movedLessonLessonMTM.lessonId} IS NOT NULL), - ARRAY[]::text[] - )`.as('lessons'), - movedLesson, - period, - }) - .from(movedLesson) - .leftJoin(period, eq(movedLesson.startingPeriod, period.id)) - .leftJoin(dayDefinition, eq(movedLesson.startingDay, dayDefinition.id)) - .leftJoin(classroom, eq(movedLesson.room, classroom.id)) - .leftJoin( - movedLessonLessonMTM, - eq(movedLesson.id, movedLessonLessonMTM.movedLessonId) - ) - .leftJoin(lesson, eq(movedLessonLessonMTM.lessonId, lesson.id)) - .leftJoin(lessonCohortMTM, eq(lesson.id, lessonCohortMTM.lessonId)) - .where( - and( - eq(lessonCohortMTM.cohortId, cohortId), - gte(movedLesson.date, today) - ) - ) - .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), - }); - -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'); - const { startingPeriod, startingDay, room, date, lessonIds, comment } = - body; - - if (!date) { - throw new HTTPException(StatusCodes.BAD_REQUEST, { - message: 'Date is required', - }); - } - - await validateMovedLessonReferences({ - lessonIds, - room, - startingDay, - startingPeriod, - }); - - const [newMovedLesson] = await db - .insert(movedLesson) - .values({ - comment, - date, - id: crypto.randomUUID(), - room, - startingDay, - startingPeriod, - }) - .returning(); - - if ( - lessonIds && - Array.isArray(lessonIds) && - lessonIds.length > 0 && - newMovedLesson - ) { - await db.insert(movedLessonLessonMTM).values( - lessonIds.map((lessonId: string) => ({ - lessonId, - movedLessonId: newMovedLesson.id, - })) - ); - } - - if (newMovedLesson) { - dispatchPendingNotification(newMovedLesson.id, 'moved_lesson', { - date: body.date, - lessonIds: lessonIds ?? [], - room, - startingDay, - startingPeriod, - }); - } - - return created(c, 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'); - const { startingPeriod, startingDay, room, date, lessonIds, comment } = - c.req.valid('json'); - - await validateMovedLessonReferences({ - lessonIds, - room, - startingDay, - startingPeriod, - }); - - 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(); - - if (!updatedMovedLesson) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Moved lesson not found', - }); - } - - if (lessonIds !== undefined && Array.isArray(lessonIds)) { - await db - .delete(movedLessonLessonMTM) - .where(eq(movedLessonLessonMTM.movedLessonId, id)); - - if (lessonIds.length > 0) { - await db.insert(movedLessonLessonMTM).values( - lessonIds.map((lessonId: string) => ({ - lessonId, - movedLessonId: id, - })) - ); - } - } - - dispatchPendingNotification(id, 'moved_lesson', { - date, - lessonIds: lessonIds ?? [], - room, - startingDay, - startingPeriod, - }); - - return ok(c, 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'); - - const [deletedMovedLesson] = await db - .delete(movedLesson) - .where(eq(movedLesson.id, id)) - .returning(); - - if (!deletedMovedLesson) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Moved lesson not found', - }); - } - - cancelPendingNotification(id, 'moved_lesson'); - - return ok(c, deletedMovedLesson); - } -); diff --git a/apps/chronos/src/routes/timetable/period.ts b/apps/chronos/src/routes/timetable/period.ts deleted file mode 100644 index 8e20751d..00000000 --- a/apps/chronos/src/routes/timetable/period.ts +++ /dev/null @@ -1,81 +0,0 @@ -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'; - -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'); - - let periods: (typeof period.$inferSelect)[]; - - if (timetableId) { - // Get all distinct period IDs used in lessons for this timetable - const usedPeriodIds = await db - .selectDistinct({ periodId: lesson.periodId }) - .from(lesson) - .where(eq(lesson.timetableId, timetableId)); - - const ids = usedPeriodIds.map((r) => r.periodId); - - if (ids.length === 0) { - return ok(c, []); - } - - periods = await db - .select() - .from(period) - .where(inArray(period.id, ids)) - .orderBy(asc(period.period)); - } else { - periods = await db.select().from(period).orderBy(asc(period.period)); - } - - const data = periods.map((p) => ({ - endTime: String(p.endTime), - id: p.id, - period: p.period, - startTime: String(p.startTime), - })); - - return ok(c, data); - } -); diff --git a/apps/chronos/src/routes/timetable/teacher.ts b/apps/chronos/src/routes/timetable/teacher.ts deleted file mode 100644 index 681e000c..00000000 --- a/apps/chronos/src/routes/timetable/teacher.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { - getMyTeacherResponseSchema, - getTeacherParamsSchema, - listTeachersResponseSchema, - publicTeacherSchema, - type TeacherListItem, - teacherListItemSchema, - updateTeacherPayload, -} 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(), -}); - -/** - * 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); - } -); - -/** - * 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; - - const [row] = await db - .select({ - firstName: teacher.firstName, - id: teacher.id, - lastName: teacher.lastName, - short: teacher.short, - }) - .from(teacher) - .where(eq(teacher.userId, userId)) - .orderBy(teacher.id) - .limit(1); - - c.header('Cache-Control', 'no-store'); - return ok(c, 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) => { - const rows = await db - .select({ - email: teacher.email, - firstName: teacher.firstName, - id: teacher.id, - lastName: teacher.lastName, - short: teacher.short, - userEmail: user.email, - userId: teacher.userId, - userName: user.name, - }) - .from(teacher) - .leftJoin(user, eq(user.id, teacher.userId)); - - const data: TeacherListItem[] = rows.map((row) => ({ - email: row.email, - firstName: row.firstName, - id: row.id, - lastName: row.lastName, - short: row.short, - user: row.userId - ? { - email: row.userEmail ?? '', - id: row.userId, - name: row.userName ?? '', - } - : null, - userId: row.userId, - })); - - return ok(c, 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'); - - const [existing] = await db - .select({ id: teacher.id }) - .from(teacher) - .where(eq(teacher.id, id)) - .limit(1); - if (!existing) { - throw notFound('Teacher not found'); - } - - if (body.userId !== undefined && body.userId !== null) { - const [linkedUser] = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.id, body.userId)) - .limit(1); - if (!linkedUser) { - throw badRequest('The linked user does not exist'); - } - } - - let email: string | null | undefined; - if (body.email === undefined) { - email = undefined; - } else if (body.email === null) { - email = null; - } else { - email = body.email.toLowerCase(); - } - - await db - .update(teacher) - .set({ - email, - userId: body.userId, - }) - .where(eq(teacher.id, id)); - - const [updated] = await db - .select({ - email: teacher.email, - firstName: teacher.firstName, - id: teacher.id, - lastName: teacher.lastName, - short: teacher.short, - userEmail: user.email, - userId: teacher.userId, - userName: user.name, - }) - .from(teacher) - .leftJoin(user, eq(user.id, teacher.userId)) - .where(eq(teacher.id, id)) - .limit(1); - - if (!updated) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'Teacher not found', - }); - } - - const data: TeacherListItem = { - email: updated.email, - firstName: updated.firstName, - id: updated.id, - lastName: updated.lastName, - short: updated.short, - user: updated.userId - ? { - email: updated.userEmail ?? '', - id: updated.userId, - name: updated.userName ?? '', - } - : null, - userId: updated.userId, - }; - - return ok(c, 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 deleted file mode 100644 index 5838b131..00000000 --- a/apps/chronos/src/routes/users/_router.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { usersFactory } from '#routes/users/_factory'; -import { - createApiKeyRoute, - listApiKeysRoute, - revokeApiKeyRoute, -} 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); diff --git a/apps/chronos/src/routes/users/api-keys.ts b/apps/chronos/src/routes/users/api-keys.ts deleted file mode 100644 index 0b000aa4..00000000 --- a/apps/chronos/src/routes/users/api-keys.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - apiKeyIdParamsSchema, - createApiKeySchema, -} from '@filcdev/api/domains/api-keys'; -import { zValidator } from '@hono/zod-validator'; -import { getLogger } from '@logtape/logtape'; -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 { generateApiKey } from '#utils/api-keys'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; -import { usersFactory } from './_factory'; - -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); - } - - const keys = await db - .select() - .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); - } - - const { name, expiresAt } = c.req.valid('json'); - const { hash, key, prefix } = generateApiKey(); - - const [inserted] = await db - .insert(apiKey) - .values({ - expiresAt: expiresAt ?? null, - keyHash: hash, - name, - prefix, - userId: session.userId, - }) - .returning(); - - if (!inserted) { - throw new HTTPException(StatusCodes.INTERNAL_SERVER_ERROR, { - message: 'Failed to create API key', - }); - } - - logger.info('Created API key', { - id: inserted.id, - 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); - } - - const { id } = c.req.valid('param'); - - const [deleted] = await db - .delete(apiKey) - .where(and(eq(apiKey.id, id), eq(apiKey.userId, session.userId))) - .returning(); - - if (!deleted) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'API key not found', - }); - } - - logger.info('Revoked API key', { id, userId: session.userId }); - - return c.json({ success: true }); - } -); diff --git a/apps/chronos/src/routes/users/index.ts b/apps/chronos/src/routes/users/index.ts deleted file mode 100644 index efe365b1..00000000 --- a/apps/chronos/src/routes/users/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - listUsersQuerySchema, - userUpdatePayload, -} from '@filcdev/api/domains/users'; -import { permissions } from '@filcdev/api/permissions'; -import { zValidator } from '@hono/zod-validator'; -import { arrayContains, 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 { getUserPermissionsBulk } from '#utils/authorization'; -import { ok } from '#utils/http'; -import { filcExt } from '#utils/openapi'; -import { createSelectSchema } from '#utils/zod'; - -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(permissions.usersRead), - zValidator('query', listUsersQuerySchema), - async (c) => { - const { limit, offset, role, search } = c.req.valid('query'); - - const whereClause = or( - search - ? or( - ilike(user.name, `%${search}%`), - ilike(user.email, `%${search}%`), - ilike(user.nickname, `%${search}%`) - ) - : undefined, - role ? arrayContains(user.roles, [role]) : undefined - ); - - const usersQuery = await db - .select() - .from(user) - .where(whereClause) - .limit(limit) - .offset(offset) - .orderBy(desc(user.createdAt)); - - const permissionsByUser = await getUserPermissionsBulk( - usersQuery.map((u) => u.id) - ); - - const users = usersQuery.map((u) => ({ - ...u, - displayName: u.nickname ? u.nickname : u.name || 'Unknown user', - permissions: permissionsByUser.get(u.id) ?? [], - })); - - const [countResult] = await db - .select({ count: count() }) - .from(user) - .where(whereClause); - - return ok(c, { 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(permissions.usersManage), - 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'); - - const [updatedUser] = await db - .update(user) - .set({ - ...(cohortId === undefined ? {} : { cohortId }), - ...(nickname === undefined ? {} : { nickname }), - ...(roles === undefined ? {} : { roles }), - }) - .where(eq(user.id, userId)) - .returning(); - - if (!updatedUser) { - throw new HTTPException(StatusCodes.NOT_FOUND, { - message: 'User not found', - }); - } - - return ok(c, updatedUser); - } -); diff --git a/apps/chronos/src/utils/api-keys.ts b/apps/chronos/src/utils/api-keys.ts deleted file mode 100644 index 905749e8..00000000 --- a/apps/chronos/src/utils/api-keys.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; -import { getLogger } from '@logtape/logtape'; -import { eq } from 'drizzle-orm'; -import { db } from '#database'; -import { apiKey } from '#database/schema/api-keys'; -import { user } from '#database/schema/authentication'; -import { getUserPermissions } from '#utils/authorization'; - -const logger = getLogger(['chronos', 'api-keys']); - -const KEY_PREFIX_LENGTH = 8; -const KEY_RANDOM_BYTES = 32; -const SCRYPT_KEYLEN = 64; -const SCRYPT_COST = 16_384; -const SCRYPT_BLOCK_SIZE = 8; -const SCRYPT_PARALLELIZATION = 1; -const BEARER_REGEX = /^Bearer\s+(.+)$/i; - -/** - * Generate a new API key. The returned `key` is the only time the raw secret - * is available — callers must surface it to the user immediately. We store a - * scrypt-derived key (with a per-key random salt) plus a short, non-secret - * prefix for later identification. The stored value has the form - * `salt:keyHex` so the salt is persisted alongside the derived key. - */ -export const generateApiKey = (): { - hash: string; - key: string; - prefix: string; -} => { - const random = crypto.getRandomValues(new Uint8Array(KEY_RANDOM_BYTES)); - const secret = Array.from(random, (b) => - b.toString(16).padStart(2, '0') - ).join(''); - const prefix = secret.slice(0, KEY_PREFIX_LENGTH); - const key = `${prefix}.${secret}`; - - const hash = hashApiKey(key); - - return { hash, key, prefix }; -}; - -/** - * Derive a stored representation of an API key using scrypt with a per-key - * random salt. Returns `salt:keyHex` where both halves are hex-encoded. The - * salt is required at validation time, so it is persisted with the derived - * key. Scrypt provides far more computational effort than a plain SHA-256 - * hash, which is what makes it suitable for credential storage. - */ -export const hashApiKey = (key: string): string => { - const salt = randomBytes(16); - const derived = scryptSync(key, salt, SCRYPT_KEYLEN, { - N: SCRYPT_COST, - p: SCRYPT_PARALLELIZATION, - r: SCRYPT_BLOCK_SIZE, - }); - - return `${salt.toString('hex')}:${derived.toString('hex')}`; -}; - -/** - * Verify a raw API key against a stored `salt:keyHex` representation using a - * constant-time comparison to avoid timing side-channels. - */ -const verifyApiKey = (key: string, stored: string): boolean => { - const [saltHex, keyHex] = stored.split(':'); - if (!(saltHex && keyHex)) { - return false; - } - - const salt = Buffer.from(saltHex, 'hex'); - const expected = Buffer.from(keyHex, 'hex'); - const derived = scryptSync(key, salt, expected.length, { - N: SCRYPT_COST, - p: SCRYPT_PARALLELIZATION, - r: SCRYPT_BLOCK_SIZE, - }); - - if (derived.length !== expected.length) { - return false; - } - - return timingSafeEqual(derived, expected); -}; - -export type ApiKeyUser = { - id: string; - email: string; - name: string; - nickname: string | null; - roles: string[]; - displayName: string; - permissions: string[]; -}; - -/** - * Validate a raw API key. Returns the owning user (augmented with the same - * shape the session middleware produces) when the key exists, is not expired - * and belongs to an existing user. On success the key's `lastUsedAt` is bumped. - */ -export const validateApiKey = async ( - key: string -): Promise => { - // Scrypt is non-reversible, so we cannot query by a recomputed hash. Load - // candidate keys for the prefix (a cheap, non-secret filter) and verify the - // raw key against each stored `salt:keyHex` value in constant time. - const prefix = key.split('.')[0]?.slice(0, KEY_PREFIX_LENGTH); - if (!prefix) { - return null; - } - - const records = await db - .select() - .from(apiKey) - .where(eq(apiKey.prefix, prefix)) - .limit(20); - - const record = records.find((r) => verifyApiKey(key, r.keyHash)); - if (!record) { - return null; - } - - if (record.expiresAt && record.expiresAt.getTime() < Date.now()) { - logger.debug('Rejected expired API key', { id: record.id }); - return null; - } - - const [owner] = await db - .select() - .from(user) - .where(eq(user.id, record.userId)) - .limit(1); - - if (!owner) { - return null; - } - - await db - .update(apiKey) - .set({ lastUsedAt: new Date() }) - .where(eq(apiKey.id, record.id)); - - const permissions = await getUserPermissions(owner.id); - - return { - ...owner, - displayName: owner.nickname ? owner.nickname : owner.name || 'Unknown user', - permissions, - }; -}; - -/** Extract a raw API key from the common header conventions. */ -export const extractApiKey = (headers: Headers): string | null => { - const authorization = headers.get('authorization'); - if (authorization) { - const match = BEARER_REGEX.exec(authorization.trim()); - if (match?.[1]) { - return match[1].trim(); - } - } - - const xApiKey = headers.get('x-api-key'); - if (xApiKey) { - return xApiKey.trim(); - } - - return null; -}; diff --git a/apps/chronos/src/utils/authentication.ts b/apps/chronos/src/utils/authentication.ts index 9cee3119..ddac4b1c 100644 --- a/apps/chronos/src/utils/authentication.ts +++ b/apps/chronos/src/utils/authentication.ts @@ -1,22 +1,32 @@ +import { apiKey } from '@better-auth/api-key'; +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 { apiKeySchema } from '#database/schema/api-keys'; import { authenticationSchema, user as userTable, } from '#database/schema/authentication'; -import { teacher } from '#database/schema/timetable'; +import { userPreferences } from '#modules/notifications/schema'; +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'; const logger = getLogger(['chronos', 'auth']); +/** + * The `Authorization: Bearer ` form the aegis door-lock firmware sends. + * The api-key plugin only reads `x-api-key` by default, so `customAPIKeyGetter` + * below also accepts this shape. + */ +const BEARER_TOKEN_REGEX = /^Bearer\s+(.+)$/i; + /** * Preview deployments sign in through the production origin: Entra rejects * wildcard redirect URIs, and registering one URI per pull request does not @@ -57,7 +67,10 @@ const authOptions = { baseURL: env.baseUrl, database: drizzleAdapter(db, { provider: 'pg', - schema: authenticationSchema, + // The plugin's `apikey` table must be in the adapter's schema or + // better-auth's own schema check rejects every api-key call. The rest of + // the auth tables stay in `authenticationSchema`. + schema: { ...authenticationSchema, ...apiKeySchema }, }), databaseHooks: { session: { @@ -92,6 +105,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(); @@ -132,7 +167,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, }); @@ -142,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, @@ -186,24 +230,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; @@ -211,6 +238,42 @@ export const auth = betterAuth({ ...authOptions, plugins: [ ...(authOptions.plugins ?? []), + // API keys come from better-auth's official plugin, which owns the + // `apikey` table, hashing (SHA-256), expiry, enable/disable and per-key + // rate limiting. `enableSessionForAPIKeys` lets a valid key stand in for a + // session, which is what lets the oRPC middleware treat a key holder like + // any other caller. + // + // `x-api-key` is the plugin's default header; the `Authorization: Bearer` + // form is added here because the aegis door-lock firmware sends a bearer + // token, and the plugin has no built-in support for it. + apiKey({ + customAPIKeyGetter: (ctx) => { + const headers = ctx.headers; + if (!headers) { + return null; + } + const bearer = headers.get('authorization'); + if (bearer) { + const match = BEARER_TOKEN_REGEX.exec(bearer.trim()); + if (match?.[1]) { + return match[1].trim(); + } + } + return headers.get('x-api-key'); + }, + enableSessionForAPIKeys: true, + // The public timetable and door-lock surfaces are the point of these + // keys, so a key must not be able to reach the whole API by default. + // `getUserPermissions` is resolved per owner when a key is created, and + // the middleware still enforces the route's own permission. + rateLimit: { + enabled: true, + maxRequests: env.apiKeyRateLimitMax, + timeWindow: env.apiKeyRateLimitWindowMs, + }, + requireName: true, + }), customSession(async ({ user, session }) => { const permissions = await getUserPermissions(user.id); const displayName = user.nickname @@ -228,9 +291,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/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/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/cron.ts b/apps/chronos/src/utils/cron.ts index b94932a6..39c25796 100644 --- a/apps/chronos/src/utils/cron.ts +++ b/apps/chronos/src/utils/cron.ts @@ -1,8 +1,6 @@ import { getLogger } from '@logtape/logtape'; import Baker from 'cronbake'; -import { cleanUpOldDeviceAuditLogs } from '#utils/doorlock/cards'; -import { cleanUpOldNotifications } from '#utils/notifications/cleanup'; -import { cleanupOrphanedCohorts } from '#utils/timetable/cleanup'; +import { modules } from '#modules'; const logger = getLogger(['chronos', 'cron']); @@ -14,26 +12,17 @@ export const baker = Baker.create({ }, }); +/** + * Register every module's scheduled work. A job lives in the module that owns + * the thing it maintains, so this is the only file that has to know a module + * exists. + */ export const setupCronJobs = () => { - baker.add({ - callback: cleanUpOldDeviceAuditLogs, - cron: '@monthly', - name: 'clean-up-old-card-audit-logs', - }); - - baker.add({ - callback: cleanUpOldNotifications, - cron: '@daily', - name: 'clean-up-old-notifications', - }); - - baker.add({ - callback: async () => { - await cleanupOrphanedCohorts(); - }, - cron: '@daily', - name: 'clean-up-orphaned-cohorts', - }); + for (const module of modules) { + for (const job of module.jobs ?? []) { + baker.add(job); + } + } const jobs = baker.getJobNames(); 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/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/chronos/src/utils/environment.ts b/apps/chronos/src/utils/environment.ts index 2c1ccf49..3a865849 100644 --- a/apps/chronos/src/utils/environment.ts +++ b/apps/chronos/src/utils/environment.ts @@ -15,24 +15,38 @@ const boolean = z.preprocess((v) => { const envShape = z.object({ CHRONOS_ADMIN_EMAIL: z.email(), + CHRONOS_API_KEY_RATE_LIMIT_MAX: z.coerce.number().default(120), + CHRONOS_API_KEY_RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60_000), CHRONOS_AUTH_SECRET: z.base64().min(MIN_SECRET_LENGTH), CHRONOS_BASE_URL: z.url(), 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(), 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'), 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 @@ -71,6 +85,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(), @@ -84,6 +99,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/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/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/navigator/errors.ts b/apps/chronos/src/utils/navigator/errors.ts deleted file mode 100644 index c09099d7..00000000 --- a/apps/chronos/src/utils/navigator/errors.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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. - */ -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; -}; - -/** - * 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; -}; diff --git a/apps/chronos/src/utils/navigator/schemas.ts b/apps/chronos/src/utils/navigator/schemas.ts deleted file mode 100644 index a06a853b..00000000 --- a/apps/chronos/src/utils/navigator/schemas.ts +++ /dev/null @@ -1,112 +0,0 @@ -import z from 'zod'; -import { - navigatorCorridor, - navigatorLift, - navigatorStair, - navigatorTranslation, -} from '#database/schema/navigator'; -import { building, classroom, classroomType } from '#database/schema/timetable'; -import { createSelectSchema } from '#utils/zod'; - -/** Row schemas derived from the tables, so the docs always match the wire. */ -export const buildingSelectSchema = createSelectSchema(building); -export const classroomSelectSchema = createSelectSchema(classroom); -export const classroomTypeSelectSchema = createSelectSchema(classroomType); -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/utils/news/cohort.ts b/apps/chronos/src/utils/news/cohort.ts deleted file mode 100644 index ec98c8f7..00000000 --- a/apps/chronos/src/utils/news/cohort.ts +++ /dev/null @@ -1,20 +0,0 @@ -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'; - -/** Throw 400 if any of the provided cohort IDs do not exist. */ -export const validateCohortIds = async (cohortIds: string[]) => { - const existingCohorts = await db - .select({ id: cohort.id }) - .from(cohort) - .where(sql`${cohort.id} IN ${cohortIds}`); - 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(', ')}`, - }); - } -}; diff --git a/apps/chronos/src/utils/news/shared.ts b/apps/chronos/src/utils/news/shared.ts deleted file mode 100644 index 2ac767bd..00000000 --- a/apps/chronos/src/utils/news/shared.ts +++ /dev/null @@ -1,95 +0,0 @@ -import z from 'zod'; -import { user } from '#database/schema/authentication'; -import { announcement, systemMessage } from '#database/schema/news'; -import { createSelectSchema } from '#utils/zod'; - -/** Shared `author` projection used by announcement/system-message list & detail. */ -export const authorSelect = { - id: user.id, - image: user.image, - name: user.name, -}; - -/** - * Shared column projection for announcement list & detail, so a new column - * cannot be forgotten in one of the hand-written selects. Spread the author - * projection next to it: `{ ...announcementSelect, author: authorSelect }`. - */ -export const announcementSelect = { - authorId: announcement.authorId, - content: announcement.content, - createdAt: announcement.createdAt, - highlighted: announcement.highlighted, - id: announcement.id, - imageByteSize: announcement.imageByteSize, - imageContentType: announcement.imageContentType, - imageKey: announcement.imageKey, - imageUpdatedAt: announcement.imageUpdatedAt, - kioskOnly: announcement.kioskOnly, - title: announcement.title, - updatedAt: announcement.updatedAt, - validFrom: announcement.validFrom, - 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/utils/notifications/cleanup.ts b/apps/chronos/src/utils/notifications/cleanup.ts index dbc70483..00e7e124 100644 --- a/apps/chronos/src/utils/notifications/cleanup.ts +++ b/apps/chronos/src/utils/notifications/cleanup.ts @@ -1,7 +1,7 @@ import { getLogger } from '@logtape/logtape'; import { lt } from 'drizzle-orm'; import { db } from '#database'; -import { notification } from '#database/schema/notifications'; +import { notification } from '#modules/notifications/schema'; const logger = getLogger(['chronos', 'notifications', 'cleanup']); diff --git a/apps/chronos/src/utils/notifications/engine.ts b/apps/chronos/src/utils/notifications/engine.ts index 48a2a7c2..11c3ffbd 100644 --- a/apps/chronos/src/utils/notifications/engine.ts +++ b/apps/chronos/src/utils/notifications/engine.ts @@ -2,19 +2,17 @@ import { getLogger } from '@logtape/logtape'; import { eq, inArray } from 'drizzle-orm'; import { db } from '#database'; import { user as userTable } from '#database/schema/authentication'; -import { notification, userPreferences } from '#database/schema/notifications'; -import { lessonCohortMTM } from '#database/schema/timetable'; +import { notification, userPreferences } from '#modules/notifications/schema'; +import { lessonCohortMTM } from '#modules/timetable/schema'; import { enqueue } from '#utils/notifications/queue'; -import { - resolveSubstituteTeacherAudience, - type SubstitutionTeacherPayload, -} from '#utils/notifications/substitution-teacher'; 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']); @@ -45,7 +43,7 @@ export function cancelPendingNotification( } } -async function resolveAudienceForLessonChange( +export async function resolveAudienceForLessonChange( payload: unknown ): Promise { const p = payload as { lessonIds?: string[] }; @@ -82,7 +80,7 @@ async function resolveAudienceByCohortIds( return enrichUsersWithLanguage(users); } -async function resolveAudienceByCohortOrAll( +export async function resolveAudienceByCohortOrAll( cohortIds?: string[] ): Promise { let users: { id: string; email: string; cohortId: string | null }[]; @@ -108,7 +106,7 @@ async function resolveAudienceByCohortOrAll( return enrichUsersWithLanguage(users); } -async function resolveAudienceForBlogPost(): Promise { +export async function resolveAudienceForBlogPost(): Promise { const users = await db .select({ cohortId: userTable.cohortId, @@ -120,70 +118,41 @@ async function resolveAudienceForBlogPost(): Promise { return enrichUsersWithLanguage(users); } -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); + const { userId } = payload as { userId: string }; - return [ - { - cohortId: u.cohortId, - email: u.email, - id: u.id, - language: prefs?.language ?? 'hu', - }, - ]; -} - -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', }, ]; @@ -219,43 +188,6 @@ async function enrichUsersWithLanguage( }); } -const audienceResolvers: Record< - string, - (payload: unknown) => Promise -> = { - announcement: (p) => - resolveAudienceByCohortOrAll((p as { cohortIds?: string[] }).cohortIds), - blog_post: () => resolveAudienceForBlogPost(), - cohort_reselection_required: resolveAudienceForCohortReselection, - doorlock_card_used: resolveAudienceForDoorlock, - moved_lesson: resolveAudienceForLessonChange, - substitution: resolveAudienceForLessonChange, - substitution_teacher: (payload) => - resolveSubstituteTeacherAudience(payload as SubstitutionTeacherPayload), - system_message: (p) => - resolveAudienceByCohortOrAll((p as { cohortIds?: string[] }).cohortIds), -}; - -const preferenceKeys: Record< - string, - keyof { - substitution: boolean; - movedLesson: boolean; - announcement: boolean; - systemMessage: boolean; - blogPost: boolean; - doorlockCardUsed: boolean; - } -> = { - announcement: 'announcement', - blog_post: 'blogPost', - doorlock_card_used: 'doorlockCardUsed', - moved_lesson: 'movedLesson', - substitution: 'substitution', - substitution_teacher: 'substitution', - system_message: 'systemMessage', -}; - async function fireNotification( type: NotificationType, payload: unknown @@ -269,10 +201,7 @@ async function fireNotification( } try { - const resolver = audienceResolvers[type]; - const audience = resolver - ? await resolver(payload) - : await handler.getAudience(payload); + const audience = await handler.getAudience(payload); if (audience.length === 0) { logger.debug('Empty audience for notification type: {type}', { type }); @@ -286,8 +215,17 @@ async function fireNotification( .where(eq(userPreferences.userId, user.id)) .limit(1); - const prefKey = preferenceKeys[type]; - if (prefs && prefKey && !prefs.notificationPreferences[prefKey]) { + // 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 (prefKey && !preferences[prefKey]) { logger.debug('User {userId} opted out of notification type {type}', { type, userId: user.id, @@ -295,8 +233,7 @@ async function fireNotification( continue; } - const channelsEnabled = - prefs?.notificationPreferences.channelsEnabled ?? true; + const channelsEnabled = preferences.channelsEnabled; const content: NotificationContent = handler.buildContent( payload, user.language @@ -318,6 +255,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/initialize.ts b/apps/chronos/src/utils/notifications/initialize.ts index 35f8f01f..ea51f32a 100644 --- a/apps/chronos/src/utils/notifications/initialize.ts +++ b/apps/chronos/src/utils/notifications/initialize.ts @@ -1,328 +1,29 @@ import { getLogger } from '@logtape/logtape'; -import { env } from '#utils/environment'; +import { modules } from '#modules'; import { registerHandler } from '#utils/notifications/engine'; import { initializeFcm } from '#utils/notifications/providers/fcm'; -import { - buildSubstitutionTeacherContent, - type SubstitutionTeacherPayload, -} from '#utils/notifications/substitution-teacher'; const logger = getLogger(['chronos', 'notifications', 'initialize']); -const DAY_NAMES_EN = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; -const DAY_NAMES_HU = ['Hétfő', 'Kedd', 'Szerda', 'Csütörtök', 'Péntek']; -const WEEKDAY_NAMES_EN = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', -]; -const WEEKDAY_NAMES_HU = [ - 'Vasárnap', - 'Hétfő', - 'Kedd', - 'Szerda', - 'Csütörtök', - 'Péntek', - 'Szombat', -]; - -function normalizeLocale(locale: string): string { - return locale.toLowerCase().split('-')[0] ?? locale; -} - -function isHungarianLocale(locale: string): boolean { - return normalizeLocale(locale) === 'hu'; -} - -function dayName(day: string, locale: string): string { - const names = isHungarianLocale(locale) ? DAY_NAMES_HU : DAY_NAMES_EN; - const num = Number.parseInt(day, 10); - if (num >= 1 && num <= names.length) { - return names[num - 1] ?? day; - } - return day; -} - -function formatDate(date: Date, locale: string): string { - const day = String(date.getDate()).padStart(2, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const year = date.getFullYear(); - const weekday = isHungarianLocale(locale) - ? WEEKDAY_NAMES_HU[date.getDay()] - : WEEKDAY_NAMES_EN[date.getDay()]; - if (isHungarianLocale(locale)) { - return `${weekday}, ${year}. ${month}. ${day}.`; - } - return `${weekday}, ${year}-${month}-${day}`; -} - -function substitutionContentHu( - dateStr: string, - subName: string, - lessonCount: number -): string { - const timetableCta = ' Nézd meg az órarendedben a részletekért!'; - if (subName && lessonCount > 0) { - return `${subName} helyettesít ${dateStr}, ${lessonCount} órádat tartja meg.${lessonCount > 1 ? timetableCta : ''}`; - } - if (subName) { - return `${subName} helyettesít ${dateStr}.${timetableCta}`; - } - if (lessonCount > 0) { - return `Helyettesítés ${dateStr} — ${lessonCount} órádat érinti.${lessonCount > 1 ? timetableCta : ''}`; - } - return `Új helyettesítés ${dateStr}.${timetableCta}`; -} - -function substitutionContentEn( - dateStr: string, - subName: string, - lessonCount: number -): string { - const timetableCta = ' Check your timetable for details.'; - if (subName && lessonCount > 0) { - const lessonWord = lessonCount > 1 ? 'lessons' : 'lesson'; - return `${subName} is covering ${lessonCount} of your ${lessonWord} on ${dateStr}.${lessonCount > 1 ? timetableCta : ''}`; - } - if (subName) { - return `${subName} will be covering your lesson(s) on ${dateStr}.${timetableCta}`; - } - if (lessonCount > 0) { - const lessonWord = lessonCount > 1 ? 'lessons' : 'lesson'; - return `A substitution on ${dateStr} affects ${lessonCount} of your ${lessonWord}.${lessonCount > 1 ? timetableCta : ''}`; - } - return `A new substitution has been posted for ${dateStr}.${timetableCta}`; -} - -function buildSubstitutionContent( - p: { date?: Date; substituter?: string | null; lessonIds?: string[] }, - locale: string -): string { - const date = p.date ? new Date(p.date) : null; - const dateStr = date ? formatDate(date, locale) : ''; - const subName = p.substituter ?? ''; - const lessonCount = p.lessonIds?.length ?? 0; - return isHungarianLocale(locale) - ? substitutionContentHu(dateStr, subName, lessonCount) - : substitutionContentEn(dateStr, subName, lessonCount); -} - -function periodLabel(period: string, locale: string): string { - return isHungarianLocale(locale) ? `${period}. óra` : `Period ${period}`; -} - -function movedLessonContentHu( - p: { - room?: string; - startingDay?: string; - startingPeriod?: string; - }, - multiple: boolean -): string { - const roomPart = p.room ? `, ${p.room} terem` : ''; - const dayPart = p.startingDay ? dayName(p.startingDay, 'hu') : ''; - const periodPart = p.startingPeriod - ? periodLabel(p.startingPeriod, 'hu') - : ''; - const timePart = [dayPart, periodPart].filter(Boolean).join(', '); - const suffix = multiple - ? ' Nézd meg az órarendedben a további változásokat!' - : ''; - const location = timePart - ? `${timePart}${roomPart}` - : `másik időpontra${roomPart ? ` (${roomPart.slice(2)})` : ''}`; - return `Az órád át lett helyezve: ${location}.${suffix}`; -} - -function movedLessonContentEn( - p: { - room?: string; - startingDay?: string; - startingPeriod?: string; - }, - multiple: boolean -): string { - const roomPart = p.room ? ` in room ${p.room}` : ''; - const dayPart = p.startingDay ? dayName(p.startingDay, 'en') : ''; - const periodPart = p.startingPeriod - ? periodLabel(p.startingPeriod, 'en') - : ''; - const atPart = - dayPart && periodPart ? `${dayPart}, ${periodPart}` : dayPart || periodPart; - const suffix = multiple ? ' Check your timetable for all updates.' : ''; - const location = atPart - ? `${atPart}${roomPart}` - : `a new time slot${roomPart ? ` (${roomPart.slice(1)})` : ''}`; - return `Your lesson has been moved to ${location}.${suffix}`; -} - -export function initializeNotificationEngine(): void { +/** + * Register every module's notification handlers, then the delivery channels. + * + * What a notification says, who receives it and which preference gates it all + * live with the module that raises it; this only wires the handlers the + * modules declared into the engine. + */ +export async function initializeNotificationEngine(): Promise { logger.info('Initializing notification engine'); - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { - date?: Date; - substituter?: string | null; - lessonIds?: string[]; - }; - return { - content: buildSubstitutionContent(p, locale), - title: isHungarianLocale(locale) - ? 'Új helyettesítés' - : 'New Substitution', - }; - }, - getAudience: async () => [], - getDelay: () => env.notificationDelaySubstitution, - type: 'substitution', - }); - - registerHandler({ - buildContent: (payload, locale) => - buildSubstitutionTeacherContent( - payload as SubstitutionTeacherPayload, - locale - ), - getAudience: async () => [], - getDelay: () => env.notificationDelaySubstitution, - type: 'substitution_teacher', - }); - - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { - date?: Date; - lessonIds?: string[]; - room?: string; - startingDay?: string; - startingPeriod?: string; - }; - const multiple = (p.lessonIds?.length ?? 0) > 1; - const content = isHungarianLocale(locale) - ? movedLessonContentHu(p, multiple) - : movedLessonContentEn(p, multiple); - return { - content, - title: isHungarianLocale(locale) ? 'Áthelyezett óra' : 'Moved Lesson', - }; - }, - getAudience: async () => [], - getDelay: () => env.notificationDelayMovedLesson, - type: 'moved_lesson', - }); - - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { title: string }; - const title = p.title || ''; - const prefix = isHungarianLocale(locale) - ? 'Új bejelentés' - : 'New announcement'; - return { - content: `${prefix}: ${title}`, - title: isHungarianLocale(locale) ? 'Új bejelentés' : 'New Announcement', - }; - }, - getAudience: async () => [], - getDelay: () => env.notificationDelayAnnouncement, - type: 'announcement', - }); - - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { title: string }; - const title = p.title || ''; - const prefix = isHungarianLocale(locale) - ? 'Rendszerüzenet' - : 'System notice'; - return { - content: `${prefix}: ${title}`, - title: isHungarianLocale(locale) ? 'Rendszerüzenet' : 'System Message', - }; - }, - getAudience: async () => [], - getDelay: () => env.notificationDelaySystemMessage, - type: 'system_message', - }); - - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { title: string; slug?: string }; - const title = p.title || ''; - const cta = isHungarianLocale(locale) - ? 'koppints az olvasáshoz' - : 'tap to read'; - const prefix = isHungarianLocale(locale) ? 'Új bejegyzés' : 'New post'; - const content = `${prefix}: ${title} — ${cta}`; - const result: { - content: string; - title: string; - metadata?: Record; - } = { - content, - title: isHungarianLocale(locale) ? 'Új blogbejegyzés' : 'New Blog Post', - }; - if (p.slug) { - result.metadata = { slug: p.slug }; - } - return result; - }, - getAudience: async () => [], - getDelay: () => env.notificationDelayBlogPost, - type: 'blog_post', - }); - - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { deviceName: string }; - const device = p.deviceName; - let content: string; - if (device) { - content = isHungarianLocale(locale) - ? `A belépőkártyád használatát észleltük: ${device}.` - : `Your access card was just used at ${device}.`; - } else { - content = isHungarianLocale(locale) - ? 'A belépőkártyád használatát észleltük.' - : 'Your access card was just used.'; - } - return { - content, - title: isHungarianLocale(locale) - ? 'Belépőkártya használat' - : 'Card Used', - }; - }, - getAudience: async () => [], - getDelay: () => 0, - type: 'doorlock_card_used', - }); - - registerHandler({ - buildContent: (payload, locale) => { - const p = payload as { userId: string }; - const content = isHungarianLocale(locale) - ? 'A csoportod már nem elérhető. Kérjük, válassz új csoportot a Beállítások menüben.' - : 'Your cohort is no longer available. Please go to Settings to select a new cohort.'; - return { - content, - metadata: { action: 'cohort_reselection', userId: p.userId }, - title: isHungarianLocale(locale) - ? 'Csoport újraválasztása szükséges' - : 'Cohort Reselection Required', - }; - }, - getAudience: async () => [], - getDelay: () => 0, - type: 'cohort_reselection_required', - }); + let count = 0; + for (const module of modules) { + for (const handler of module.notifications ?? []) { + registerHandler(handler); + count += 1; + } + } - initializeFcm(); + await initializeFcm(); - logger.info('Notification engine initialized with 7 handlers'); + logger.info(`Notification engine initialized with ${count} handlers`); } diff --git a/apps/chronos/src/utils/notifications/locale.ts b/apps/chronos/src/utils/notifications/locale.ts new file mode 100644 index 00000000..c738a5ee --- /dev/null +++ b/apps/chronos/src/utils/notifications/locale.ts @@ -0,0 +1,8 @@ +/** + * Whether a notification should be written in Hungarian. Every module that + * owns a notification branches on this, so it lives here rather than being + * copied per feature. Matches on the BCP-47 prefix, so `hu-HU` counts too. + */ +export function isHungarianLocale(locale: string): boolean { + return (locale.toLowerCase().split('-')[0] ?? locale) === 'hu'; +} diff --git a/apps/chronos/src/utils/notifications/providers/fcm.ts b/apps/chronos/src/utils/notifications/providers/fcm.ts index fc487253..9dddd08a 100644 --- a/apps/chronos/src/utils/notifications/providers/fcm.ts +++ b/apps/chronos/src/utils/notifications/providers/fcm.ts @@ -5,32 +5,34 @@ import type { MulticastMessage, } from 'firebase-admin/messaging'; import { db } from '#database'; -import { fcmToken } from '#database/schema/notifications'; +import { fcmToken } from '#modules/notifications/schema'; import { env } from '#utils/environment'; 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/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..f032e873 100644 --- a/apps/chronos/src/utils/notifications/queue.ts +++ b/apps/chronos/src/utils/notifications/queue.ts @@ -32,9 +32,8 @@ async function processJob(job: DeliveryJob): Promise { const emailPromise = sendEmail( job.email, - job.title, job.type, - 'hu', + job.language, { content: job.content, title: job.title, diff --git a/apps/chronos/src/utils/notifications/substitution-teacher.ts b/apps/chronos/src/utils/notifications/substitution-teacher.ts index 632043d6..2644bc42 100644 --- a/apps/chronos/src/utils/notifications/substitution-teacher.ts +++ b/apps/chronos/src/utils/notifications/substitution-teacher.ts @@ -1,7 +1,7 @@ import { eq, inArray } from 'drizzle-orm'; import { db } from '#database'; import { user as userTable } from '#database/schema/authentication'; -import { userPreferences } from '#database/schema/notifications'; +import { userPreferences } from '#modules/notifications/schema'; import { classroom, cohort, @@ -10,7 +10,8 @@ import { period, subject, teacher, -} from '#database/schema/timetable'; +} 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/notifications/types.ts b/apps/chronos/src/utils/notifications/types.ts index 86ff5fce..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; @@ -32,11 +48,19 @@ export type NotificationContent = { metadata?: Record; }; +/** + * How one module declares the notification it owns: what it says, who gets it, + * how long the engine waits, and the opt-out flag that gates it. The engine + * holds no per-type bookkeeping of its own, so a new notification type is a + * module-local change plus one union member. + */ export type NotificationHandler = { - type: NotificationType; - getDelay: () => number; - getAudience: (payload: T) => Promise; buildContent: (payload: T, locale: string) => NotificationContent; + getAudience: (payload: T) => Promise; + getDelay: () => number; + /** Absent means the type is not gated on a user preference. */ + preferenceKey?: keyof NotificationPreferences; + type: NotificationType; }; export type DeliveryJob = { @@ -47,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/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/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 `