diff --git a/.env.example b/.env.example index edea1cb..11e1602 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,29 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" GUEST_JWT_SECRET="change-me" PORT=3000 + +# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to +# use an S3-compatible bucket instead (see S3_* vars below). +STORAGE_PROVIDER="webdav" +WEBDAV_URL="https://nextcloud.example.org/remote.php/dav/files/kc-app" +WEBDAV_USERNAME="kc-app" +WEBDAV_PASSWORD="change-me" + +# Only used when STORAGE_PROVIDER=s3 +S3_BUCKET="kc-app" +S3_REGION="auto" +S3_ENDPOINT="" +S3_FORCE_PATH_STYLE="false" +S3_ACCESS_KEY_ID="" +S3_SECRET_ACCESS_KEY="" + +# Unique id for THIS server instance (local on-site vs. cloud); used to tag +# replication log entries and avoid echoing changes back to their origin. +SERVER_ID="change-me-uuid" + +# Local/cloud sync: set on the LOCAL (on-site) server to periodically push/ +# pull against the cloud instance's API base URL. Leave SYNC_ENABLED=false +# on the cloud server (it only needs to expose /sync/ingest + /sync/export). +SYNC_ENABLED="false" +SYNC_PEER_URL="https://kc-app-cloud.example.org/api" +SYNC_SHARED_SECRET="change-me" diff --git a/README.md b/README.md index b8f69ab..93b5912 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ npx prisma migrate dev --name init # requires a running PostgreSQL instance npm run start:dev ``` +The API is served under `/api` (see `app.setGlobalPrefix('api')` in +`main.ts`); everything else (`/`, `/app.js`, ...) is served statically from +`../client/web` via `ServeStaticModule`, so the backend doubles as the web +client's host - no separate web server is needed. + ## Auth model - Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are @@ -28,10 +33,44 @@ npm run start:dev ## Modules implemented so far - `prisma/` — shared `PrismaClient` provider. -- `auth/` — Authentik resource-server strategy + guest invite-code login. +- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + + guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). - `kc/` — KC (event) creation/listing, Leitungsteam-only. +- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest + Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: + a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → + up to 3 wish rounds → random fill → consolidation of workshops that stay + below `minTeilnehmer`), plus CSV export (`GET /wahl/:id/zuteilung/csv`). +- `files/` — Leitungsteam-only upload (`POST /files/:kcId`, multipart) tagged + with a `FileVisibility` tier; list/download (`GET /files/:kcId`, + `GET /files/download/:fileId`) accept either an Authentik or a guest token + and filter by the caller's allowed visibility tiers. Storage is behind a + `StorageProvider` abstraction: defaults to Nextcloud via WebDAV + (`WEBDAV_*` env vars), switchable to S3-compatible storage with + `STORAGE_PROVIDER=s3` (`S3_*` env vars). +- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über- + greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration + and message history are plain REST (`ChatController`); real-time send/ + receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport + guards don't apply to WS upgrades — auth happens once via `?token=` at + connect time (`TokenVerificationService` tries Authentik JWKS, then falls + back to a guest token). Access rules live in `ChatService` and are shared + between the REST and WS entry points. +- `sync/` — replicates mutations between the local (on-site) and cloud + server. `SyncService.capture()` is called by feature services right after + a write, appending an entry to the append-only `SyncLogEntry` log tagged + with this server's `SERVER_ID`. The local server (set `SYNC_ENABLED=true`, + `SYNC_PEER_URL`) periodically pushes its new entries to the cloud's + `POST /sync/ingest` and pulls the cloud's via `GET /sync/export` + (`SyncSchedulerService`, every 30s), both guarded by `SYNC_SHARED_SECRET` + (`SyncSecretGuard`) rather than user auth. No conflict resolution is + implemented by design — the local server is the sole source of truth + while an event is live. `POST /sync/trigger` lets a Leitungsteam member + force an immediate push+pull. Known gap: only entity metadata is + replicated; uploaded file bytes only resolve on both sides if local and + cloud share the same Nextcloud/S3 backend. - `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped, Leitungsteam roles are global across all KCs). -Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat -realtime gateway, local/cloud sync engine. +All planned backend phases are implemented; remaining work is the Flutter +clients (see repo root README). diff --git a/package-lock.json b/package-lock.json index c0bfd1d..31b3040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-s3": "^3.679.0", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", @@ -16,15 +17,20 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.15", "@nestjs/platform-ws": "^10.4.15", + "@nestjs/schedule": "^4.1.1", + "@nestjs/serve-static": "^4.0.2", "@nestjs/websockets": "^10.4.15", "@prisma/client": "^5.22.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", + "multer": "^2.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "webdav": "^5.7.1", "ws": "^8.18.0" }, "devDependencies": { @@ -33,6 +39,8 @@ "@nestjs/testing": "^10.4.15", "@types/express": "^4.17.21", "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.7", + "@types/multer": "^1.4.12", "@types/node": "^20.17.9", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", @@ -215,6 +223,314 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1128.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1128.0.tgz", + "integrity": "sha512-tYEB4058LdhTiSS7sCVVSpqSAdjIc1jTaf1dDPoIRJbR/XI5A2ZOuCiV1Aebo/pria/pUJBOT0WjdZVIwaZtDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -741,6 +1057,34 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@buttercup/fetch": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "license": "MIT", + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, + "node_modules/@buttercup/fetch/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1824,6 +2168,15 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/jwt/node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@nestjs/passport": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", @@ -1855,6 +2208,24 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/@nestjs/platform-ws": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz", @@ -1895,6 +2266,20 @@ } } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", + "license": "MIT", + "dependencies": { + "cron": "3.1.7", + "uuid": "10.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -1919,6 +2304,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/serve-static": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.2.tgz", + "integrity": "sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==", + "license": "MIT", + "dependencies": { + "path-to-regexp": "0.2.5" + }, + "peerDependencies": { + "@fastify/static": "^6.5.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "express": "^4.18.1", + "fastify": "^4.7.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + } + }, + "node_modules/@nestjs/serve-static/node_modules/path-to-regexp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz", + "integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==", + "license": "MIT" + }, "node_modules/@nestjs/testing": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", @@ -1983,6 +2401,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2168,6 +2598,87 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -2411,14 +2922,21 @@ "license": "MIT" }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2433,6 +2951,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -3148,6 +3682,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -3335,7 +3881,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", "license": "MIT" }, "node_modules/base64-js": { @@ -3436,11 +3987,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3564,6 +4120,12 @@ "node": ">=10.16.0" } }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3695,6 +4257,15 @@ "dev": true, "license": "MIT" }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4105,6 +4676,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4120,6 +4701,24 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4403,6 +5002,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4975,6 +5586,45 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -4995,6 +5645,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -5230,6 +5903,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", @@ -5606,6 +6291,12 @@ "node": ">= 0.4" } }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5806,6 +6497,12 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -5921,6 +6618,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6935,6 +7644,12 @@ "node": ">=6" } }, + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7130,6 +7845,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7185,6 +7909,17 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7316,7 +8051,6 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -7494,21 +8228,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mute-stream": { @@ -7541,6 +8276,12 @@ "dev": true, "license": "MIT" }, + "node_modules/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", + "license": "MIT" + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -7548,6 +8289,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -7874,6 +8635,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7901,6 +8677,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", + "license": "ISC" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -8211,6 +8993,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8339,6 +9127,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -9028,6 +9822,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -9817,6 +10626,25 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9832,6 +10660,20 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -9905,6 +10747,58 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.10.0.tgz", + "integrity": "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A==", + "license": "MIT", + "dependencies": { + "@buttercup/fetch": "^0.2.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "entities": "^6.0.1", + "fast-xml-parser": "^5.7.2", + "hot-patcher": "^2.0.1", + "layerr": "^3.0.0", + "md5": "^2.3.0", + "minimatch": "^9.0.9", + "nested-property": "^4.0.0", + "node-fetch": "^3.3.2", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webdav/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -10133,6 +11027,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index b30c032..35e3b9d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "prisma:migrate": "prisma migrate dev" }, "dependencies": { + "@aws-sdk/client-s3": "^3.679.0", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", @@ -28,15 +29,20 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.15", "@nestjs/platform-ws": "^10.4.15", + "@nestjs/schedule": "^4.1.1", + "@nestjs/serve-static": "^4.0.2", "@nestjs/websockets": "^10.4.15", "@prisma/client": "^5.22.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", + "multer": "^2.0.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "webdav": "^5.7.1", "ws": "^8.18.0" }, "devDependencies": { @@ -45,6 +51,8 @@ "@nestjs/testing": "^10.4.15", "@types/express": "^4.17.21", "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.7", + "@types/multer": "^1.4.12", "@types/node": "^20.17.9", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", @@ -67,13 +75,19 @@ "typescript": "^5.6.3" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c3469d..112c711 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,19 +9,19 @@ datasource db { /// A Konfi-Castle event; the top-level tenant. One instance manages many KCs. model Kc { - id String @id @default(cuid()) - name String - inviteCode String @unique - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + inviteCode String @unique + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - gemeinden Gemeinde[] + gemeinden Gemeinde[] memberships Membership[] - wahlen Wahl[] - files File[] - channels ChatChannel[] - guests GuestAccount[] + wahlen Wahl[] + files File[] + channels ChatChannel[] + guests GuestAccount[] } /// A local congregation/community participating in one Kc. @@ -31,7 +31,7 @@ model Gemeinde { kcId String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] guests GuestAccount[] @@ -46,15 +46,16 @@ enum Role { /// Authentik-backed user (team member with elevated rights). model User { - id String @id @default(cuid()) - authentikSub String @unique - email String @unique - firstName String - lastName String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + authentikSub String @unique + email String @unique + firstName String + lastName String + createdAt DateTime @default(now()) - memberships Membership[] - messages ChatMessage[] + memberships Membership[] + messages ChatMessage[] + chatParticipations ChatParticipant[] } /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). @@ -83,62 +84,79 @@ model GuestAccount { lastName String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) - messages ChatMessage[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) + messages ChatMessage[] teilnehmer Teilnehmer[] } /// A workshop election, scoped to a Kc; name carries a date key + "Teil". model Wahl { - id String @id @default(cuid()) - kcId String - name String + id String @id @default(cuid()) + kcId String + name String datumsSchluessel String - teil String - isOpen Boolean @default(true) - createdAt DateTime @default(now()) + teil String + isOpen Boolean @default(true) + createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - workshops Workshop[] - teilnehmer Teilnehmer[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + workshops Workshop[] + teilnehmer Teilnehmer[] + forceZuteilungen ForceZuteilung[] } model Workshop { - id String @id @default(cuid()) - wahlId String - name String - kapazitaet Int + id String @id @default(cuid()) + wahlId String + name String + kapazitaet Int + minTeilnehmer Int @default(0) - wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) - zuteilungen Zuteilung[] + wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) + zuteilungen Zuteilung[] + forceZuteilungen ForceZuteilung[] } /// A participant's submitted choices for a Wahl. model Teilnehmer { - id String @id @default(cuid()) - wahlId String + id String @id @default(cuid()) + wahlId String guestAccountId String - prioritaeten Json - createdAt DateTime @default(now()) + prioritaeten Json + createdAt DateTime @default(now()) - wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) - guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) - zuteilung Zuteilung? + wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) + guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) + zuteilung Zuteilung? + forceZuteilung ForceZuteilung? @@unique([wahlId, guestAccountId]) } -/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer. +/// Manual override set by LT before running the assignment algorithm; takes precedence. +model ForceZuteilung { + id String @id @default(cuid()) + wahlId String + teilnehmerId String @unique + workshopId String + + wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) + teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) + workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) +} + +/// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left). model Zuteilung { id String @id @default(cuid()) teilnehmerId String @unique - workshopId String + workshopId String? + wunschRang Int @default(-1) isForced Boolean @default(false) createdAt DateTime @default(now()) teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) - workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) + workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull) } enum FileVisibility { @@ -173,19 +191,64 @@ model ChatChannel { gemeindeId String? createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - messages ChatMessage[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + messages ChatMessage[] + participants ChatParticipant[] +} + +/// Explicit membership for DIREKT (1:1) channels; other channel types derive +/// access from Membership/Gemeinde instead of this table. +model ChatParticipant { + id String @id @default(cuid()) + channelId String + userId String + createdAt DateTime @default(now()) + + channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([channelId, userId]) } model ChatMessage { - id String @id @default(cuid()) - channelId String - senderUserId String? - senderGuestId String? - body String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + channelId String + senderUserId String? + senderGuestId String? + body String + createdAt DateTime @default(now()) channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) senderUser User? @relation(fields: [senderUserId], references: [id]) senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id]) } + +enum SyncOperation { + CREATE + UPDATE + DELETE +} + +/// Append-only log of local mutations, replicated to the peer server (local +/// <-> cloud). `originId` is the SERVER_ID that made the change, so applying +/// an incoming entry never gets re-captured/re-pushed back (no echo loops). +model SyncLogEntry { + id String @id @default(cuid()) + sequence Int @default(autoincrement()) + model String + recordId String + operation SyncOperation + payload Json + originId String + createdAt DateTime @default(now()) +} + +/// Per-peer replication progress, kept on the side that initiates sync +/// (normally the local, on-site server, since it can always dial out to the +/// cloud even when the cloud can't reach into the event's local network). +model SyncCursor { + id String @id @default(cuid()) + peerId String @unique + lastPushedSequence Int @default(0) + lastPulledSequence Int @default(0) +} diff --git a/src/app.module.ts b/src/app.module.ts index b2755e6..f916aaa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,15 +1,31 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; +import { WahlModule } from './wahl/wahl.module'; +import { FilesModule } from './files/files.module'; +import { ChatModule } from './chat/chat.module'; +import { SyncModule } from './sync/sync.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + // Serves the plain static web client from ../client/web; the REST API + // lives under /api (see main.ts) so it never collides with these routes. + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', '..', 'client', 'web'), + exclude: ['/api*'], + }), PrismaModule, + SyncModule, AuthModule, KcModule, + WahlModule, + FilesModule, + ChatModule, ], }) export class AppModule {} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index db06d1f..706b234 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,6 +5,8 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { GuestAuthService } from './guest-auth.service'; import { AuthentikStrategy } from './authentik.strategy'; +import { GuestJwtStrategy } from './guest-jwt.strategy'; +import { TokenVerificationService } from './token-verification.service'; @Module({ imports: [ @@ -18,6 +20,7 @@ import { AuthentikStrategy } from './authentik.strategy'; }), ], controllers: [AuthController], - providers: [GuestAuthService, AuthentikStrategy], + providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService], + exports: [TokenVerificationService], }) export class AuthModule {} diff --git a/src/auth/authenticated-request.ts b/src/auth/authenticated-request.ts index daec849..aa4ed20 100644 --- a/src/auth/authenticated-request.ts +++ b/src/auth/authenticated-request.ts @@ -1,5 +1,6 @@ import { Request } from 'express'; import { Role } from '../common/role.enum'; +import { GuestJwtPayload } from './guest-auth.service'; export interface AuthenticatedMembership { kcId: string; @@ -18,3 +19,8 @@ export interface AuthenticatedUser { export interface AuthenticatedRequest extends Request { user?: AuthenticatedUser; } + +/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes. +export interface GuestAuthenticatedRequest extends Request { + user?: GuestJwtPayload; +} diff --git a/src/auth/guest-auth.service.ts b/src/auth/guest-auth.service.ts index eafc7b8..fc6eed6 100644 --- a/src/auth/guest-auth.service.ts +++ b/src/auth/guest-auth.service.ts @@ -1,6 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { SyncOperation } from '@prisma/client'; import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; export interface GuestJwtPayload { guestId: string; @@ -15,6 +17,7 @@ export class GuestAuthService { constructor( private readonly prisma: PrismaClient, private readonly jwt: JwtService, + private readonly sync: SyncService, ) {} async createGuest( @@ -30,6 +33,7 @@ export class GuestAuthService { const guest = await this.prisma.guestAccount.create({ data: { kcId: kc.id, firstName, lastName }, }); + await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); const payload: GuestJwtPayload = { guestId: guest.id, diff --git a/src/auth/guest-jwt.strategy.ts b/src/auth/guest-jwt.strategy.ts new file mode 100644 index 0000000..d149605 --- /dev/null +++ b/src/auth/guest-jwt.strategy.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { GuestJwtPayload } from './guest-auth.service'; + +/// Verifies the local JWT issued to guests/Konfis by GuestAuthService. +/// Kept separate from AuthentikStrategy since guests are never Authentik-backed. +@Injectable() +export class GuestJwtStrategy extends PassportStrategy(Strategy, 'guest') { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.getOrThrow('GUEST_JWT_SECRET'), + }); + } + + validate(payload: GuestJwtPayload): GuestJwtPayload { + return payload; + } +} diff --git a/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts new file mode 100644 index 0000000..374725d --- /dev/null +++ b/src/auth/token-verification.service.ts @@ -0,0 +1,74 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as jwt from 'jsonwebtoken'; +import * as jwksRsa from 'jwks-rsa'; +import { PrismaClient } from '../prisma/prisma.module'; +import { AuthenticatedUser } from './authenticated-request'; +import { GuestJwtPayload } from './guest-auth.service'; + +/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for +/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. +@Injectable() +export class TokenVerificationService { + private readonly issuerUrl: string; + private readonly jwks: jwksRsa.JwksClient; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaClient, + private readonly guestJwt: JwtService, + ) { + this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); + } + + async verifyAuthentik(token: string): Promise { + const decoded = jwt.decode(token, { complete: true }); + const kid = decoded?.header.kid; + if (!kid) { + throw new UnauthorizedException('Malformed Authentik token'); + } + const key = await this.jwks.getSigningKey(kid); + const payload = jwt.verify(token, key.getPublicKey(), { + issuer: this.issuerUrl, + algorithms: ['RS256'], + }) as jwt.JwtPayload; + if (!payload.sub) { + throw new UnauthorizedException('Authentik token missing subject'); + } + + const user = await this.prisma.user.findUnique({ + where: { authentikSub: payload.sub }, + include: { memberships: true }, + }); + if (!user) { + throw new UnauthorizedException('User not provisioned locally yet'); + } + return { + userId: user.id, + authentikSub: user.authentikSub, + email: user.email, + memberships: user.memberships.map((m) => ({ + kcId: m.kcId, + gemeindeId: m.gemeindeId, + role: m.role, + })), + }; + } + + async verifyGuest(token: string): Promise { + return this.guestJwt.verifyAsync(token); + } + + /// Tries Authentik first (team member), then falls back to a guest token. + async verifyEither(token: string): Promise< + { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload } + > { + try { + return { kind: 'user', user: await this.verifyAuthentik(token) }; + } catch { + return { kind: 'guest', guest: await this.verifyGuest(token) }; + } + } +} diff --git a/src/chat/caller.util.ts b/src/chat/caller.util.ts new file mode 100644 index 0000000..e40a2d7 --- /dev/null +++ b/src/chat/caller.util.ts @@ -0,0 +1,13 @@ +import { AuthenticatedUser } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; +import { ChatCaller } from './chat.service'; + +function isGuestPayload(user: unknown): user is GuestJwtPayload { + return !!user && typeof user === 'object' && 'guestId' in user; +} + +/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload, +/// depending on which strategy AuthGuard(['authentik','guest']) picked. +export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller { + return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user }; +} diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts new file mode 100644 index 0000000..75370b1 --- /dev/null +++ b/src/chat/chat.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ChatService } from './chat.service'; +import { CreateChannelDto } from './dto/create-channel.dto'; +import { CreateDirectChannelDto } from './dto/create-direct-channel.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; +import { resolveChatCaller } from './caller.util'; + +type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload }; + +@Controller('chat') +export class ChatController { + constructor(private readonly chat: ChatService) {} + + /// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only. + @Post(':kcId/channels') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) { + return this.chat.createChannel(kcId, dto.type, dto.gemeindeId); + } + + /// Any two team members of the same KC can start a direct conversation. + @Post('direct') + @UseGuards(AuthGuard('authentik')) + createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) { + return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId); + } + + @Get(':kcId/channels') + @UseGuards(AuthGuard(['authentik', 'guest'])) + listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) { + return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!)); + } + + @Get('channels/:channelId/messages') + @UseGuards(AuthGuard(['authentik', 'guest'])) + listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) { + return this.chat.listMessages(channelId, resolveChatCaller(req.user!)); + } +} diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts new file mode 100644 index 0000000..27978a9 --- /dev/null +++ b/src/chat/chat.gateway.ts @@ -0,0 +1,100 @@ +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { IncomingMessage } from 'http'; +import { WebSocket } from 'ws'; +import { TokenVerificationService } from '../auth/token-verification.service'; +import { ChatCaller, ChatService } from './chat.service'; + +/// Raw `ws` gateway (no socket.io rooms available), so channel membership is +/// tracked manually per connected socket. Auth happens once at handshake via +/// a `?token=` query param since passport guards don't run for WS upgrades. +@WebSocketGateway({ path: '/chat' }) +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { + private readonly logger = new Logger(ChatGateway.name); + private readonly callers = new WeakMap(); + private readonly rooms = new Map>(); + + constructor( + private readonly tokenVerification: TokenVerificationService, + private readonly chat: ChatService, + ) {} + + async handleConnection(client: WebSocket, request: IncomingMessage) { + const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token'); + if (!token) { + client.close(4001, 'Missing token'); + return; + } + try { + this.callers.set(client, await this.tokenVerification.verifyEither(token)); + } catch (err) { + this.logger.warn(`WS auth failed: ${(err as Error).message}`); + client.close(4001, 'Unauthorized'); + } + } + + handleDisconnect(client: WebSocket) { + this.callers.delete(client); + for (const members of this.rooms.values()) { + members.delete(client); + } + } + + @SubscribeMessage('chat:join') + async onJoin( + @ConnectedSocket() client: WebSocket, + @MessageBody() data: { channelId: string }, + ) { + const caller = this.requireCaller(client); + await this.chat.assertCanRead(data.channelId, caller); + this.roomFor(data.channelId).add(client); + return { event: 'chat:joined', data: { channelId: data.channelId } }; + } + + @SubscribeMessage('chat:send') + async onSend( + @ConnectedSocket() client: WebSocket, + @MessageBody() data: { channelId: string; body: string }, + ) { + const caller = this.requireCaller(client); + const message = await this.chat.sendMessage(data.channelId, caller, data.body); + this.broadcast(data.channelId, { event: 'chat:message', data: message }); + return { event: 'chat:sent', data: { id: message.id } }; + } + + private requireCaller(client: WebSocket): ChatCaller { + const caller = this.callers.get(client); + if (!caller) { + client.close(4001, 'Unauthorized'); + throw new Error('Unauthorized WS client'); + } + return caller; + } + + private roomFor(channelId: string): Set { + let room = this.rooms.get(channelId); + if (!room) { + room = new Set(); + this.rooms.set(channelId, room); + } + return room; + } + + private broadcast(channelId: string, payload: unknown) { + const room = this.rooms.get(channelId); + if (!room) return; + const json = JSON.stringify(payload); + for (const socket of room) { + if (socket.readyState === socket.OPEN) { + socket.send(json); + } + } + } +} diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts new file mode 100644 index 0000000..745ac81 --- /dev/null +++ b/src/chat/chat.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ChatService } from './chat.service'; +import { ChatGateway } from './chat.gateway'; +import { ChatController } from './chat.controller'; + +@Module({ + imports: [AuthModule], + controllers: [ChatController], + providers: [ChatService, ChatGateway], +}) +export class ChatModule {} diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts new file mode 100644 index 0000000..df9ef4d --- /dev/null +++ b/src/chat/chat.service.ts @@ -0,0 +1,165 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ChatChannelType, Role, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { AuthenticatedUser } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; +import { SyncService } from '../sync/sync.service'; + +export type ChatCaller = + | { kind: 'user'; user: AuthenticatedUser } + | { kind: 'guest'; guest: GuestJwtPayload }; + +@Injectable() +export class ChatService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} + + async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) { + const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } }); + await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel); + return channel; + } + + async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) { + const existing = await this.prisma.chatChannel.findFirst({ + where: { + kcId, + type: ChatChannelType.DIREKT, + AND: [ + { participants: { some: { userId: userAId } } }, + { participants: { some: { userId: userBId } } }, + ], + }, + }); + if (existing) return existing; + const channel = await this.prisma.chatChannel.create({ + data: { + kcId, + type: ChatChannelType.DIREKT, + participants: { create: [{ userId: userAId }, { userId: userBId }] }, + }, + }); + await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel); + return channel; + } + + async listChannelsForCaller(kcId: string, caller: ChatCaller) { + if (caller.kind === 'guest') { + return this.prisma.chatChannel.findMany({ + where: { kcId, type: ChatChannelType.BROADCAST }, + }); + } + const { user } = caller; + const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM); + if (isLt) { + return this.prisma.chatChannel.findMany({ where: { kcId } }); + } + const gemeindeIds = user.memberships + .filter((m) => m.kcId === kcId && m.gemeindeId) + .map((m) => m.gemeindeId as string); + return this.prisma.chatChannel.findMany({ + where: { + kcId, + OR: [ + { type: ChatChannelType.BROADCAST }, + { type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } }, + { type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } }, + ], + }, + }); + } + + async assertCanRead(channelId: string, caller: ChatCaller) { + return this.getChannelForCallerOrThrow(channelId, caller, 'read'); + } + + async assertCanWrite(channelId: string, caller: ChatCaller) { + return this.getChannelForCallerOrThrow(channelId, caller, 'write'); + } + + private async getChannelForCallerOrThrow( + channelId: string, + caller: ChatCaller, + mode: 'read' | 'write', + ) { + const channel = await this.prisma.chatChannel.findUnique({ + where: { id: channelId }, + include: { participants: true }, + }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + + if (caller.kind === 'guest') { + const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read'; + if (!allowed) { + throw new ForbiddenException('Guests may only read broadcast channels'); + } + if (caller.guest.kcId !== channel.kcId) { + throw new ForbiddenException('Guest does not belong to this KC'); + } + return channel; + } + + const { user } = caller; + const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM); + if (isLt) { + return channel; + } + if (channel.kcId && !user.memberships.some((m) => m.kcId === channel.kcId)) { + throw new ForbiddenException('Not a member of this KC'); + } + + switch (channel.type) { + case ChatChannelType.BROADCAST: + if (mode === 'write') { + throw new ForbiddenException('Only Leitungsteam may post broadcasts'); + } + return channel; + case ChatChannelType.LT_UEBERGREIFEND: + throw new ForbiddenException('Leitungsteam-only channel'); + case ChatChannelType.GEMEINDE_GRUPPE: { + const inGemeinde = user.memberships.some( + (m) => m.kcId === channel.kcId && m.gemeindeId === channel.gemeindeId, + ); + if (!inGemeinde) { + throw new ForbiddenException('Not a member of this Gemeinde'); + } + return channel; + } + case ChatChannelType.DIREKT: { + const isParticipant = channel.participants.some((p) => p.userId === user.userId); + if (!isParticipant) { + throw new ForbiddenException('Not a participant of this conversation'); + } + return channel; + } + default: + throw new ForbiddenException('Unknown channel type'); + } + } + + async sendMessage(channelId: string, caller: ChatCaller, body: string) { + await this.assertCanWrite(channelId, caller); + const message = await this.prisma.chatMessage.create({ + data: { + channelId, + body, + senderUserId: caller.kind === 'user' ? caller.user.userId : null, + senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null, + }, + }); + await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message); + return message; + } + + async listMessages(channelId: string, caller: ChatCaller) { + await this.assertCanRead(channelId, caller); + return this.prisma.chatMessage.findMany({ + where: { channelId }, + orderBy: { createdAt: 'asc' }, + }); + } +} diff --git a/src/chat/dto/create-channel.dto.ts b/src/chat/dto/create-channel.dto.ts new file mode 100644 index 0000000..c7d37c5 --- /dev/null +++ b/src/chat/dto/create-channel.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ChatChannelType } from '@prisma/client'; + +export class CreateChannelDto { + @IsEnum(ChatChannelType) + type!: ChatChannelType; + + @IsOptional() + @IsString() + gemeindeId?: string; +} diff --git a/src/chat/dto/create-direct-channel.dto.ts b/src/chat/dto/create-direct-channel.dto.ts new file mode 100644 index 0000000..aede567 --- /dev/null +++ b/src/chat/dto/create-direct-channel.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateDirectChannelDto { + @IsString() + @IsNotEmpty() + kcId!: string; + + @IsString() + @IsNotEmpty() + otherUserId!: string; +} diff --git a/src/files/dto/upload-file.dto.ts b/src/files/dto/upload-file.dto.ts new file mode 100644 index 0000000..6c122e8 --- /dev/null +++ b/src/files/dto/upload-file.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { FileVisibility } from '@prisma/client'; + +export class UploadFileDto { + @IsEnum(FileVisibility) + visibility!: FileVisibility; +} diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts new file mode 100644 index 0000000..c173666 --- /dev/null +++ b/src/files/files.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + Res, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AuthGuard } from '@nestjs/passport'; +import { Response } from 'express'; +import { FilesService } from './files.service'; +import { UploadFileDto } from './dto/upload-file.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; +import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util'; + +type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload }; + +function isGuest(user: unknown): user is GuestJwtPayload { + return !!user && typeof user === 'object' && 'guestId' in user; +} + +@Controller('files') +export class FilesController { + constructor(private readonly files: FilesService) {} + + /// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier. + @Post(':kcId') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + @UseInterceptors(FileInterceptor('file')) + upload( + @Param('kcId') kcId: string, + @Body() dto: UploadFileDto, + @UploadedFile() file: Express.Multer.File, + @Req() req: AuthenticatedRequest, + ) { + return this.files.upload(kcId, dto.visibility, file.originalname, file.buffer, req.user!.userId); + } + + @Get(':kcId') + @UseGuards(AuthGuard(['authentik', 'guest'])) + list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) { + const allowed = isGuest(req.user) + ? GUEST_ALLOWED_VISIBILITIES + : allowedVisibilitiesForUser(req.user!, kcId); + return this.files.listForCaller(kcId, allowed); + } + + @Get('download/:fileId') + @UseGuards(AuthGuard(['authentik', 'guest'])) + async download( + @Param('fileId') fileId: string, + @Req() req: FileCallerRequest, + @Res() res: Response, + ) { + const meta = await this.files.getFileOrThrow(fileId); + const allowed = isGuest(req.user) + ? GUEST_ALLOWED_VISIBILITIES + : allowedVisibilitiesForUser(req.user!, meta.kcId); + const { file, data } = await this.files.downloadForCaller(fileId, allowed); + res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`); + res.send(data); + } +} diff --git a/src/files/files.module.ts b/src/files/files.module.ts new file mode 100644 index 0000000..8cf4b02 --- /dev/null +++ b/src/files/files.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FilesService } from './files.service'; +import { FilesController } from './files.controller'; +import { STORAGE_PROVIDER } from './storage/storage-provider'; +import { WebDavStorageProvider } from './storage/webdav-storage.provider'; +import { S3StorageProvider } from './storage/s3-storage.provider'; + +@Module({ + controllers: [FilesController], + providers: [ + FilesService, + { + // Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage. + provide: STORAGE_PROVIDER, + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get('STORAGE_PROVIDER') === 's3' + ? new S3StorageProvider(config) + : new WebDavStorageProvider(config), + }, + ], +}) +export class FilesModule {} diff --git a/src/files/files.service.ts b/src/files/files.service.ts new file mode 100644 index 0000000..74dd265 --- /dev/null +++ b/src/files/files.service.ts @@ -0,0 +1,53 @@ +import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { FileVisibility, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider'; +import { SyncService } from '../sync/sync.service'; + +@Injectable() +export class FilesService { + constructor( + private readonly prisma: PrismaClient, + @Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider, + private readonly sync: SyncService, + ) {} + + async upload( + kcId: string, + visibility: FileVisibility, + filename: string, + data: Buffer, + uploadedById: string, + ) { + const storageKey = await this.storage.upload(kcId, filename, data); + const file = await this.prisma.file.create({ + data: { kcId, storageKey, filename, visibility, uploadedById }, + }); + // Note: only metadata is replicated here; storageKey only resolves if + // local and cloud share the same Nextcloud/S3 backend (see sync docs). + await this.sync.capture('File', SyncOperation.CREATE, file.id, file); + return file; + } + + listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) { + return this.prisma.file.findMany({ + where: { kcId, visibility: { in: allowedVisibilities } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) { + const file = await this.getFileOrThrow(fileId); + if (!allowedVisibilities.includes(file.visibility)) { + throw new ForbiddenException('Not permitted to access this file'); + } + const data = await this.storage.download(file.storageKey); + return { file, data }; + } + + getFileOrThrow(fileId: string) { + return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => { + throw new NotFoundException('File not found'); + }); + } +} diff --git a/src/files/storage/s3-storage.provider.ts b/src/files/storage/s3-storage.provider.ts new file mode 100644 index 0000000..4b40687 --- /dev/null +++ b/src/files/storage/s3-storage.provider.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { StorageProvider } from './storage-provider'; + +/// S3-compatible object storage (AWS S3, MinIO, etc.). +@Injectable() +export class S3StorageProvider implements StorageProvider { + private readonly client: S3Client; + private readonly bucket: string; + + constructor(config: ConfigService) { + this.bucket = config.getOrThrow('S3_BUCKET'); + this.client = new S3Client({ + region: config.get('S3_REGION') ?? 'auto', + endpoint: config.get('S3_ENDPOINT'), + forcePathStyle: config.get('S3_FORCE_PATH_STYLE') === 'true', + credentials: { + accessKeyId: config.getOrThrow('S3_ACCESS_KEY_ID'), + secretAccessKey: config.getOrThrow('S3_SECRET_ACCESS_KEY'), + }, + }); + } + + async upload(kcId: string, filename: string, data: Buffer): Promise { + const storageKey = `${kcId}/${randomUUID()}-${filename}`; + await this.client.send( + new PutObjectCommand({ Bucket: this.bucket, Key: storageKey, Body: data }), + ); + return storageKey; + } + + async download(storageKey: string): Promise { + const result = await this.client.send( + new GetObjectCommand({ Bucket: this.bucket, Key: storageKey }), + ); + const chunks: Uint8Array[] = []; + for await (const chunk of result.Body as AsyncIterable) { + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + + async delete(storageKey: string): Promise { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey })); + } +} diff --git a/src/files/storage/storage-provider.ts b/src/files/storage/storage-provider.ts new file mode 100644 index 0000000..b05ac5c --- /dev/null +++ b/src/files/storage/storage-provider.ts @@ -0,0 +1,10 @@ +/// Abstraction over the external file storage backend (Nextcloud via WebDAV, +/// or S3-compatible object storage). Implementations only need to move raw +/// bytes; visibility/ownership metadata lives in the `File` Prisma model. +export interface StorageProvider { + upload(kcId: string, filename: string, data: Buffer): Promise; + download(storageKey: string): Promise; + delete(storageKey: string): Promise; +} + +export const STORAGE_PROVIDER = Symbol('STORAGE_PROVIDER'); diff --git a/src/files/storage/webdav-storage.provider.ts b/src/files/storage/webdav-storage.provider.ts new file mode 100644 index 0000000..f1210b3 --- /dev/null +++ b/src/files/storage/webdav-storage.provider.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import { createClient, WebDAVClient } from 'webdav'; +import { StorageProvider } from './storage-provider'; + +/// Nextcloud (or any WebDAV server) as file storage backend. +@Injectable() +export class WebDavStorageProvider implements StorageProvider { + private readonly client: WebDAVClient; + + constructor(config: ConfigService) { + this.client = createClient(config.getOrThrow('WEBDAV_URL'), { + username: config.getOrThrow('WEBDAV_USERNAME'), + password: config.getOrThrow('WEBDAV_PASSWORD'), + }); + } + + async upload(kcId: string, filename: string, data: Buffer): Promise { + const dir = `/${kcId}`; + if (!(await this.client.exists(dir))) { + await this.client.createDirectory(dir, { recursive: true }); + } + const storageKey = `${dir}/${randomUUID()}-${filename}`; + await this.client.putFileContents(storageKey, data, { overwrite: false }); + return storageKey; + } + + async download(storageKey: string): Promise { + const content = await this.client.getFileContents(storageKey); + return Buffer.isBuffer(content) ? content : Buffer.from(content as ArrayBuffer); + } + + async delete(storageKey: string): Promise { + await this.client.deleteFile(storageKey); + } +} diff --git a/src/files/visibility.util.ts b/src/files/visibility.util.ts new file mode 100644 index 0000000..5b099b2 --- /dev/null +++ b/src/files/visibility.util.ts @@ -0,0 +1,21 @@ +import { FileVisibility, Role } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/authenticated-request'; + +/// Maps the caller's role for a given KC to the file visibility tiers they may see. +/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything. +export function allowedVisibilitiesForUser( + user: AuthenticatedUser, + kcId: string, +): FileVisibility[] { + const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM); + if (isLt) { + return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT]; + } + const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId); + if (isTeamMemberForKc) { + return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS]; + } + return []; +} + +export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE]; diff --git a/src/kc/kc.service.ts b/src/kc/kc.service.ts index 366f3b3..a411c99 100644 --- a/src/kc/kc.service.ts +++ b/src/kc/kc.service.ts @@ -1,15 +1,22 @@ import { Injectable } from '@nestjs/common'; import { randomBytes } from 'crypto'; +import { SyncOperation } from '@prisma/client'; import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; @Injectable() export class KcService { - constructor(private readonly prisma: PrismaClient) {} + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} - createKc(name: string) { - return this.prisma.kc.create({ + async createKc(name: string) { + const kc = await this.prisma.kc.create({ data: { name, inviteCode: randomBytes(6).toString('hex') }, }); + await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc); + return kc; } listKcs() { diff --git a/src/main.ts b/src/main.ts index 1ebda52..15e4af3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,14 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { WsAdapter } from '@nestjs/platform-ws'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.enableCors(); + app.useWebSocketAdapter(new WsAdapter(app)); await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/src/sync/dto/ingest-entries.dto.ts b/src/sync/dto/ingest-entries.dto.ts new file mode 100644 index 0000000..54cc55a --- /dev/null +++ b/src/sync/dto/ingest-entries.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsNotEmpty } from 'class-validator'; + +export class IngestEntriesDto { + @IsArray() + @IsNotEmpty() + entries!: unknown[]; +} diff --git a/src/sync/sync-scheduler.service.ts b/src/sync/sync-scheduler.service.ts new file mode 100644 index 0000000..ea56c4f --- /dev/null +++ b/src/sync/sync-scheduler.service.ts @@ -0,0 +1,32 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Interval } from '@nestjs/schedule'; +import { SyncService } from './sync.service'; + +/// Periodically pushes/pulls against the configured peer when enabled. Safe +/// to fail silently (e.g. no internet at an on-site event) - just retries +/// on the next tick. +@Injectable() +export class SyncSchedulerService { + private readonly logger = new Logger(SyncSchedulerService.name); + + constructor( + private readonly sync: SyncService, + private readonly config: ConfigService, + ) {} + + @Interval(30_000) + async tick() { + if (this.config.get('SYNC_ENABLED') !== 'true') return; + const peerUrl = this.config.get('SYNC_PEER_URL'); + const peerSecret = this.config.get('SYNC_SHARED_SECRET'); + if (!peerUrl || !peerSecret) return; + + try { + await this.sync.pushToPeer(peerUrl, peerSecret); + await this.sync.pullFromPeer(peerUrl, peerSecret); + } catch (err) { + this.logger.debug(`Sync with peer skipped: ${(err as Error).message}`); + } + } +} diff --git a/src/sync/sync-secret.guard.ts b/src/sync/sync-secret.guard.ts new file mode 100644 index 0000000..3db94ce --- /dev/null +++ b/src/sync/sync-secret.guard.ts @@ -0,0 +1,18 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +/// Server-to-server auth for /sync/*: a shared secret header, not a user token. +@Injectable() +export class SyncSecretGuard implements CanActivate { + constructor(private readonly config: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const expected = this.config.getOrThrow('SYNC_SHARED_SECRET'); + if (request.headers['x-sync-secret'] !== expected) { + throw new ForbiddenException('Invalid sync secret'); + } + return true; + } +} diff --git a/src/sync/sync.controller.ts b/src/sync/sync.controller.ts new file mode 100644 index 0000000..4a06dfc --- /dev/null +++ b/src/sync/sync.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { SyncService } from './sync.service'; +import { SyncSecretGuard } from './sync-secret.guard'; +import { IngestEntriesDto } from './dto/ingest-entries.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; + +@Controller('sync') +export class SyncController { + constructor( + private readonly sync: SyncService, + private readonly config: ConfigService, + ) {} + + /// Peer pushes its new entries to us. + @Post('ingest') + @UseGuards(SyncSecretGuard) + async ingest(@Body() dto: IngestEntriesDto) { + await this.sync.applyIncoming(dto.entries as never); + return { applied: dto.entries.length }; + } + + /// Peer pulls our new entries since their last known sequence. + @Get('export') + @UseGuards(SyncSecretGuard) + async export(@Query('since') since: string) { + const entries = await this.sync.getEntriesSince(Number(since) || 0); + return { entries }; + } + + /// Manual on-demand push+pull against the configured peer (Leitungsteam-only). + @Post('trigger') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + async trigger() { + const peerUrl = this.config.getOrThrow('SYNC_PEER_URL'); + const peerSecret = this.config.getOrThrow('SYNC_SHARED_SECRET'); + const pushed = await this.sync.pushToPeer(peerUrl, peerSecret); + const pulled = await this.sync.pullFromPeer(peerUrl, peerSecret); + return { ...pushed, ...pulled }; + } +} diff --git a/src/sync/sync.module.ts b/src/sync/sync.module.ts new file mode 100644 index 0000000..5e47e6a --- /dev/null +++ b/src/sync/sync.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { SyncService } from './sync.service'; +import { SyncController } from './sync.controller'; +import { SyncSchedulerService } from './sync-scheduler.service'; + +/// Global so every feature module can inject SyncService to capture its +/// mutations without each one importing SyncModule explicitly. +@Global() +@Module({ + imports: [ScheduleModule.forRoot()], + controllers: [SyncController], + providers: [SyncService, SyncSchedulerService], + exports: [SyncService], +}) +export class SyncModule {} diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts new file mode 100644 index 0000000..094aab4 --- /dev/null +++ b/src/sync/sync.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; + +const SYNCED_MODELS = [ + 'Kc', + 'Gemeinde', + 'GuestAccount', + 'Wahl', + 'Workshop', + 'Teilnehmer', + 'ForceZuteilung', + 'Zuteilung', + 'File', + 'ChatChannel', + 'ChatMessage', +] as const; +export type SyncedModel = (typeof SYNCED_MODELS)[number]; + +interface IncomingEntry { + sequence: number; + model: string; + recordId: string; + operation: SyncOperation; + payload: Record; + originId: string; +} + +/// Replicates mutations between the local (on-site) and cloud server. The +/// local server is the sole source of truth while an event is live, so +/// incoming entries are applied with simple upserts - no conflict resolution +/// is needed by design (see plan doc). +@Injectable() +export class SyncService { + private readonly logger = new Logger(SyncService.name); + readonly serverId: string; + + constructor( + private readonly prisma: PrismaClient, + private readonly config: ConfigService, + ) { + this.serverId = config.getOrThrow('SERVER_ID'); + } + + /// Called by feature services right after a mutation to append it to the replication log. + async capture(model: SyncedModel, operation: SyncOperation, recordId: string, payload: object) { + await this.prisma.syncLogEntry.create({ + data: { + model, + recordId, + operation, + payload: payload as never, + originId: this.serverId, + }, + }); + } + + async getEntriesSince(sequence: number, limit = 500) { + return this.prisma.syncLogEntry.findMany({ + where: { sequence: { gt: sequence } }, + orderBy: { sequence: 'asc' }, + take: limit, + }); + } + + /// Applies entries received from a peer; never re-captures them, which is + /// what prevents echo loops between the two servers. + async applyIncoming(entries: IncomingEntry[]) { + for (const entry of entries) { + if (entry.originId === this.serverId) continue; + const delegate = this.delegateFor(entry.model); + if (!delegate) { + this.logger.warn(`Skipping sync entry for unknown model "${entry.model}"`); + continue; + } + try { + if (entry.operation === SyncOperation.DELETE) { + await delegate.delete({ where: { id: entry.recordId } }); + } else { + await delegate.upsert({ + where: { id: entry.recordId }, + create: entry.payload, + update: entry.payload, + }); + } + } catch (err) { + this.logger.warn( + `Failed to apply sync entry ${entry.model}/${entry.recordId}: ${(err as Error).message}`, + ); + } + } + } + + async pushToPeer(peerUrl: string, peerSecret: string) { + const peerId = new URL(peerUrl).host; + const cursor = await this.getOrCreateCursor(peerId); + const entries = await this.getEntriesSince(cursor.lastPushedSequence); + if (entries.length === 0) return { pushed: 0 }; + + const res = await fetch(`${peerUrl}/sync/ingest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-sync-secret': peerSecret }, + body: JSON.stringify({ entries }), + }); + if (!res.ok) { + throw new Error(`Peer rejected sync push: ${res.status}`); + } + await this.prisma.syncCursor.update({ + where: { peerId }, + data: { lastPushedSequence: entries[entries.length - 1].sequence }, + }); + return { pushed: entries.length }; + } + + async pullFromPeer(peerUrl: string, peerSecret: string) { + const peerId = new URL(peerUrl).host; + const cursor = await this.getOrCreateCursor(peerId); + const res = await fetch(`${peerUrl}/sync/export?since=${cursor.lastPulledSequence}`, { + headers: { 'x-sync-secret': peerSecret }, + }); + if (!res.ok) { + throw new Error(`Peer rejected sync pull: ${res.status}`); + } + const { entries } = (await res.json()) as { entries: IncomingEntry[] }; + if (entries.length === 0) return { pulled: 0 }; + + await this.applyIncoming(entries); + await this.prisma.syncCursor.update({ + where: { peerId }, + data: { lastPulledSequence: entries[entries.length - 1].sequence }, + }); + return { pulled: entries.length }; + } + + private async getOrCreateCursor(peerId: string) { + return this.prisma.syncCursor.upsert({ + where: { peerId }, + create: { peerId }, + update: {}, + }); + } + + private delegateFor(model: string) { + if (!SYNCED_MODELS.includes(model as SyncedModel)) return null; + const key = (model.charAt(0).toLowerCase() + model.slice(1)) as keyof PrismaClient; + // Generic dispatch across models is inherent to a replication log; each + // delegate exposes the same upsert/delete shape we need here. + return this.prisma[key] as unknown as { + upsert: (args: { where: { id: string }; create: object; update: object }) => Promise; + delete: (args: { where: { id: string } }) => Promise; + }; + } +} diff --git a/src/wahl/dto/create-force-zuteilung.dto.ts b/src/wahl/dto/create-force-zuteilung.dto.ts new file mode 100644 index 0000000..e8eefde --- /dev/null +++ b/src/wahl/dto/create-force-zuteilung.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateForceZuteilungDto { + @IsString() + @IsNotEmpty() + teilnehmerId!: string; + + @IsString() + @IsNotEmpty() + workshopId!: string; +} diff --git a/src/wahl/dto/create-wahl.dto.ts b/src/wahl/dto/create-wahl.dto.ts new file mode 100644 index 0000000..ec995bf --- /dev/null +++ b/src/wahl/dto/create-wahl.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateWahlDto { + @IsString() + @IsNotEmpty() + kcId!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsNotEmpty() + datumsSchluessel!: string; + + @IsString() + @IsNotEmpty() + teil!: string; +} diff --git a/src/wahl/dto/create-workshop.dto.ts b/src/wahl/dto/create-workshop.dto.ts new file mode 100644 index 0000000..a3a4362 --- /dev/null +++ b/src/wahl/dto/create-workshop.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; + +export class CreateWorkshopDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsInt() + @Min(1) + kapazitaet!: number; + + @IsInt() + @Min(0) + minTeilnehmer: number = 0; +} diff --git a/src/wahl/dto/submit-teilnehmer.dto.ts b/src/wahl/dto/submit-teilnehmer.dto.ts new file mode 100644 index 0000000..b825067 --- /dev/null +++ b/src/wahl/dto/submit-teilnehmer.dto.ts @@ -0,0 +1,11 @@ +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString } from 'class-validator'; + +/// Ordered workshop-id preferences, most preferred first (up to 3, matching +/// the original plugin's wunsch1..wunsch3). +export class SubmitTeilnehmerDto { + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(3) + @IsString({ each: true }) + prioritaeten!: string[]; +} diff --git a/src/wahl/wahl.controller.ts b/src/wahl/wahl.controller.ts new file mode 100644 index 0000000..f222678 --- /dev/null +++ b/src/wahl/wahl.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Response } from 'express'; +import { WahlService } from './wahl.service'; +import { ZuteilungService } from './zuteilung.service'; +import { CreateWahlDto } from './dto/create-wahl.dto'; +import { CreateWorkshopDto } from './dto/create-workshop.dto'; +import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto'; +import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; +import { GuestAuthenticatedRequest } from '../auth/authenticated-request'; + +@Controller('wahl') +export class WahlController { + constructor( + private readonly wahl: WahlService, + private readonly zuteilung: ZuteilungService, + ) {} + + /// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des + /// Leitungsteams (global über alle KCs, siehe RolesGuard). + @Post() + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + createWahl(@Body() dto: CreateWahlDto) { + return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil); + } + + @Get() + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listWahlen(@Query('kcId') kcId: string) { + return this.wahl.listWahlen(kcId); + } + + @Post(':wahlId/workshops') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) { + return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer); + } + + @Get(':wahlId/workshops') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listWorkshops(@Param('wahlId') wahlId: string) { + return this.wahl.listWorkshops(wahlId); + } + + @Post(':wahlId/force-zuteilung') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + createForceZuteilung( + @Param('wahlId') wahlId: string, + @Body() dto: CreateForceZuteilungDto, + ) { + return this.wahl.createForceZuteilung(wahlId, dto.teilnehmerId, dto.workshopId); + } + + /// Guests submit their own workshop preferences (guest JWT, not Authentik). + @Post(':wahlId/teilnehmer') + @UseGuards(AuthGuard('guest')) + submitTeilnehmer( + @Param('wahlId') wahlId: string, + @Body() dto: SubmitTeilnehmerDto, + @Req() req: GuestAuthenticatedRequest, + ) { + const guest = req.user!; + return this.wahl.submitTeilnehmer(wahlId, guest.guestId, guest.kcId, dto.prioritaeten); + } + + @Post(':wahlId/zuteilung/run') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + runZuteilung(@Param('wahlId') wahlId: string) { + return this.zuteilung.run(wahlId); + } + + @Get(':wahlId/zuteilung') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + getZuteilung(@Param('wahlId') wahlId: string) { + return this.zuteilung.getResults(wahlId); + } + + @Get(':wahlId/zuteilung/csv') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) { + const csv = await this.zuteilung.exportCsv(wahlId); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="zuteilung-${wahlId}.csv"`); + res.send(csv); + } +} diff --git a/src/wahl/wahl.module.ts b/src/wahl/wahl.module.ts new file mode 100644 index 0000000..8d655ca --- /dev/null +++ b/src/wahl/wahl.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { WahlService } from './wahl.service'; +import { ZuteilungService } from './zuteilung.service'; +import { WahlController } from './wahl.controller'; + +@Module({ + providers: [WahlService, ZuteilungService], + controllers: [WahlController], +}) +export class WahlModule {} diff --git a/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts new file mode 100644 index 0000000..290c698 --- /dev/null +++ b/src/wahl/wahl.service.ts @@ -0,0 +1,93 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +@Injectable() +export class WahlService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} + + async createWahl(kcId: string, name: string, datumsSchluessel: string, teil: string) { + const wahl = await this.prisma.wahl.create({ + data: { kcId, name, datumsSchluessel, teil }, + }); + await this.sync.capture('Wahl', SyncOperation.CREATE, wahl.id, wahl); + return wahl; + } + + listWahlen(kcId: string) { + return this.prisma.wahl.findMany({ where: { kcId } }); + } + + async createWorkshop( + wahlId: string, + name: string, + kapazitaet: number, + minTeilnehmer: number, + ) { + await this.getWahlOrThrow(wahlId); + const workshop = await this.prisma.workshop.create({ + data: { wahlId, name, kapazitaet, minTeilnehmer }, + }); + await this.sync.capture('Workshop', SyncOperation.CREATE, workshop.id, workshop); + return workshop; + } + + listWorkshops(wahlId: string) { + return this.prisma.workshop.findMany({ where: { wahlId } }); + } + + async createForceZuteilung(wahlId: string, teilnehmerId: string, workshopId: string) { + const [teilnehmer, workshop] = await Promise.all([ + this.prisma.teilnehmer.findUnique({ where: { id: teilnehmerId } }), + this.prisma.workshop.findUnique({ where: { id: workshopId } }), + ]); + if (!teilnehmer || teilnehmer.wahlId !== wahlId) { + throw new NotFoundException('Teilnehmer not found in this Wahl'); + } + if (!workshop || workshop.wahlId !== wahlId) { + throw new NotFoundException('Workshop not found in this Wahl'); + } + const force = await this.prisma.forceZuteilung.upsert({ + where: { teilnehmerId }, + create: { wahlId, teilnehmerId, workshopId }, + update: { workshopId }, + }); + await this.sync.capture('ForceZuteilung', SyncOperation.UPDATE, force.id, force); + return force; + } + + /// Guests submit their own choices; only allowed for their own KC and while the Wahl is open. + async submitTeilnehmer( + wahlId: string, + guestAccountId: string, + guestKcId: string, + prioritaeten: string[], + ) { + const wahl = await this.getWahlOrThrow(wahlId); + if (wahl.kcId !== guestKcId) { + throw new ForbiddenException('Guest does not belong to this KC'); + } + if (!wahl.isOpen) { + throw new ForbiddenException('Wahl is closed'); + } + const teilnehmer = await this.prisma.teilnehmer.upsert({ + where: { wahlId_guestAccountId: { wahlId, guestAccountId } }, + create: { wahlId, guestAccountId, prioritaeten }, + update: { prioritaeten }, + }); + await this.sync.capture('Teilnehmer', SyncOperation.UPDATE, teilnehmer.id, teilnehmer); + return teilnehmer; + } + + async getWahlOrThrow(wahlId: string) { + const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } }); + if (!wahl) { + throw new NotFoundException('Wahl not found'); + } + return wahl; + } +} diff --git a/src/wahl/zuteilung.service.ts b/src/wahl/zuteilung.service.ts new file mode 100644 index 0000000..b1e4baa --- /dev/null +++ b/src/wahl/zuteilung.service.ts @@ -0,0 +1,212 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { SyncOperation, Teilnehmer } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +type TeilnehmerRow = Teilnehmer; + +interface ZuteilungResult { + workshopId: string | null; + wunschRang: number; + isForced: boolean; +} + +/// Port of the WP plugin's kc_run_zuteilung: force-assignments first, then up +/// to 3 wish rounds, then random fill of the rest, then a consolidation pass +/// that dissolves workshops which stayed below their minTeilnehmer. +@Injectable() +export class ZuteilungService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} + + async run(wahlId: string) { + const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } }); + if (!wahl) { + throw new NotFoundException('Wahl not found'); + } + + const [workshops, teilnehmerList, forces] = await Promise.all([ + this.prisma.workshop.findMany({ where: { wahlId } }), + this.prisma.teilnehmer.findMany({ where: { wahlId } }), + this.prisma.forceZuteilung.findMany({ where: { wahlId } }), + ]); + + await this.prisma.zuteilung.deleteMany({ + where: { teilnehmer: { wahlId } }, + }); + + const caps = new Map(workshops.map((w) => [w.id, w.kapazitaet])); + const results = new Map(); + + const tryAssign = ( + teilnehmerId: string, + workshopId: string, + wunschRang: number, + isForced: boolean, + ): boolean => { + const cap = caps.get(workshopId) ?? 0; + if (cap <= 0) return false; + caps.set(workshopId, cap - 1); + results.set(teilnehmerId, { workshopId, wunschRang, isForced }); + return true; + }; + + // 1) Force-Zuteilungen haben Vorrang + for (const force of forces) { + const teilnehmer = teilnehmerList.find((t) => t.id === force.teilnehmerId); + if (!teilnehmer || results.has(teilnehmer.id)) continue; + tryAssign(teilnehmer.id, force.workshopId, 0, true); + } + + // 2) Verbleibende Teilnehmer mischen + let remaining = shuffle(teilnehmerList.filter((t) => !results.has(t.id))); + + // 3) Wunschrunden 1..3 + for (let wunschRang = 1; wunschRang <= 3; wunschRang++) { + const notAssigned: TeilnehmerRow[] = []; + for (const teilnehmer of remaining) { + const wunsch = readPrioritaeten(teilnehmer.prioritaeten)[wunschRang - 1]; + if (!wunsch || !tryAssign(teilnehmer.id, wunsch, wunschRang, false)) { + notAssigned.push(teilnehmer); + } + } + remaining = shuffle(notAssigned); + } + + // 4) Rest zufällig auf freie Workshops verteilen, sonst unzugeteilt + for (const teilnehmer of remaining) { + const freeWorkshopId = pickRandomFreeWorkshop(caps); + if (freeWorkshopId) { + tryAssign(teilnehmer.id, freeWorkshopId, 99, false); + } else { + results.set(teilnehmer.id, { workshopId: null, wunschRang: -1, isForced: false }); + } + } + + // 5) Konsolidierung: Workshops unter minTeilnehmer auflösen und neu verteilen + consolidateUnderfilledWorkshops(workshops, teilnehmerList, results, caps); + + await this.prisma.zuteilung.createMany({ + data: Array.from(results.entries()).map(([teilnehmerId, r]) => ({ + teilnehmerId, + workshopId: r.workshopId, + wunschRang: r.wunschRang, + isForced: r.isForced, + })), + }); + + const created = await this.prisma.zuteilung.findMany({ where: { teilnehmer: { wahlId } } }); + for (const row of created) { + await this.sync.capture('Zuteilung', SyncOperation.CREATE, row.id, row); + } + + return this.getResults(wahlId); + } + + async getResults(wahlId: string) { + return this.prisma.zuteilung.findMany({ + where: { teilnehmer: { wahlId } }, + include: { + teilnehmer: { include: { guestAccount: true } }, + workshop: true, + }, + }); + } + + async exportCsv(wahlId: string): Promise { + const rows = await this.getResults(wahlId); + const header = 'Vorname;Nachname;Workshop;WunschRang;Erzwungen'; + const lines = rows.map((r) => { + const vorname = r.teilnehmer.guestAccount.firstName; + const nachname = r.teilnehmer.guestAccount.lastName; + const workshop = r.workshop?.name ?? 'UNZUGETEILT'; + return `${vorname};${nachname};${workshop};${r.wunschRang};${r.isForced ? 'ja' : 'nein'}`; + }); + return [header, ...lines].join('\n'); + } +} + +/// Dissolves workshops that got some participants but stayed below their +/// minTeilnehmer, freeing their capacity and reassigning displaced +/// participants (preferring their remaining wishes, then any free workshop). +function consolidateUnderfilledWorkshops( + workshops: { id: string; minTeilnehmer: number }[], + teilnehmerList: TeilnehmerRow[], + results: Map, + caps: Map, +) { + const countByWorkshop = new Map(); + for (const r of results.values()) { + if (r.workshopId) { + countByWorkshop.set(r.workshopId, (countByWorkshop.get(r.workshopId) ?? 0) + 1); + } + } + + const failing = workshops.filter((w) => { + const count = countByWorkshop.get(w.id) ?? 0; + return count > 0 && w.minTeilnehmer > 0 && count < w.minTeilnehmer; + }); + if (failing.length === 0) return; + + const failingIds = new Set(failing.map((w) => w.id)); + const toReassign: string[] = []; + for (const [teilnehmerId, r] of results.entries()) { + if (r.workshopId && failingIds.has(r.workshopId)) { + caps.set(r.workshopId, (caps.get(r.workshopId) ?? 0) + 1); + toReassign.push(teilnehmerId); + results.delete(teilnehmerId); + } + } + + const assign = (teilnehmerId: string, workshopId: string, wunschRang: number): boolean => { + const cap = caps.get(workshopId) ?? 0; + if (cap <= 0) return false; + caps.set(workshopId, cap - 1); + results.set(teilnehmerId, { workshopId, wunschRang, isForced: false }); + return true; + }; + + for (const teilnehmerId of toReassign) { + const teilnehmer = teilnehmerList.find((t) => t.id === teilnehmerId); + const wuensche = teilnehmer ? readPrioritaeten(teilnehmer.prioritaeten) : []; + let reassigned = false; + for (let wunschRang = 1; wunschRang <= wuensche.length; wunschRang++) { + const choice = wuensche[wunschRang - 1]; + if (choice && !failingIds.has(choice) && assign(teilnehmerId, choice, wunschRang)) { + reassigned = true; + break; + } + } + if (!reassigned) { + const freeWorkshopId = pickRandomFreeWorkshop(caps, failingIds); + if (freeWorkshopId) { + assign(teilnehmerId, freeWorkshopId, 99); + } else { + results.set(teilnehmerId, { workshopId: null, wunschRang: -1, isForced: false }); + } + } + } +} + +function readPrioritaeten(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []; +} + +function shuffle(items: T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [copy[i], copy[j]] = [copy[j], copy[i]]; + } + return copy; +} + +function pickRandomFreeWorkshop(caps: Map, exclude?: Set): string | null { + const free = [...caps.entries()].filter( + ([id, cap]) => cap > 0 && !(exclude && exclude.has(id)), + ); + if (free.length === 0) return null; + return free[Math.floor(Math.random() * free.length)][0]; +}