Compare commits

...
17 Commits
Author SHA1 Message Date
Nick Szaunig adfbc2ab83 fix: Access-Token wird bei deaktivierten Usern sofort ungueltig
Bisher griff der isActive-Check nur auf Routen mit @RequirePermission.
Ein deaktivierter User mit gueltigem Token konnte Routen ohne
Permission-Decorator bis zum Token-Ablauf (bis zu 15 Min) weiter
erreichen. JwtAuthGuard prueft jetzt PermissionResolverService.isUserActive
auf jedem authentifizierten Request, ueber denselben 30s-Cache wie die
Permission-Aufloesung, also ohne zusaetzliche DB-Last.
2026-08-26 14:17:49 +02:00
Nick SzaunigandClaude Sonnet 5 5a1015c4a0 fix: kritische Rechte-Luecke, Admin-Lockout-Schutz und Wetter-Haertung
Dispatchte security-reviewer und code-reviewer Agenten fuer eine
vollstaendige Pruefung des RBAC-Systems (d31aa79..HEAD). Ein
KRITISCHER Fund, zwei HIGH, ein weiteres MEDIUM, alle behoben:

KRITISCH: Deaktivieren eines Benutzers hat dessen Berechtigungen nicht
entzogen. PermissionResolverService.loadPermissions() prüfte nur den
Rollen-Status, nie User.isActive - ein bereits ausgestelltes Access-
Token behielt volle Rechte bis zum Ablauf (bis zu 15 Minuten), nicht
die vorgesehenen 30 Sekunden. UsersService rief zudem nie
invalidateUser() auf. Beides jetzt behoben; neue dedizierte
permission-resolver.service.spec.ts deckt das ab (existierte bisher
gar nicht - ein Grund, warum die Luecke unbemerkt blieb).

HIGH: Kein Schutz gegen den letzten aktiven Systemrollen-Nutzer
(ADMIN). Man konnte den letzten Admin deaktivieren oder umrollen und
sich damit komplett aussperren - ohne Selbsthilfe-Weg, da
prisma/seed.ts das Seeden ueberspringt, sobald die Admin-E-Mail
bereits existiert. UsersService.deactivate()/.update() pruefen jetzt
per assertNotLastSystemRoleUser(), analog zum bestehenden
Lockout-Schutz in RolesService fuer Systemrollen selbst.

HIGH: Der Open-Meteo-Aufruf in WeatherService hatte kein Timeout und
konnte unbegrenzt haengen. Jetzt mit AbortSignal.timeout(8000) plus
sauberem Fehler-Mapping.

MEDIUM: Trachtprognose fiel bei fehlenden Wetterdaten stillschweigend
auf "MAESSIG" zurueck statt einen eigenen Unbekannt-Zustand zu zeigen
- ein Imker haette eine echte Einschaetzung gesehen, wo keine moeglich
war. Neuer Rating-Wert UNBEKANNT in Backend und Frontend.

LOW: Veralteter Kommentar an PermissionsGuard, der noch den in Phase 3
geloeschten RolesGuard erwaehnte, korrigiert.

Verifiziert: tsc, lint, build auf beiden Projekten gruen. Backend-Unit
245/245 (17 neue Tests fuer die Fixes). Frontend-Unit 14/14. Volle
Backend-e2e-Suite konnte weiterhin nicht laufen (WSL/Postgres von
Windows aus nicht erreichbar, auch nach vollem "wsl --shutdown"-
Neustart) - jeder Fix hat aber gezielte, gruene Unit-Abdeckung.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 14:03:32 +02:00
Nick SzaunigandClaude Sonnet 5 f1d6b2956e fix: Volk-QR-Code fuehrt zur Volk-Uebersicht statt direkt zum Durchsicht-Formular
Scannen des QR-Codes eines Volks landete bisher direkt auf einem
leeren "Neue Durchsicht"-Formular ohne jeglichen Kontext (kein
Status, keine Historie, kein Menue) - erst nach dem Speichern kam man
auf /colonies/[id], die eigentliche Uebersichtsseite mit Status,
Aktionen, Durchsicht-/Varroa-/Schwarmkontroll-/Behandlungs-/
Fuetterungshistorie.

QR fuehrt jetzt direkt zu /colonies/[id]. Der "Neue Durchsicht"-
Button ist dort weiterhin prominent vorhanden. Lastenheft Abschnitt
4.23 erlaubt explizit beide Varianten ("Volkseite beziehungsweise
eine neue Durchsicht"), diese Aenderung bleibt also spezifikations-
konform.

Verifiziert: tsc, lint, unit (228/228, inkl. der angepassten
qr.service.spec.ts-Assertion) gruen. Volle e2e-Suite konnte wegen der
wiederkehrenden WSL/Postgres-Portweiterleitungs-Unzuverlaessigkeit
nicht neu durchlaufen werden - die geaenderte Logik (ein einzelner
String) ist durch den angepassten Unit-Test direkt abgedeckt.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 10:57:57 +02:00
Nick SzaunigandClaude Sonnet 5 0518a5740f feat: Wetter-Widget auf der Bienenstand-Detailseite
Neue WeatherCard auf /apiaries/[id]: Trachtprognose-Badge mit
Begruendung, 7-Tage-Vorhersagestreifen (Temperatur + Niederschlag).
Zeigt einen Hinweis statt eines Fehlers, wenn fuer den Bienenstand
noch keine Koordinaten hinterlegt sind.

TASKS.md: Wetterintegration + Trachtprognosen jetzt vollstaendig
(Backend aus vorherigem Commit + Frontend hier) abgehakt.

Verifiziert: tsc, lint, build, alle Unit-Tests (14/14) gruen. Live-
Browser-Check war blockiert durch dieselbe wiederkehrende WSL/
Postgres-Portweiterleitungs-Unzuverlaessigkeit wie zuvor in der
Session - die Funktionalitaet ist stattdessen durch die Backend-e2e-
Suite abgedeckt, die echten Postgres UND die echte Open-Meteo-API
aufruft (163/163 gruen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 09:47:44 +02:00
Nick SzaunigandClaude Sonnet 5 ce9cea1b94 feat: Wetterintegration + Trachtprognose pro Bienenstand
Neues WeatherModule: liest Bienenstand-Koordinaten und ruft die freie
Open-Meteo-API ab (kein API-Key nötig), liefert 14 Tage Wetterhistorie
+ 7-Tage-Vorhersage sowie eine heuristische Trachtprognose (Saison +
Wetter der letzten 7 Tage + historischer Ernteschnitt für denselben
Kalendermonat am selben Standort - explizit kein ML-Modell, siehe
TASKS.md Abschnitt 26).

- GET /api/v1/apiaries/:id/weather, GET .../trachtprognose, beide
  ueber die bestehende apiaries:read-Berechtigung abgesichert (kein
  neuer Permission-Key nötig, gleiches Muster wie die bestehenden
  Colony-Sub-Controller).
- Bienenstände ohne hinterlegte Koordinaten liefern 400 statt eines
  stillen Fallbacks.
- test/weather.e2e-spec.ts ruft bewusst die echte Open-Meteo-API auf
  (kostenlos, kein Auth, stabil genug für einen einzelnen Testlauf) -
  validiert damit den echten Response-Vertrag statt nur ein Mock.
- TASKS.md: beide Punkte abgehakt, stale Duplikat-Zeilen im
  Später-Abschnitt bereinigt.

Verifiziert: tsc, lint, build, unit (228/228), volle e2e-Suite
163/163 gruen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 09:43:08 +02:00
Nick SzaunigandClaude Sonnet 5 335c572dfc feat: RBAC Phase 5 - Rollenverwaltung im Frontend
Neue /settings/roles-Seite (Aktiv/Historie, Karten-Grid) zum Anlegen,
Bearbeiten, Deaktivieren und Reaktivieren von Rollen mit einer
Rechte-Matrix (nach Bereich gruppierte Checkboxen, Gruppen-Toggle,
Live-Zaehler), passend zum Backend aus Phase 1-3.

- AuthContext liest Rolle/Rechte jetzt ueber GET /auth/me statt aus
  dem JWT (das seit Phase 3 keinen role-Claim mehr traegt); neue
  hasPermission()-Hilfsfunktion ersetzt String-Vergleiche gegen "ADMIN".
- Sidebar zeigt Verwaltungs-Eintraege jetzt einzeln nach Berechtigung
  (statt Alles-oder-Nichts fuer ADMIN), inkl. neuem "Rollen & Rechte"-
  Eintrag.
- Settings-Layout blockt nur noch, wenn gar keine Verwaltungs-
  Berechtigung vorhanden ist, statt stur auf die ADMIN-Rolle zu pruefen.
- Benutzerseite: Rollen-Auswahl laedt jetzt aktive Rollen von
  GET /roles statt einer festen 5-Werte-Liste; sendet roleId statt
  role an POST/PATCH /users.
- Totes jwt.ts (Rolle wurde bislang aus dem Token dekodiert) entfernt.

Live im Browser getestet (Login, Sidebar, Rollen anlegen/bearbeiten/
deaktivieren/reaktivieren, Benutzer-Dialog): dabei einen echten a11y-
Bug gefunden und behoben - Gruppen-Checkboxen fuer mehrwortige
Bereichsnamen ("Wiederkehrende Aufgaben", "Rollen & Rechte") hatten
keinen zugaenglichen Namen, weil die HTML-id ein Leerzeichen enthielt;
jetzt ueber einen Slug erzeugt.

Verifiziert: tsc, lint, build, alle Unit-Tests (14/14) gruen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 09:27:23 +02:00
Nick SzaunigandClaude Sonnet 5 ae53f7889e feat: RBAC Phase 3 - LegacyRole-Enum, RolesGuard und @Roles vollstaendig entfernt
Letzte Phase des RBAC-Umbaus: die transitionale Rollen-Infrastruktur,
die seit Phase 1 parallel zum neuen Role/Permission-System mitlief,
ist jetzt komplett weg.

- Prisma-Migration: User.legacyRole-Spalte und LegacyRole-Enum
  gedroppt (nichts liest die Spalte mehr, seit Phase 2 jeder Controller
  auf @RequirePermission laeuft).
- roles.guard.ts, roles.guard.spec.ts, roles.decorator.ts geloescht;
  RolesGuard aus den APP_GUARD-Providern entfernt.
- AuthenticatedUser/JWT-Payload verlieren das role-Feld (permissions
  werden ohnehin frisch pro Request aus der DB aufgeloest, nicht aus
  dem Claim).
- Users-API: CreateUserDto/UpdateUserDto akzeptieren jetzt roleId
  (eine echte Role.id) statt der alten role-Enum-Werte; UsersService
  validiert, dass die Rolle existiert und aktiv ist, bevor sie
  zugewiesen wird.
- Alle 34 e2e-Fixtures und der Seed-Script folgen dem neuen Schema.

Verifiziert: tsc, lint, build, unit (220/220), volle e2e-Suite
159/159 gruen - keine uebersprungenen Tests.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 09:01:13 +02:00
Nick SzaunigandClaude Sonnet 5 0284077d3b feat: RBAC Phase 2e - Verwaltung auf @RequirePermission umgestellt, Coverage-Test aktiviert
Users, Webhooks, Automation, Import, AuditLog: die klassenweiten
@Roles(Role.ADMIN)-Blankets (die gefaehrlichsten der Migration, da sie
JEDE Route der Klasse abdecken) durch pro-Route @RequirePermission
ersetzt. Effektiver Zugriff unveraendert: ADMIN besitzt weiterhin alle
betroffenen Permission-Keys, jede andere Rolle bleibt ausgeschlossen.

Damit ist kein @Roles(...) mehr in der Codebase in Benutzung - alle
23 Controller laufen jetzt vollstaendig ueber @RequirePermission.
test/authorization-coverage.e2e-spec.ts (bisher it.skip mit TODO fuer
"nach Phase 2") ist jetzt scharf geschaltet und bestaetigt: 0 Routen
ohne Rechte-Pruefung, 0 ungueltige Permission-Keys.

Verifiziert: tsc, lint, build, unit (222/222), volle e2e-Suite
159/159 gruen - keine uebersprungenen Tests mehr.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 07:13:04 +02:00
Nick SzaunigandClaude Sonnet 5 8832081973 feat: RBAC Phase 2d - Auswertung & Ausgabe auf @RequirePermission umgestellt
Dashboard, Search, Calendar, Statistics, Qr, Export, Documents:
@Roles(...) durch @RequirePermission(...) ersetzt bzw. neu eingefuehrt
(alle diese Controller hatten zuvor gar keine Rollenpruefung -
jeder eingeloggte Nutzer konnte lesen). Documents behaelt STAFF-Zugriff
auf Upload und Loeschen.

Verifiziert: tsc, lint, volle e2e-Suite 158/159 gruen (1 getrackter Skip).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 07:09:20 +02:00
Nick SzaunigandClaude Sonnet 5 c510304813 feat: RBAC Phase 2c - Ernte & Lager auf @RequirePermission umgestellt
Harvests, Extractions, HoneyBatches, Storage, Products, Bottling,
Equipment: @Roles(...) durch @RequirePermission(...) ersetzt,
ungeschuetzte GET-Routen mit :read-Rechten geschlossen. HoneyBatches
behaelt die feine Unterscheidung relocate (auch STAFF) vs.
create/measurements (nur BEEKEEPER+); Storage bekommt konsequent
keine STAFF-Schreibrechte (deckt sich mit dem bisherigen @Roles-Stand).

Verifiziert: tsc, lint, volle e2e-Suite 158/159 gruen (1 getrackter Skip).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 07:05:32 +02:00
Nick SzaunigandClaude Sonnet 5 cdbdaedb8f feat: RBAC Phase 2b - Betrieb auf @RequirePermission umgestellt
Varroa, SwarmControl, Treatments, Feedings (+ jeweilige
Colony-Sub-Controller): @Roles(...) durch @RequirePermission(...)
ersetzt, ungeschuetzte GET-Routen mit :read-Rechten geschlossen.

Verifiziert: tsc, lint, e2e (varroa/swarm-control/treatments/feedings
einzeln gruen; volle Suite 158/159 gruen, 1 getrackter Skip).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 07:02:15 +02:00
Nick SzaunigandClaude Sonnet 5 f4b174ec56 feat: RBAC Phase 2a - Bienenvolk-Kern auf @RequirePermission umgestellt
Apiaries, Colonies, Queens, Inspections (+ ColonyInspections),
Tasks, TaskTemplates: @Roles(...) durch @RequirePermission(...) ersetzt.
Schliesst dabei die bisher ungeschuetzten GET-Routen (jeder eingeloggte
Nutzer konnte lesen, unabhaengig von seiner Rolle) mit :read-Rechten.
TaskTemplatesController verliert seinen klassenweiten @Roles-Blanket
zugunsten pro-Route-Rechten (gleiche effektive Berechtigung: nur
ADMIN/BEEKEEPER, jetzt ueber task-templates:* statt Rollen-Liste).

Verifiziert: tsc, lint, e2e (apiaries/colonies/queens/inspections/
tasks/task-templates, 29/29 gruen).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 06:57:04 +02:00
Nick SzaunigandClaude Sonnet 5 139ea6fdd7 feat: RBAC Rollen & Rechte (Phase 1 - Datenmodell, Roles-Verwaltung, Guard-Infrastruktur)
Replaces the fixed 5-value Role enum with a database-driven Role/Permission
model: admins can create custom roles with a precise per-resource/action
permission set (83-key code-owned catalog), instead of being locked to
ADMIN/BEEKEEPER/STAFF/READONLY/API_CLIENT.

- New Role/Permission/RolePermission tables; legacy Role enum renamed to
  LegacyRole and kept as User.legacyRole so all existing @Roles() call
  sites keep working unchanged during the migration.
- New RolesController/RolesService (CRUD, permission-set editing,
  deactivate/reactivate) with lockout-prevention invariants: system roles
  (ADMIN) can't be edited or deactivated, and a role still assigned to
  users can't be deactivated.
- New PermissionResolverService (30s-cached, DB-fresh per user) and
  PermissionsGuard + @RequirePermission(), registered as an additional
  APP_GUARD alongside the legacy RolesGuard so both systems run
  side-by-side until controllers are migrated in Phase 2.
- PermissionCatalogSyncService seeds any new catalog entries into the DB
  on boot, so future features only touch the TS catalog.
- New GET /auth/me returns the caller's role and effective permissions.
- Migration hand-written (destructive column rename) with grants derived
  from a fresh grep of every existing @Roles() call site, verified
  against 5 controllers read in full for STAFF's exact write boundary.
- test/roles.e2e-spec.ts covers the new endpoints and invariants end to
  end; test/authorization-coverage.e2e-spec.ts added but skipped until
  Phase 2 gives every controller a @RequirePermission.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-26 06:52:24 +02:00
Nick Szaunig d31aa79f50 feat: card-grid boxes with Aktiv/Historie split on main list pages
Replace the list-row layout with a responsive box grid (EntityCard):
icon avatar, title, badge, and 1-2 key fields visible at a glance per
box, matching what the user asked for ("Kaestchen" over rows).

Add an Aktiv/Historie tab split (ActiveHistoryTabs) so deactivated or
terminal-status entries move out of the default view instead of mixing
in with active ones:
- Apiaries, Colonies, Queens: split on deactivatedAt (the field the app
  already uses everywhere for "Historie statt Loeschen").
- Task templates: split on the existing active boolean.
- Tasks: OPEN/IN_PROGRESS vs DONE/CANCELLED.
- Honey batches: everything vs SOLD/CONSUMED.
- Products: ACTIVE vs DISCONTINUED.
- Storage containers: everything vs OUT_OF_SERVICE.
Equipment and stock items have no lifecycle field in the schema, so
they keep the plain grid without a Historie tab.

Verified live: deactivating a Bienenstand moves it out of Aktiv into
Historie immediately, both counts update, reactivating moves it back.
2026-08-26 00:25:23 +02:00
Nick Szaunig c586bff18f feat: modernize main list pages from tables to card-based rows
New shared ListItemCard component: icon avatar, title/subtitle, trailing
badges/actions, chevron affordance when linked to a detail page. Applied
to the 9 primary data-list pages (Bienenstaende, Voelker, Koeniginnen,
Honigchargen, Aufgaben, Lager, Produkte, Inventar, Wiederkehrende
Aufgaben), replacing the plain HTML tables with the same rounded-card
list aesthetic already used elsewhere in the app (inspections,
treatments, varroa, etc.), for a consistent look across the whole
product. Admin/settings tables (Benutzer, Webhooks, Audit-Log, Import)
intentionally left as tables per user's scoping choice.
2026-08-25 23:49:31 +02:00
Nick Szaunig faefd47e4b feat: dashboard tiles link to their matching list page
Each stat tile now navigates to /apiaries, /colonies, /queens, /tasks,
/honey-batches or /storage on click instead of being inert.
2026-08-25 23:21:16 +02:00
Nick Szaunig 147417d7d4 fix: address code-review findings (startup validation, shutdown hooks, health check, deploy config)
Verified each finding against the real codebase first, since the review's
diff was polluted by a stale leftover agent-worktree copy under
.claude/worktrees/ (now removed) that made several already-fixed or
never-broken issues look current (CORS, missing typecheck script, PWA
icons, font CSS, Helmet/Swagger CSP - all confirmed fine live, not
touched).

Real fixes:
- main.ts: fail-fast on missing DATABASE_URL/JWT_ACCESS_SECRET at boot,
  call enableShutdownHooks() so PrismaService.onModuleDestroy() actually
  runs on SIGTERM during k8s rolling deploys, PORT fallback 3000 -> 3001
  to match every other place that documents the port.
- health.controller.ts: log the real underlying DB error instead of
  discarding it, add a 5s timeout to the readiness query so a hung DB
  fails the probe fast instead of blocking indefinitely.
- Frontend Dockerfile: declare ARG/ENV NEXT_PUBLIC_API_URL before the
  build step, since Next.js inlines NEXT_PUBLIC_* vars at build time and
  a runtime-only ConfigMap value never reaches the compiled bundle.
- Backend ingress.yaml: drop the no-op rewrite-target regex for a plain
  prefix path (identical behavior, less confusing).
2026-08-25 23:07:29 +02:00
147 changed files with 4626 additions and 952 deletions

No files matched your search

@@ -0,0 +1,244 @@
-- RBAC migration: replaces the fixed User.role enum with a database-driven
-- Role/Permission/RolePermission model. See docs/lastenheft.md RBAC notes
-- for the phased rollout this belongs to (Phase 1 of 5).
--
-- Grants below reproduce today's effective access exactly, derived from a
-- fresh grep of every @Roles(...) call site in src/**/*.controller.ts at
-- the time this migration was written. Do not hand-tune without re-deriving
-- from the code.
BEGIN;
-- 1. Preserve the old enum + column under a new name so every existing
-- @Roles(Role.ADMIN, ...) call site keeps compiling untouched during the
-- migration (Phase 1-2 alias `LegacyRole as Role` in TS).
ALTER TYPE "Role" RENAME TO "LegacyRole";
ALTER TABLE "User" RENAME COLUMN "role" TO "legacyRole";
-- 2. Drop the old index on the renamed column (replaced by an index on the
-- new roleId column in step 10).
DROP INDEX "User_role_idx";
-- 3. New RBAC tables.
CREATE TYPE "RoleStatus" AS ENUM ('ACTIVE', 'INACTIVE');
CREATE TABLE "Role" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"status" "RoleStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deactivatedAt" TIMESTAMP(3),
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Role_key_key" ON "Role"("key");
CREATE INDEX "Role_status_idx" ON "Role"("status");
CREATE TABLE "Permission" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"labelDe" TEXT NOT NULL,
"groupDe" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Permission_key_key" ON "Permission"("key");
CREATE UNIQUE INDEX "Permission_resource_action_key" ON "Permission"("resource", "action");
CREATE INDEX "Permission_resource_idx" ON "Permission"("resource");
CREATE TABLE "RolePermission" (
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"grantedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RolePermission_pkey" PRIMARY KEY ("roleId", "permissionId")
);
CREATE INDEX "RolePermission_permissionId_idx" ON "RolePermission"("permissionId");
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_roleId_fkey"
FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RolePermission" ADD CONSTRAINT "RolePermission_permissionId_fkey"
FOREIGN KEY ("permissionId") REFERENCES "Permission"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 4. Seed the fixed permission catalog (generated from
-- src/auth/permissions/permission-catalog.ts — the code-owned source of
-- truth this table mirrors).
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
SELECT gen_random_uuid()::text, v.key, v.resource, v.action, v."labelDe", v."groupDe", v."sortOrder"
FROM (VALUES
('apiaries:read', 'apiaries', 'read', 'Ansehen', 'Bienenstände', 100),
('apiaries:create', 'apiaries', 'create', 'Anlegen', 'Bienenstände', 101),
('apiaries:update', 'apiaries', 'update', 'Bearbeiten', 'Bienenstände', 102),
('apiaries:deactivate', 'apiaries', 'deactivate', 'Deaktivieren', 'Bienenstände', 103),
('colonies:read', 'colonies', 'read', 'Ansehen', 'Völker', 200),
('colonies:create', 'colonies', 'create', 'Anlegen', 'Völker', 201),
('colonies:update', 'colonies', 'update', 'Bearbeiten', 'Völker', 202),
('colonies:deactivate', 'colonies', 'deactivate', 'Deaktivieren', 'Völker', 203),
('colonies:manage-lifecycle', 'colonies', 'manage-lifecycle', 'Umsetzen/Teilen/Vereinigen/Auflösen', 'Völker', 204),
('queens:read', 'queens', 'read', 'Ansehen', 'Königinnen', 300),
('queens:create', 'queens', 'create', 'Anlegen', 'Königinnen', 301),
('queens:update', 'queens', 'update', 'Bearbeiten', 'Königinnen', 302),
('queens:assign', 'queens', 'assign', 'Volk zuweisen', 'Königinnen', 303),
('queens:evaluate', 'queens', 'evaluate', 'Bewerten', 'Königinnen', 304),
('inspections:read', 'inspections', 'read', 'Ansehen', 'Durchsichten', 400),
('inspections:create', 'inspections', 'create', 'Anlegen', 'Durchsichten', 401),
('inspections:update', 'inspections', 'update', 'Bearbeiten', 'Durchsichten', 402),
('tasks:read', 'tasks', 'read', 'Ansehen', 'Aufgaben', 500),
('tasks:create', 'tasks', 'create', 'Anlegen', 'Aufgaben', 501),
('tasks:update', 'tasks', 'update', 'Bearbeiten', 'Aufgaben', 502),
('tasks:complete', 'tasks', 'complete', 'Erledigen', 'Aufgaben', 503),
('task-templates:read', 'task-templates', 'read', 'Ansehen', 'Wiederkehrende Aufgaben', 600),
('task-templates:create', 'task-templates', 'create', 'Anlegen', 'Wiederkehrende Aufgaben', 601),
('task-templates:update', 'task-templates', 'update', 'Bearbeiten', 'Wiederkehrende Aufgaben', 602),
('task-templates:deactivate', 'task-templates', 'deactivate', 'Deaktivieren', 'Wiederkehrende Aufgaben', 603),
('varroa:read', 'varroa', 'read', 'Ansehen', 'Varroa', 700),
('varroa:create', 'varroa', 'create', 'Anlegen', 'Varroa', 701),
('varroa:update', 'varroa', 'update', 'Bearbeiten', 'Varroa', 702),
('swarm-control:read', 'swarm-control', 'read', 'Ansehen', 'Schwarmkontrolle', 800),
('swarm-control:create', 'swarm-control', 'create', 'Anlegen', 'Schwarmkontrolle', 801),
('swarm-control:update', 'swarm-control', 'update', 'Bearbeiten', 'Schwarmkontrolle', 802),
('treatments:read', 'treatments', 'read', 'Ansehen', 'Behandlungen', 900),
('treatments:create', 'treatments', 'create', 'Anlegen', 'Behandlungen', 901),
('treatments:update', 'treatments', 'update', 'Bearbeiten', 'Behandlungen', 902),
('feedings:read', 'feedings', 'read', 'Ansehen', 'Fütterungen', 1000),
('feedings:create', 'feedings', 'create', 'Anlegen', 'Fütterungen', 1001),
('feedings:update', 'feedings', 'update', 'Bearbeiten', 'Fütterungen', 1002),
('harvests:read', 'harvests', 'read', 'Ansehen', 'Ernten', 1100),
('harvests:create', 'harvests', 'create', 'Anlegen', 'Ernten', 1101),
('harvests:update', 'harvests', 'update', 'Bearbeiten', 'Ernten', 1102),
('extractions:read', 'extractions', 'read', 'Ansehen', 'Schleuderungen', 1200),
('extractions:create', 'extractions', 'create', 'Anlegen', 'Schleuderungen', 1201),
('extractions:update', 'extractions', 'update', 'Bearbeiten', 'Schleuderungen', 1202),
('honey-batches:read', 'honey-batches', 'read', 'Ansehen', 'Honigchargen', 1300),
('honey-batches:create', 'honey-batches', 'create', 'Anlegen', 'Honigchargen', 1301),
('honey-batches:relocate', 'honey-batches', 'relocate', 'Umlagern', 'Honigchargen', 1302),
('honey-batches:measure', 'honey-batches', 'measure', 'Messwerte erfassen', 'Honigchargen', 1303),
('storage:read', 'storage', 'read', 'Ansehen', 'Lager', 1400),
('storage:create', 'storage', 'create', 'Anlegen', 'Lager', 1401),
('storage:move', 'storage', 'move', 'Bewegung buchen', 'Lager', 1402),
('products:read', 'products', 'read', 'Ansehen', 'Produkte', 1500),
('products:create', 'products', 'create', 'Anlegen', 'Produkte', 1501),
('products:update', 'products', 'update', 'Bearbeiten', 'Produkte', 1502),
('bottling:read', 'bottling', 'read', 'Ansehen', 'Abfüllungen', 1600),
('bottling:create', 'bottling', 'create', 'Anlegen', 'Abfüllungen', 1601),
('bottling:update', 'bottling', 'update', 'Bearbeiten', 'Abfüllungen', 1602),
('equipment:read', 'equipment', 'read', 'Ansehen', 'Inventar', 1700),
('equipment:create', 'equipment', 'create', 'Anlegen', 'Inventar', 1701),
('equipment:update', 'equipment', 'update', 'Bearbeiten', 'Inventar', 1702),
('documents:read', 'documents', 'read', 'Ansehen', 'Dokumente', 1800),
('documents:upload', 'documents', 'upload', 'Hochladen', 'Dokumente', 1801),
('documents:delete', 'documents', 'delete', 'Löschen', 'Dokumente', 1802),
('webhooks:read', 'webhooks', 'read', 'Ansehen', 'Webhooks', 1900),
('webhooks:create', 'webhooks', 'create', 'Anlegen', 'Webhooks', 1901),
('webhooks:delete', 'webhooks', 'delete', 'Löschen', 'Webhooks', 1902),
('automation:read', 'automation', 'read', 'Ansehen', 'Automatisierungen', 2000),
('automation:update', 'automation', 'update', 'Bearbeiten', 'Automatisierungen', 2001),
('import:execute', 'import', 'execute', 'Ausführen', 'Import', 2100),
('export:read', 'export', 'read', 'Ansehen', 'Export', 2200),
('users:read', 'users', 'read', 'Ansehen', 'Benutzer', 2300),
('users:create', 'users', 'create', 'Anlegen', 'Benutzer', 2301),
('users:update', 'users', 'update', 'Bearbeiten', 'Benutzer', 2302),
('users:deactivate', 'users', 'deactivate', 'Deaktivieren', 'Benutzer', 2303),
('roles:read', 'roles', 'read', 'Ansehen', 'Rollen & Rechte', 2400),
('roles:create', 'roles', 'create', 'Anlegen', 'Rollen & Rechte', 2401),
('roles:update', 'roles', 'update', 'Bearbeiten', 'Rollen & Rechte', 2402),
('roles:deactivate', 'roles', 'deactivate', 'Deaktivieren', 'Rollen & Rechte', 2403),
('audit:read', 'audit', 'read', 'Ansehen', 'Audit-Log', 2500),
('calendar:read', 'calendar', 'read', 'Ansehen', 'Kalender', 2600),
('dashboard:read', 'dashboard', 'read', 'Ansehen', 'Dashboard', 2700),
('statistics:read', 'statistics', 'read', 'Ansehen', 'Statistik', 2800),
('search:read', 'search', 'read', 'Ansehen', 'Suche', 2900),
('qr:read', 'qr', 'read', 'Ansehen', 'QR-Codes', 3000)
) AS v(key, resource, action, "labelDe", "groupDe", "sortOrder");
-- 5. Seed the 5 roles that replace the legacy enum values, one-for-one.
INSERT INTO "Role" (id, key, name, description, "isSystem", status, "createdAt", "updatedAt")
VALUES
(gen_random_uuid()::text, 'ADMIN', 'Administrator', 'Voller Zugriff auf alle Bereiche, inkl. Benutzer- und Rechteverwaltung.', true, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'BEEKEEPER', 'Imker', 'Voller fachlicher Zugriff, ohne Benutzer-, Rollen- und Systemverwaltung.', false, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'STAFF', 'Mitarbeiter', 'Ansicht aller Bereiche, Erfassung der laufenden Tagesarbeit.', false, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'READONLY', 'Nur Lesen', 'Lesezugriff auf alle Bereiche, keine Änderungen.', false, 'ACTIVE', now(), now()),
(gen_random_uuid()::text, 'API_CLIENT', 'API-Client', 'Lesezugriff für externe Integrationen.', false, 'ACTIVE', now(), now());
-- 6. Grants, derived from a fresh grep of every @Roles(...) call site.
-- ADMIN: everything.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'ADMIN';
-- BEEKEEPER: everything except the administrative/system resources that
-- were ADMIN-only via class-level @Roles(Role.ADMIN).
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'BEEKEEPER'
AND p.resource NOT IN ('users', 'roles', 'audit', 'webhooks', 'automation', 'import');
-- STAFF: read access to every non-administrative resource ...
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'STAFF'
AND p.action = 'read'
AND p.resource NOT IN ('task-templates', 'users', 'roles', 'audit', 'webhooks', 'automation', 'import');
-- ... plus exactly the write actions that carried Role.STAFF today.
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key = 'STAFF'
AND p.key IN (
'inspections:create', 'inspections:update',
'tasks:create', 'tasks:update', 'tasks:complete',
'feedings:create', 'feedings:update',
'harvests:create', 'harvests:update',
'honey-batches:relocate',
'documents:upload', 'documents:delete'
);
-- READONLY and API_CLIENT: the same read-only set as STAFF (neither role
-- appears in any @Roles(...) call site today, so both currently mean
-- "read everything" — kept identical here, adjustable later via the UI).
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
SELECT r.id, p.id, now()
FROM "Role" r, "Permission" p
WHERE r.key IN ('READONLY', 'API_CLIENT')
AND p.action = 'read'
AND p.resource NOT IN ('task-templates', 'users', 'roles', 'audit', 'webhooks', 'automation', 'import');
-- 7. New nullable roleId column on User, backfilled below.
ALTER TABLE "User" ADD COLUMN "roleId" TEXT;
-- 8. Backfill from the legacy enum value.
UPDATE "User" u
SET "roleId" = r.id
FROM "Role" r
WHERE r.key = u."legacyRole"::text;
-- 9. Verification gate: refuse to proceed if any user was left unresolved.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "User" WHERE "roleId" IS NULL) THEN
RAISE EXCEPTION 'RBAC backfill incomplete: at least one User row has no resolvable roleId';
END IF;
END $$;
-- 10. Lock it down.
ALTER TABLE "User" ALTER COLUMN "roleId" SET NOT NULL;
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey"
FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
CREATE INDEX "User_roleId_idx" ON "User"("roleId");
COMMIT;
@@ -0,0 +1,10 @@
-- RBAC Phase 3: drops the transitional LegacyRole enum + User.legacyRole
-- column now that every controller runs on @RequirePermission and no code
-- reads the legacy field anymore (see permission-catalog.ts / roles module).
BEGIN;
ALTER TABLE "User" DROP COLUMN "legacyRole";
DROP TYPE "LegacyRole";
COMMIT;
+54 -13
View File
@@ -16,23 +16,64 @@ datasource db {
// Benutzer / Auth / Audit (Abschnitt 3, 14, 15, 16)
// ---------------------------------------------------------------------------
enum Role {
ADMIN
BEEKEEPER
STAFF
READONLY
API_CLIENT
enum RoleStatus {
ACTIVE
INACTIVE
}
model Role {
id String @id @default(cuid())
key String @unique
name String
description String?
isSystem Boolean @default(false)
status RoleStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
permissions RolePermission[]
users User[]
@@index([status])
}
model Permission {
id String @id @default(cuid())
key String @unique
resource String
action String
labelDe String
groupDe String
sortOrder Int
roles RolePermission[]
@@unique([resource, action])
@@index([resource])
}
model RolePermission {
roleId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permissionId String
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
grantedAt DateTime @default(now())
@@id([roleId, permissionId])
@@index([permissionId])
}
model User {
id String @id @default(cuid())
email String @unique
id String @id @default(cuid())
email String @unique
passwordHash String
name String
role Role @default(STAFF)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roleId String
role Role @relation(fields: [roleId], references: [id])
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deactivatedAt DateTime?
refreshTokens RefreshToken[]
@@ -59,7 +100,7 @@ model User {
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
@@index([role])
@@index([roleId])
}
model RefreshToken {
+9 -1
View File
@@ -22,8 +22,16 @@ async function main(): Promise<void> {
}
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
await prisma.user.create({
data: { email, passwordHash, name: 'Administrator', role: 'ADMIN' },
data: {
email,
passwordHash,
name: 'Administrator',
roleId: adminRole.id,
},
});
console.log(`Seed: created admin user ${email}.`);
await prisma.$disconnect();
@@ -8,8 +8,8 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ApiaryStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { ApiaryStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateApiaryDto } from './dto/create-apiary.dto';
@@ -22,6 +22,7 @@ import { ApiariesService } from './apiaries.service';
export class ApiariesController {
constructor(private readonly apiariesService: ApiariesService) {}
@RequirePermission('apiaries:read')
@Get()
findAll(
@Query('status') status?: ApiaryStatus,
@@ -30,12 +31,13 @@ export class ApiariesController {
return this.apiariesService.findAll({ status, location });
}
@RequirePermission('apiaries:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.apiariesService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('apiaries:create')
@Post()
create(
@Body() dto: CreateApiaryDto,
@@ -44,7 +46,7 @@ export class ApiariesController {
return this.apiariesService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('apiaries:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -54,13 +56,13 @@ export class ApiariesController {
return this.apiariesService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('apiaries:deactivate')
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.apiariesService.deactivate(id, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('apiaries:deactivate')
@Post(':id/reactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.apiariesService.reactivate(id, actor.id);
+8 -2
View File
@@ -7,6 +7,7 @@ import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { RolesModule } from './roles/roles.module';
import { ApiariesModule } from './apiaries/apiaries.module';
import { ColoniesModule } from './colonies/colonies.module';
import { QueensModule } from './queens/queens.module';
@@ -36,18 +37,22 @@ import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WeatherModule } from './weather/weather.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{ name: 'default', ttl: 60000, limit: 100 }]),
PrismaModule,
PermissionsModule,
AuditModule,
HealthModule,
UsersModule,
AuthModule,
RolesModule,
ApiariesModule,
ColoniesModule,
QueensModule,
@@ -77,11 +82,12 @@ import { RolesGuard } from './auth/guards/roles.guard';
PublicModule,
TaskTemplatesModule,
SwarmControlModule,
WeatherModule,
],
controllers: [],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
@@ -1,7 +1,6 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { AuditLogService } from './audit-log.service';
@ApiTags('audit-log')
@@ -10,7 +9,7 @@ import { AuditLogService } from './audit-log.service';
export class AuditController {
constructor(private readonly auditLogService: AuditLogService) {}
@Roles(Role.ADMIN)
@RequirePermission('audit:read')
@Get()
findAll(
@Query('entityType') entityType?: string,
+11 -2
View File
@@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
@@ -8,13 +9,15 @@ import {
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import type { AuthenticatedUser } from './types/authenticated-user';
import { LoginDto } from './dto/login.dto';
import { AccessTokenDto } from './dto/access-token.dto';
import { AuthService } from './auth.service';
import { AuthService, CurrentUserProfile } from './auth.service';
import { parseDurationToMs } from './util/duration';
const REFRESH_TOKEN_COOKIE = 'refreshToken';
@@ -96,4 +99,10 @@ export class AuthController {
await this.authService.logout(this.extractRefreshToken(req));
this.clearRefreshTokenCookie(res);
}
@ApiBearerAuth()
@Get('me')
me(@CurrentUser() user: AuthenticatedUser): Promise<CurrentUserProfile> {
return this.authService.getCurrentUserProfile(user.id);
}
}
@@ -18,11 +18,11 @@ describe('AuthService', () => {
};
let jwtService: { sign: jest.Mock };
let auditLog: { record: jest.Mock };
let permissionResolver: { getPermissions: jest.Mock };
const activeUser = {
id: 'user-1',
email: '[email protected]',
role: 'STAFF',
isActive: true,
passwordHash: '',
};
@@ -40,12 +40,16 @@ describe('AuthService', () => {
};
jwtService = { sign: jest.fn().mockReturnValue('signed.jwt.token') };
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
getPermissions: jest.fn().mockResolvedValue(new Set()),
};
authService = new AuthService(
usersService as unknown as UsersService,
prisma as unknown as PrismaService,
jwtService as never,
auditLog as unknown as AuditLogService,
permissionResolver as never,
);
});
+28 -3
View File
@@ -5,6 +5,7 @@ import * as crypto from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { AuthenticatedUser } from './types/authenticated-user';
import { parseDurationToMs } from './util/duration';
@@ -13,6 +14,15 @@ interface AuthTokens {
refreshToken: string;
}
export interface CurrentUserProfile {
id: string;
email: string;
name: string;
roleKey: string;
roleName: string;
permissions: string[];
}
const REFRESH_TOKEN_BYTES = 40;
@Injectable()
@@ -22,8 +32,25 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
include: { role: { select: { key: true, name: true } } },
});
const permissions = await this.permissionResolver.getPermissions(userId);
return {
id: user.id,
email: user.email,
name: user.name,
roleKey: user.role.key,
roleName: user.role.name,
permissions: Array.from(permissions),
};
}
private hashRefreshToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
@@ -33,7 +60,7 @@ export class AuthService {
parseDurationToMs(process.env.JWT_ACCESS_EXPIRES_IN ?? '15m') / 1000,
);
return this.jwtService.sign(
{ sub: user.id, email: user.email, role: user.role },
{ sub: user.id, email: user.email },
{
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: expiresInSeconds,
@@ -77,7 +104,6 @@ export class AuthService {
const authenticatedUser: AuthenticatedUser = {
id: user.id,
email: user.email,
role: user.role,
};
const accessToken = this.signAccessToken(authenticatedUser);
const refreshToken = await this.issueRefreshToken(user.id);
@@ -109,7 +135,6 @@ export class AuthService {
const authenticatedUser: AuthenticatedUser = {
id: user.id,
email: user.email,
role: user.role,
};
const accessToken = this.signAccessToken(authenticatedUser);
const newRefreshToken = await this.issueRefreshToken(user.id);
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
import { PermissionKey } from '../permissions/permission-catalog';
export const REQUIRE_PERMISSION_KEY = 'require-permission';
/**
* Grants access if the caller holds ANY of the given permission keys
* (OR semantics, matching the legacy @Roles(...) behaviour it replaces).
*/
export const RequirePermission = (...keys: PermissionKey[]) =>
SetMetadata(REQUIRE_PERMISSION_KEY, keys);
@@ -1,5 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../../../generated/prisma';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,70 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: { getAllAndOverride: jest.Mock };
let permissionResolver: { isUserActive: jest.Mock };
function contextWithUser(user: { id: string } | undefined): ExecutionContext {
const request = { user };
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
beforeEach(() => {
reflector = { getAllAndOverride: jest.fn().mockReturnValue(false) };
permissionResolver = { isUserActive: jest.fn() };
guard = new JwtAuthGuard(
reflector as unknown as Reflector,
permissionResolver as unknown as PermissionResolverService,
);
});
it('allows public routes through without checking authentication', async () => {
reflector.getAllAndOverride.mockReturnValue(true);
await expect(guard.canActivate(contextWithUser(undefined))).resolves.toBe(
true,
);
expect(permissionResolver.isUserActive).not.toHaveBeenCalled();
});
it('allows the request when the JWT is valid and the user is active', async () => {
const context = contextWithUser({ id: 'user-1' });
jest
.spyOn(
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
canActivate: (context: ExecutionContext) => boolean;
},
'canActivate',
)
.mockReturnValue(true);
permissionResolver.isUserActive.mockResolvedValue(true);
await expect(guard.canActivate(context)).resolves.toBe(true);
expect(permissionResolver.isUserActive).toHaveBeenCalledWith('user-1');
});
it('throws UnauthorizedException when the authenticated user has been deactivated', async () => {
const context = contextWithUser({ id: 'user-1' });
jest
.spyOn(
Object.getPrototypeOf(JwtAuthGuard.prototype) as {
canActivate: (context: ExecutionContext) => boolean;
},
'canActivate',
)
.mockReturnValue(true);
permissionResolver.isUserActive.mockResolvedValue(false);
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
});
@@ -1,15 +1,37 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
interface RequestWithUser {
user?: AuthenticatedUser;
}
/**
* Enforces User.isActive on every authenticated request, not just ones
* gated by @RequirePermission. Without this, a deactivated user's
* still-valid access token would keep working on any route that carries no
* permission decorator until the token's own expiry (up to
* JWT_ACCESS_EXPIRES_IN). isUserActive shares PermissionsGuard's cache, so
* this check costs no extra query beyond what permission resolution already
* does.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
constructor(
private readonly reflector: Reflector,
private readonly permissionResolver: PermissionResolverService,
) {
super();
}
canActivate(context: ExecutionContext) {
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
@@ -17,6 +39,15 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
if (isPublic) {
return true;
}
return super.canActivate(context);
const authenticated = (await super.canActivate(context)) as boolean;
if (!authenticated) {
return false;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user || !(await this.permissionResolver.isUserActive(user.id))) {
throw new UnauthorizedException();
}
return true;
}
}
@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { REQUIRE_PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionKey } from '../permissions/permission-catalog';
import { PermissionResolverService } from '../permissions/permission-resolver.service';
import { AuthenticatedUser } from '../types/authenticated-user';
interface RequestWithUser {
user?: AuthenticatedUser;
}
/**
* Fail-open by decorator absence: a route with no @RequirePermission is
* allowed through untouched (any authenticated user can reach it), only
* routes that carry the decorator are enforced against the caller's live
* permission set. There is no default-deny here, so
* test/authorization-coverage.e2e-spec.ts — which fails the build if any
* non-public route lacks a catalog-valid @RequirePermission — is the sole
* safety net for "someone added a route and forgot the decorator", not a
* defense-in-depth nicety.
*/
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissionResolver: PermissionResolverService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.getAllAndOverride<
PermissionKey[]
>(REQUIRE_PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!requiredPermissions || requiredPermissions.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
if (!user) {
return false;
}
return this.permissionResolver.hasAny(user.id, requiredPermissions);
}
}
@@ -1,48 +0,0 @@
import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
import { Role } from '../../../generated/prisma';
function createContext(user: { role: Role } | undefined): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as unknown as ExecutionContext;
}
describe('RolesGuard', () => {
it('allows access when no roles are required', () => {
const reflector = {
getAllAndOverride: () => undefined,
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext(undefined))).toBe(true);
});
it('denies access when the user role does not match', () => {
const reflector = {
getAllAndOverride: () => [Role.ADMIN],
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext({ role: Role.STAFF }))).toBe(false);
});
it('allows access when the user role matches', () => {
const reflector = {
getAllAndOverride: () => [Role.ADMIN],
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext({ role: Role.ADMIN }))).toBe(true);
});
it('denies access when no user is attached to the request', () => {
const reflector = {
getAllAndOverride: () => [Role.ADMIN],
} as unknown as Reflector;
const guard = new RolesGuard(reflector);
expect(guard.canActivate(createContext(undefined))).toBe(false);
});
});
@@ -1,27 +0,0 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../../../generated/prisma';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedUser } from '../types/authenticated-user';
interface RequestWithUser {
user?: AuthenticatedUser;
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<RequestWithUser>();
const user = request.user;
return !!user && requiredRoles.includes(user.role);
}
}
@@ -0,0 +1,51 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PERMISSION_CATALOG } from './permission-catalog';
/**
* Keeps the Permission table in step with the code-owned PERMISSION_CATALOG
* so a future feature only needs to add an entry to the catalog file, not
* hand-write a migration to seed it too. Runs once on boot: inserts any
* catalog entry missing from the database. Never deletes a Permission row
* that fell out of the catalog (only warns) — a role's grants must not
* silently disappear because of a typo or a reordered catalog edit.
*/
@Injectable()
export class PermissionCatalogSyncService implements OnModuleInit {
private readonly logger = new Logger(PermissionCatalogSyncService.name);
constructor(private readonly prisma: PrismaService) {}
async onModuleInit(): Promise<void> {
const existing = await this.prisma.permission.findMany({
select: { key: true },
});
const existingKeys = new Set(existing.map((permission) => permission.key));
const missing = PERMISSION_CATALOG.filter(
(entry) => !existingKeys.has(entry.key),
);
for (const entry of missing) {
await this.prisma.permission.create({ data: entry });
}
if (missing.length > 0) {
this.logger.log(
`Synced ${missing.length} new permission(s) from the code catalog: ${missing
.map((entry) => entry.key)
.join(', ')}`,
);
}
const catalogKeys = new Set(PERMISSION_CATALOG.map((entry) => entry.key));
const orphaned = existing.filter(
(permission) => !catalogKeys.has(permission.key),
);
if (orphaned.length > 0) {
this.logger.warn(
`${orphaned.length} permission(s) exist in the database but not in the code catalog (left untouched): ${orphaned
.map((permission) => permission.key)
.join(', ')}`,
);
}
}
}
@@ -0,0 +1,266 @@
export interface PermissionCatalogEntry {
key: string;
resource: string;
action: string;
labelDe: string;
groupDe: string;
sortOrder: number;
}
function group(
groupDe: string,
resource: string,
actions: [action: string, labelDe: string][],
startOrder: number,
): PermissionCatalogEntry[] {
return actions.map(([action, labelDe], index) => ({
key: `${resource}:${action}`,
resource,
action,
labelDe,
groupDe,
sortOrder: startOrder + index,
}));
}
export const PERMISSION_CATALOG = [
...group(
'Bienenstände',
'apiaries',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
100,
),
...group(
'Völker',
'colonies',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
['manage-lifecycle', 'Umsetzen/Teilen/Vereinigen/Auflösen'],
],
200,
),
...group(
'Königinnen',
'queens',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['assign', 'Volk zuweisen'],
['evaluate', 'Bewerten'],
],
300,
),
...group(
'Durchsichten',
'inspections',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
400,
),
...group(
'Aufgaben',
'tasks',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['complete', 'Erledigen'],
],
500,
),
...group(
'Wiederkehrende Aufgaben',
'task-templates',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
600,
),
...group(
'Varroa',
'varroa',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
700,
),
...group(
'Schwarmkontrolle',
'swarm-control',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
800,
),
...group(
'Behandlungen',
'treatments',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
900,
),
...group(
'Fütterungen',
'feedings',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1000,
),
...group(
'Ernten',
'harvests',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1100,
),
...group(
'Schleuderungen',
'extractions',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1200,
),
...group(
'Honigchargen',
'honey-batches',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['relocate', 'Umlagern'],
['measure', 'Messwerte erfassen'],
],
1300,
),
...group(
'Lager',
'storage',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['move', 'Bewegung buchen'],
],
1400,
),
...group(
'Produkte',
'products',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1500,
),
...group(
'Abfüllungen',
'bottling',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1600,
),
...group(
'Inventar',
'equipment',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
1700,
),
...group(
'Dokumente',
'documents',
[
['read', 'Ansehen'],
['upload', 'Hochladen'],
['delete', 'Löschen'],
],
1800,
),
...group(
'Webhooks',
'webhooks',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['delete', 'Löschen'],
],
1900,
),
...group(
'Automatisierungen',
'automation',
[
['read', 'Ansehen'],
['update', 'Bearbeiten'],
],
2000,
),
...group('Import', 'import', [['execute', 'Ausführen']], 2100),
...group('Export', 'export', [['read', 'Ansehen']], 2200),
...group(
'Benutzer',
'users',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
2300,
),
...group(
'Rollen & Rechte',
'roles',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
['deactivate', 'Deaktivieren'],
],
2400,
),
...group('Audit-Log', 'audit', [['read', 'Ansehen']], 2500),
...group('Kalender', 'calendar', [['read', 'Ansehen']], 2600),
...group('Dashboard', 'dashboard', [['read', 'Ansehen']], 2700),
...group('Statistik', 'statistics', [['read', 'Ansehen']], 2800),
...group('Suche', 'search', [['read', 'Ansehen']], 2900),
...group('QR-Codes', 'qr', [['read', 'Ansehen']], 3000),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,185 @@
import { PermissionResolverService } from './permission-resolver.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('PermissionResolverService', () => {
let resolver: PermissionResolverService;
let prisma: { user: { findUnique: jest.Mock } };
function activeUserWithPermissions(...keys: string[]) {
return {
isActive: true,
role: {
status: 'ACTIVE',
permissions: keys.map((key) => ({ permission: { key } })),
},
};
}
beforeEach(() => {
prisma = { user: { findUnique: jest.fn() } };
resolver = new PermissionResolverService(
prisma as unknown as PrismaService,
);
});
it('returns the granted permission keys for an active user with an active role', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read', 'tasks:create'),
);
const permissions = await resolver.getPermissions('user-1');
expect(Array.from(permissions).sort()).toEqual(
['colonies:read', 'tasks:create'].sort(),
);
});
it('returns no permissions for a deactivated user, even if their role is active', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
const permissions = await resolver.getPermissions('user-1');
expect(permissions.size).toBe(0);
});
it('returns no permissions when the role itself is deactivated', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
const permissions = await resolver.getPermissions('user-1');
expect(permissions.size).toBe(0);
});
it('returns no permissions when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const permissions = await resolver.getPermissions('missing');
expect(permissions.size).toBe(0);
});
it('caches the result and does not re-query within the TTL', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
it('re-queries after invalidateUser', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
resolver.invalidateUser('user-1');
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('isUserActive', () => {
it('returns true for an active user with an active role', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isUserActive('user-1')).resolves.toBe(true);
});
it('returns false for a deactivated user', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: false,
role: {
status: 'ACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
});
it('returns false when the role is inactive', async () => {
prisma.user.findUnique.mockResolvedValue({
isActive: true,
role: {
status: 'INACTIVE',
permissions: [{ permission: { key: 'colonies:read' } }],
},
});
await expect(resolver.isUserActive('user-1')).resolves.toBe(false);
});
it('returns false when the user does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
await expect(resolver.isUserActive('missing')).resolves.toBe(false);
});
it('shares the cached entry with getPermissions instead of re-querying', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.isUserActive('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
});
});
it('re-queries after invalidateAll', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
resolver.invalidateAll();
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
describe('hasAny', () => {
it('returns true when the user holds at least one of the requested keys', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(
resolver.hasAny('user-1', ['tasks:create', 'colonies:read']),
).resolves.toBe(true);
});
it('returns false when the user holds none of the requested keys', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.hasAny('user-1', ['tasks:create'])).resolves.toBe(
false,
);
});
it('returns true when no keys are required', async () => {
await expect(resolver.hasAny('user-1', [])).resolves.toBe(true);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { PermissionKey } from './permission-catalog';
interface CacheEntry {
isActive: boolean;
permissions: ReadonlySet<string>;
expiresAt: number;
}
const CACHE_TTL_MS = 30_000;
/**
* Resolves a user's effective permission set fresh from the database on
* every request (not embedded in the JWT, so revoking a role or editing a
* role's grants takes effect without forcing a re-login), with a short TTL
* cache to keep that from becoming a query-per-request cost. Call
* invalidateUser/invalidateAll after any write to Role, Permission,
* RolePermission, or User.roleId so the change is visible immediately
* instead of waiting out the cache window.
*
* isUserActive shares the same cached entry: JwtAuthGuard calls it on every
* authenticated request (including routes with no @RequirePermission) so a
* deactivated user's still-valid access token stops working within the
* cache TTL instead of staying usable until the token expires.
*/
@Injectable()
export class PermissionResolverService {
private readonly cache = new Map<string, CacheEntry>();
constructor(private readonly prisma: PrismaService) {}
async getPermissions(userId: string): Promise<ReadonlySet<string>> {
const entry = await this.getEntry(userId);
return entry.permissions;
}
async isUserActive(userId: string): Promise<boolean> {
const entry = await this.getEntry(userId);
return entry.isActive;
}
async hasAny(
userId: string,
keys: readonly PermissionKey[],
): Promise<boolean> {
if (keys.length === 0) {
return true;
}
const permissions = await this.getPermissions(userId);
return keys.some((key) => permissions.has(key));
}
invalidateUser(userId: string): void {
this.cache.delete(userId);
}
invalidateAll(): void {
this.cache.clear();
}
private async getEntry(userId: string): Promise<CacheEntry> {
const cached = this.cache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
return cached;
}
const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
return entry;
}
private async loadEntry(userId: string): Promise<CacheEntry> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
isActive: true,
role: {
select: {
status: true,
permissions: {
select: { permission: { select: { key: true } } },
},
},
},
},
});
const isEligible = Boolean(
user && user.isActive && user.role.status === 'ACTIVE',
);
const permissions = isEligible
? new Set(user!.role.permissions.map((grant) => grant.permission.key))
: new Set<string>();
return {
isActive: isEligible,
permissions,
expiresAt: Date.now() + CACHE_TTL_MS,
};
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PermissionCatalogSyncService } from './permission-catalog-sync.service';
import { PermissionResolverService } from './permission-resolver.service';
@Global()
@Module({
providers: [PermissionResolverService, PermissionCatalogSyncService],
exports: [PermissionResolverService],
})
export class PermissionsModule {}
@@ -6,7 +6,6 @@ import { AuthenticatedUser } from '../types/authenticated-user';
interface AccessTokenPayload {
sub: string;
email: string;
role: AuthenticatedUser['role'];
}
@Injectable()
@@ -20,6 +19,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
validate(payload: AccessTokenPayload): AuthenticatedUser {
return { id: payload.sub, email: payload.email, role: payload.role };
return { id: payload.sub, email: payload.email };
}
}
@@ -1,7 +1,4 @@
import { Role } from '../../../generated/prisma';
export interface AuthenticatedUser {
id: string;
email: string;
role: Role;
}
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { AutomationService } from './automation.service';
@@ -9,16 +8,17 @@ import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
@ApiTags('automation-rules')
@ApiBearerAuth()
@Roles(Role.ADMIN)
@Controller('automation-rules')
export class AutomationController {
constructor(private readonly automationService: AutomationService) {}
@RequirePermission('automation:read')
@Get()
findAll() {
return this.automationService.findAll();
}
@RequirePermission('automation:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -8,8 +8,7 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
@@ -22,17 +21,19 @@ import { BottlingService } from './bottling.service';
export class BottlingController {
constructor(private readonly bottlingService: BottlingService) {}
@RequirePermission('bottling:read')
@Get()
findAll(@Query('honeyBatchId') honeyBatchId?: string) {
return this.bottlingService.findAll({ honeyBatchId });
}
@RequirePermission('bottling:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.bottlingService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('bottling:create')
@Post()
create(
@Body() dto: CreateBottlingBatchDto,
@@ -41,7 +42,7 @@ export class BottlingController {
return this.bottlingService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('bottling:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,5 +1,6 @@
import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CalendarService } from './calendar.service';
@ApiTags('calendar')
@@ -8,6 +9,7 @@ import { CalendarService } from './calendar.service';
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
@RequirePermission('calendar:read')
@Get()
getEvents(
@Query('from') from: string,
@@ -8,8 +8,8 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ColonyStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { ColonyStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateColonyDto } from './dto/create-colony.dto';
@@ -25,6 +25,7 @@ import { ColoniesService } from './colonies.service';
export class ColoniesController {
constructor(private readonly coloniesService: ColoniesService) {}
@RequirePermission('colonies:read')
@Get()
findAll(
@Query('status') status?: ColonyStatus,
@@ -33,17 +34,19 @@ export class ColoniesController {
return this.coloniesService.findAll({ status, apiaryId });
}
@RequirePermission('colonies:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.coloniesService.findById(id);
}
@RequirePermission('colonies:read')
@Get(':id/history')
getHistory(@Param('id') id: string) {
return this.coloniesService.getHistory(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:create')
@Post()
create(
@Body() dto: CreateColonyDto,
@@ -52,7 +55,7 @@ export class ColoniesController {
return this.coloniesService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -62,7 +65,7 @@ export class ColoniesController {
return this.coloniesService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:manage-lifecycle')
@Post(':id/move')
move(
@Param('id') id: string,
@@ -72,7 +75,7 @@ export class ColoniesController {
return this.coloniesService.move(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:manage-lifecycle')
@Post(':id/split')
split(
@Param('id') id: string,
@@ -82,7 +85,7 @@ export class ColoniesController {
return this.coloniesService.split(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:manage-lifecycle')
@Post(':id/merge')
merge(
@Param('id') id: string,
@@ -92,13 +95,13 @@ export class ColoniesController {
return this.coloniesService.merge(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:manage-lifecycle')
@Post(':id/dissolve')
dissolve(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.coloniesService.dissolve(id, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('colonies:deactivate')
@Post(':id/reactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.coloniesService.reactivate(id, actor.id);
@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { DashboardService } from './dashboard.service';
@ApiTags('dashboard')
@@ -8,11 +9,13 @@ import { DashboardService } from './dashboard.service';
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@RequirePermission('dashboard:read')
@Get('summary')
summary() {
return this.dashboardService.summary();
}
@RequirePermission('dashboard:read')
@Get('alerts')
alerts() {
return this.dashboardService.alerts();
@@ -15,8 +15,8 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { DocumentEntityType, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { DocumentEntityType } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { isAllowedDocumentMimeType } from './document-mime-types';
@@ -35,6 +35,7 @@ function sanitizeFilenameHeader(fileName: string): string {
export class DocumentsController {
constructor(private readonly documentsService: DocumentsService) {}
@RequirePermission('documents:read')
@Get()
findAll(
@Query('entityType') entityType: string,
@@ -56,7 +57,7 @@ export class DocumentsController {
);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('documents:upload')
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -90,6 +91,7 @@ export class DocumentsController {
);
}
@RequirePermission('documents:read')
@Get(':id/download')
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { buffer, fileName, mimeType } =
@@ -102,7 +104,7 @@ export class DocumentsController {
res.send(buffer);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('documents:delete')
@Delete(':id')
@HttpCode(204)
delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
@@ -8,8 +8,8 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { EquipmentCondition, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { EquipmentCondition } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateEquipmentDto } from './dto/create-equipment.dto';
@@ -22,17 +22,19 @@ import { EquipmentService } from './equipment.service';
export class EquipmentController {
constructor(private readonly equipmentService: EquipmentService) {}
@RequirePermission('equipment:read')
@Get()
findAll(@Query('condition') condition?: EquipmentCondition) {
return this.equipmentService.findAll({ condition });
}
@RequirePermission('equipment:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.equipmentService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('equipment:create')
@Post()
create(
@Body() dto: CreateEquipmentDto,
@@ -41,7 +43,7 @@ export class EquipmentController {
return this.equipmentService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('equipment:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,6 +1,7 @@
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { ExportService } from './export.service';
@ApiTags('export')
@@ -9,6 +10,7 @@ import { ExportService } from './export.service';
export class ExportController {
constructor(private readonly exportService: ExportService) {}
@RequirePermission('export:read')
@Get('colonies.csv')
async colonies(@Res() res: Response): Promise<void> {
const csv = await this.exportService.exportColoniesCsv();
@@ -17,6 +19,7 @@ export class ExportController {
res.send(csv);
}
@RequirePermission('export:read')
@Get('honey-batches.csv')
async honeyBatches(@Res() res: Response): Promise<void> {
const csv = await this.exportService.exportHoneyBatchesCsv();
@@ -28,6 +31,7 @@ export class ExportController {
res.send(csv);
}
@RequirePermission('export:read')
@Get(':entity.json')
async entityJson(
@Param('entity') entity: string,
@@ -42,6 +46,7 @@ export class ExportController {
res.send(JSON.stringify(data, null, 2));
}
@RequirePermission('export:read')
@Get(':report.pdf')
async reportPdf(
@Param('report') report: string,
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateExtractionDto } from './dto/create-extraction.dto';
@@ -14,17 +13,19 @@ import { ExtractionsService } from './extractions.service';
export class ExtractionsController {
constructor(private readonly extractionsService: ExtractionsService) {}
@RequirePermission('extractions:read')
@Get()
findAll() {
return this.extractionsService.findAll();
}
@RequirePermission('extractions:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.extractionsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('extractions:create')
@Post()
create(
@Body() dto: CreateExtractionDto,
@@ -33,7 +34,7 @@ export class ExtractionsController {
return this.extractionsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('extractions:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,5 +1,6 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { FeedingsService } from './feedings.service';
@ApiTags('feedings')
@@ -8,11 +9,13 @@ import { FeedingsService } from './feedings.service';
export class ColonyFeedingsController {
constructor(private readonly feedingsService: FeedingsService) {}
@RequirePermission('feedings:read')
@Get(':id/feedings')
findByColony(@Param('id') id: string) {
return this.feedingsService.findByColony(id);
}
@RequirePermission('feedings:read')
@Get(':id/feedings/summary')
summaryByColony(@Param('id') id: string) {
return this.feedingsService.summaryByColony(id);
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateFeedingDto } from './dto/create-feeding.dto';
@@ -14,17 +13,19 @@ import { FeedingsService } from './feedings.service';
export class FeedingsController {
constructor(private readonly feedingsService: FeedingsService) {}
@RequirePermission('feedings:read')
@Get()
findAll() {
return this.feedingsService.findAll();
}
@RequirePermission('feedings:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.feedingsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('feedings:create')
@Post()
create(
@Body() dto: CreateFeedingDto,
@@ -33,7 +34,7 @@ export class FeedingsController {
return this.feedingsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('feedings:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHarvestDto } from './dto/create-harvest.dto';
@@ -14,17 +13,19 @@ import { HarvestsService } from './harvests.service';
export class HarvestsController {
constructor(private readonly harvestsService: HarvestsService) {}
@RequirePermission('harvests:read')
@Get()
findAll() {
return this.harvestsService.findAll();
}
@RequirePermission('harvests:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.harvestsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('harvests:create')
@Post()
create(
@Body() dto: CreateHarvestDto,
@@ -33,7 +34,7 @@ export class HarvestsController {
return this.harvestsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('harvests:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,6 +1,7 @@
import {
Controller,
Get,
Logger,
ServiceUnavailableException,
VERSION_NEUTRAL,
} from '@nestjs/common';
@@ -8,10 +9,29 @@ import { ApiTags } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service';
import { Public } from '../auth/decorators/public.decorator';
const READINESS_QUERY_TIMEOUT_MS = 5000;
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: NodeJS.Timeout;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error('Readiness query timed out')),
ms,
);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timer!);
}
}
@ApiTags('health')
@Public()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
private readonly logger = new Logger(HealthController.name);
constructor(private readonly prisma: PrismaService) {}
@Get()
@@ -22,9 +42,13 @@ export class HealthController {
@Get('ready')
async readiness(): Promise<{ status: 'ok' }> {
try {
await this.prisma.$queryRaw`SELECT 1`;
await withTimeout(
this.prisma.$queryRaw`SELECT 1`,
READINESS_QUERY_TIMEOUT_MS,
);
return { status: 'ok' };
} catch {
} catch (error: unknown) {
this.logger.error('Readiness check failed', error);
throw new ServiceUnavailableException('Database not reachable');
}
}
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { HoneyBatchStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { HoneyBatchStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
@@ -15,27 +15,31 @@ import { HoneyBatchesService } from './honey-batches.service';
export class HoneyBatchesController {
constructor(private readonly honeyBatchesService: HoneyBatchesService) {}
@RequirePermission('honey-batches:read')
@Get()
findAll(@Query('status') status?: HoneyBatchStatus) {
return this.honeyBatchesService.findAll({ status });
}
@RequirePermission('honey-batches:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.honeyBatchesService.findById(id);
}
@RequirePermission('honey-batches:read')
@Get(':id/traceability')
traceability(@Param('id') id: string) {
return this.honeyBatchesService.traceability(id);
}
@RequirePermission('honey-batches:read')
@Get(':id/relocations')
findRelocations(@Param('id') id: string) {
return this.honeyBatchesService.findRelocations(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('honey-batches:relocate')
@Post(':id/relocate')
relocate(
@Param('id') id: string,
@@ -45,7 +49,7 @@ export class HoneyBatchesController {
return this.honeyBatchesService.relocate(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('honey-batches:create')
@Post()
create(
@Body() dto: CreateHoneyBatchDto,
@@ -54,7 +58,7 @@ export class HoneyBatchesController {
return this.honeyBatchesService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('honey-batches:measure')
@Post(':id/measurements')
addMeasurement(
@Param('id') id: string,
@@ -8,8 +8,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ImportService } from './import.service';
@@ -18,11 +17,11 @@ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
@ApiTags('import')
@ApiBearerAuth()
@Roles(Role.ADMIN)
@Controller('import')
export class ImportController {
constructor(private readonly importService: ImportService) {}
@RequirePermission('import:execute')
@Post(':entity')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
@@ -1,5 +1,6 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { InspectionsService } from './inspections.service';
@ApiTags('inspections')
@@ -8,6 +9,7 @@ import { InspectionsService } from './inspections.service';
export class ColonyInspectionsController {
constructor(private readonly inspectionsService: InspectionsService) {}
@RequirePermission('inspections:read')
@Get(':id/inspections')
findByColony(@Param('id') id: string) {
return this.inspectionsService.findByColony(id);
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateInspectionDto } from './dto/create-inspection.dto';
@@ -15,17 +14,19 @@ import { InspectionsService } from './inspections.service';
export class InspectionsController {
constructor(private readonly inspectionsService: InspectionsService) {}
@RequirePermission('inspections:read')
@Get()
findAll() {
return this.inspectionsService.findAll();
}
@RequirePermission('inspections:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.inspectionsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('inspections:create')
@Post()
create(
@Body() dto: CreateInspectionDto,
@@ -34,7 +35,7 @@ export class InspectionsController {
return this.inspectionsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('inspections:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -44,7 +45,7 @@ export class InspectionsController {
return this.inspectionsService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('tasks:create')
@Post(':id/create-task')
createTask(
@Param('id') id: string,
+15 -1
View File
@@ -4,8 +4,22 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
import { configureApp } from './app.config';
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
function assertRequiredEnvVars(): void {
const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]);
if (missing.length > 0) {
throw new Error(
`Missing required environment variable(s): ${missing.join(', ')}`,
);
}
}
async function bootstrap() {
assertRequiredEnvVars();
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();
configureApp(app);
@@ -18,6 +32,6 @@ async function bootstrap() {
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/v1/docs', app, swaggerDocument);
await app.listen(process.env.PORT ?? 3000);
await app.listen(process.env.PORT ?? 3001);
}
void bootstrap();
@@ -8,8 +8,8 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { ProductStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { ProductStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateProductDto } from './dto/create-product.dto';
@@ -22,17 +22,19 @@ import { ProductsService } from './products.service';
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@RequirePermission('products:read')
@Get()
findAll(@Query('status') status?: ProductStatus) {
return this.productsService.findAll({ status });
}
@RequirePermission('products:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('products:create')
@Post()
create(
@Body() dto: CreateProductDto,
@@ -41,7 +43,7 @@ export class ProductsController {
return this.productsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('products:update')
@Patch(':id')
update(
@Param('id') id: string,
+3
View File
@@ -1,6 +1,7 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { QrService } from './qr.service';
@ApiTags('qr')
@@ -9,6 +10,7 @@ import { QrService } from './qr.service';
export class QrController {
constructor(private readonly qrService: QrService) {}
@RequirePermission('qr:read')
@Get(':entityType/:id.svg')
async svg(
@Param('entityType') entityType: string,
@@ -20,6 +22,7 @@ export class QrController {
res.send(svg);
}
@RequirePermission('qr:read')
@Get(':entityType/:id/link')
async link(
@Param('entityType') entityType: string,
+1 -1
View File
@@ -61,7 +61,7 @@ describe('QrService', () => {
it('resolveUrl returns the same deep-link that generateSvg encodes, for NFC writing', async () => {
prisma.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
const url = await service.resolveUrl('colonies', 'colony-1');
expect(url).toBe('http://localhost:3000/inspections/new?colonyId=colony-1');
expect(url).toBe('http://localhost:3000/colonies/colony-1');
});
it('resolveUrl rejects an unsupported entity type', async () => {
+1 -1
View File
@@ -55,7 +55,7 @@ export class QrService {
const colony = await this.prisma.colony.findUnique({
where: { id },
});
return colony ? `/inspections/new?colonyId=${id}` : null;
return colony ? `/colonies/${id}` : null;
}
case 'apiaries': {
const apiary = await this.prisma.apiary.findUnique({ where: { id } });
@@ -8,8 +8,8 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { QueenStatus, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { QueenStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateQueenDto } from './dto/create-queen.dto';
@@ -24,33 +24,37 @@ import { QueensService } from './queens.service';
export class QueensController {
constructor(private readonly queensService: QueensService) {}
@RequirePermission('queens:read')
@Get()
findAll(@Query('status') status?: QueenStatus) {
return this.queensService.findAll({ status });
}
@RequirePermission('queens:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.queensService.findById(id);
}
@RequirePermission('queens:read')
@Get(':id/history')
getHistory(@Param('id') id: string) {
return this.queensService.getHistory(id);
}
@RequirePermission('queens:read')
@Get(':id/lineage')
getLineage(@Param('id') id: string) {
return this.queensService.getLineage(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('queens:create')
@Post()
create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) {
return this.queensService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('queens:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -60,7 +64,7 @@ export class QueensController {
return this.queensService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('queens:assign')
@Post(':id/assign-colony')
assignColony(
@Param('id') id: string,
@@ -70,12 +74,13 @@ export class QueensController {
return this.queensService.assignColony(id, dto, actor.id);
}
@RequirePermission('queens:read')
@Get(':id/evaluations')
getEvaluations(@Param('id') id: string) {
return this.queensService.getEvaluations(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('queens:evaluate')
@Post(':id/evaluations')
addEvaluation(
@Param('id') id: string,
@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
import {
PERMISSION_CATALOG,
PermissionKey,
} from '../../auth/permissions/permission-catalog';
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
export class CreateRoleDto {
@ApiProperty({ description: 'Eindeutiger Schlüssel, z.B. INSPECTOR' })
@IsString()
@MinLength(2)
@MaxLength(40)
@Matches(/^[A-Z][A-Z0-9_]*$/, {
message: 'key muss GROSS_MIT_UNTERSTRICHEN sein',
})
key!: string;
@ApiProperty()
@IsString()
@MinLength(2)
@MaxLength(100)
name!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiProperty({ enum: CATALOG_KEYS, isArray: true })
@IsArray()
@IsIn(CATALOG_KEYS, { each: true })
permissionKeys!: PermissionKey[];
}
@@ -0,0 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
IsIn,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import {
PERMISSION_CATALOG,
PermissionKey,
} from '../../auth/permissions/permission-catalog';
const CATALOG_KEYS = PERMISSION_CATALOG.map((entry) => entry.key);
export class UpdateRoleDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(100)
name?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@ApiPropertyOptional({ enum: CATALOG_KEYS, isArray: true })
@IsOptional()
@IsArray()
@IsIn(CATALOG_KEYS, { each: true })
permissionKeys?: PermissionKey[];
}
@@ -0,0 +1,62 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { PERMISSION_CATALOG } from '../auth/permissions/permission-catalog';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
import { RolesService } from './roles.service';
@ApiTags('roles')
@ApiBearerAuth()
@Controller('roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get('permission-catalog')
@RequirePermission('roles:read')
getPermissionCatalog() {
return PERMISSION_CATALOG;
}
@Get()
@RequirePermission('roles:read')
findAll() {
return this.rolesService.findAll();
}
@Get(':id')
@RequirePermission('roles:read')
findOne(@Param('id') id: string) {
return this.rolesService.findSummaryById(id);
}
@Post()
@RequirePermission('roles:create')
create(@Body() dto: CreateRoleDto, @CurrentUser() actor: AuthenticatedUser) {
return this.rolesService.create(dto, actor.id);
}
@Patch(':id')
@RequirePermission('roles:update')
update(
@Param('id') id: string,
@Body() dto: UpdateRoleDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.rolesService.update(id, dto, actor.id);
}
@Post(':id/deactivate')
@RequirePermission('roles:deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.rolesService.deactivate(id, actor.id);
}
@Post(':id/reactivate')
@RequirePermission('roles:deactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.rolesService.reactivate(id, actor.id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
@Module({
controllers: [RolesController],
providers: [RolesService],
exports: [RolesService],
})
export class RolesModule {}
+225
View File
@@ -0,0 +1,225 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
export interface RoleSummary {
id: string;
key: string;
name: string;
description: string | null;
isSystem: boolean;
status: string;
userCount: number;
permissionKeys: string[];
createdAt: Date;
updatedAt: Date;
deactivatedAt: Date | null;
}
const ROLE_INCLUDE = {
permissions: { select: { permission: { select: { key: true } } } },
_count: { select: { users: true } },
} satisfies Prisma.RoleInclude;
type RoleWithRelations = Prisma.RoleGetPayload<{
include: typeof ROLE_INCLUDE;
}>;
@Injectable()
export class RolesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
private toSummary(role: RoleWithRelations): RoleSummary {
return {
id: role.id,
key: role.key,
name: role.name,
description: role.description,
isSystem: role.isSystem,
status: role.status,
userCount: role._count.users,
permissionKeys: role.permissions.map((grant) => grant.permission.key),
createdAt: role.createdAt,
updatedAt: role.updatedAt,
deactivatedAt: role.deactivatedAt,
};
}
async findAll(): Promise<RoleSummary[]> {
const roles = await this.prisma.role.findMany({
include: ROLE_INCLUDE,
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
return roles.map((role) => this.toSummary(role));
}
async findById(id: string): Promise<RoleWithRelations> {
const role = await this.prisma.role.findUnique({
where: { id },
include: ROLE_INCLUDE,
});
if (!role) {
throw new NotFoundException('Role not found');
}
return role;
}
async findSummaryById(id: string): Promise<RoleSummary> {
return this.toSummary(await this.findById(id));
}
async create(
dto: CreateRoleDto,
actingUserId?: string,
): Promise<RoleSummary> {
const existing = await this.prisma.role.findUnique({
where: { key: dto.key },
});
if (existing) {
throw new ConflictException('Role key already in use');
}
const role = await this.prisma.$transaction(async (tx) => {
const created = await tx.role.create({
data: {
key: dto.key,
name: dto.name,
description: dto.description,
},
});
await this.replacePermissions(tx, created.id, dto.permissionKeys);
return tx.role.findUniqueOrThrow({
where: { id: created.id },
include: ROLE_INCLUDE,
});
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: role.id,
action: 'CREATE',
userId: actingUserId,
afterData: summary,
});
return summary;
}
async update(
id: string,
dto: UpdateRoleDto,
actingUserId?: string,
): Promise<RoleSummary> {
const before = await this.findById(id);
if (before.isSystem) {
throw new ForbiddenException('System roles cannot be edited');
}
const role = await this.prisma.$transaction(async (tx) => {
await tx.role.update({
where: { id },
data: { name: dto.name, description: dto.description },
});
if (dto.permissionKeys) {
await this.replacePermissions(tx, id, dto.permissionKeys);
}
return tx.role.findUniqueOrThrow({
where: { id },
include: ROLE_INCLUDE,
});
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: summary,
});
return summary;
}
async deactivate(id: string, actingUserId?: string): Promise<RoleSummary> {
const before = await this.findById(id);
if (before.isSystem) {
throw new ForbiddenException('System roles cannot be deactivated');
}
if (before._count.users > 0) {
throw new ConflictException(
'Role still has users assigned; reassign them before deactivating',
);
}
const role = await this.prisma.role.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
include: ROLE_INCLUDE,
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: summary,
});
return summary;
}
async reactivate(id: string, actingUserId?: string): Promise<RoleSummary> {
const before = await this.findById(id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Role is already active');
}
const role = await this.prisma.role.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
include: ROLE_INCLUDE,
});
this.permissionResolver.invalidateAll();
const summary = this.toSummary(role);
await this.auditLog.record({
entityType: 'Role',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: this.toSummary(before),
afterData: summary,
});
return summary;
}
private async replacePermissions(
tx: Prisma.TransactionClient,
roleId: string,
permissionKeys: string[],
): Promise<void> {
const permissions = await tx.permission.findMany({
where: { key: { in: permissionKeys } },
select: { id: true },
});
await tx.rolePermission.deleteMany({ where: { roleId } });
if (permissions.length > 0) {
await tx.rolePermission.createMany({
data: permissions.map((permission) => ({
roleId,
permissionId: permission.id,
})),
});
}
}
}
@@ -1,5 +1,6 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { SearchService } from './search.service';
@ApiTags('search')
@@ -8,6 +9,7 @@ import { SearchService } from './search.service';
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@RequirePermission('search:read')
@Get()
search(@Query('q') q?: string) {
return this.searchService.search(q ?? '');
@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { StatisticsService } from './statistics.service';
@ApiTags('statistics')
@@ -8,31 +9,37 @@ import { StatisticsService } from './statistics.service';
export class StatisticsController {
constructor(private readonly statisticsService: StatisticsService) {}
@RequirePermission('statistics:read')
@Get('colonies')
colonies() {
return this.statisticsService.colonies();
}
@RequirePermission('statistics:read')
@Get('queens')
queens() {
return this.statisticsService.queens();
}
@RequirePermission('statistics:read')
@Get('apiaries')
apiaries() {
return this.statisticsService.apiaries();
}
@RequirePermission('statistics:read')
@Get('varroa')
varroa() {
return this.statisticsService.varroa();
}
@RequirePermission('statistics:read')
@Get('honey')
honey() {
return this.statisticsService.honey();
}
@RequirePermission('statistics:read')
@Get('feeding')
feeding() {
return this.statisticsService.feeding();
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateStorageContainerDto } from './dto/create-storage-container.dto';
@@ -15,12 +14,13 @@ import { StorageService } from './storage.service';
export class StorageController {
constructor(private readonly storageService: StorageService) {}
@RequirePermission('storage:read')
@Get('storage-containers')
findAllContainers() {
return this.storageService.findAllContainers();
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('storage:create')
@Post('storage-containers')
createContainer(
@Body() dto: CreateStorageContainerDto,
@@ -29,17 +29,19 @@ export class StorageController {
return this.storageService.createContainer(dto, actor.id);
}
@RequirePermission('storage:read')
@Get('stock-items')
findAllStockItems() {
return this.storageService.findAllStockItems();
}
@RequirePermission('storage:read')
@Get('stock-items/:id')
findStockItem(@Param('id') id: string) {
return this.storageService.findStockItemById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('storage:create')
@Post('stock-items')
createStockItem(
@Body() dto: CreateStockItemDto,
@@ -48,7 +50,7 @@ export class StorageController {
return this.storageService.createStockItem(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('storage:move')
@Post('stock-items/:id/movements')
addMovement(
@Param('id') id: string,
@@ -1,5 +1,6 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { SwarmControlService } from './swarm-control.service';
@ApiTags('swarm-control-checks')
@@ -8,6 +9,7 @@ import { SwarmControlService } from './swarm-control.service';
export class ColonySwarmControlChecksController {
constructor(private readonly swarmControlService: SwarmControlService) {}
@RequirePermission('swarm-control:read')
@Get(':id/swarm-control-checks')
findByColony(@Param('id') id: string) {
return this.swarmControlService.findByColony(id);
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
@@ -14,17 +13,19 @@ import { SwarmControlService } from './swarm-control.service';
export class SwarmControlController {
constructor(private readonly swarmControlService: SwarmControlService) {}
@RequirePermission('swarm-control:read')
@Get()
findAll() {
return this.swarmControlService.findAll();
}
@RequirePermission('swarm-control:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.swarmControlService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('swarm-control:create')
@Post()
create(
@Body() dto: CreateSwarmControlCheckDto,
@@ -33,7 +34,7 @@ export class SwarmControlController {
return this.swarmControlService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('swarm-control:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTaskTemplateDto } from './dto/create-task-template.dto';
@@ -10,21 +9,23 @@ import { TaskTemplatesService } from './task-templates.service';
@ApiTags('task-templates')
@ApiBearerAuth()
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Controller('task-templates')
export class TaskTemplatesController {
constructor(private readonly taskTemplatesService: TaskTemplatesService) {}
@RequirePermission('task-templates:read')
@Get()
findAll() {
return this.taskTemplatesService.findAll();
}
@RequirePermission('task-templates:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.taskTemplatesService.findById(id);
}
@RequirePermission('task-templates:create')
@Post()
create(
@Body() dto: CreateTaskTemplateDto,
@@ -33,6 +34,7 @@ export class TaskTemplatesController {
return this.taskTemplatesService.create(dto, actor.id);
}
@RequirePermission('task-templates:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -42,6 +44,7 @@ export class TaskTemplatesController {
return this.taskTemplatesService.update(id, dto, actor.id);
}
@RequirePermission('task-templates:deactivate')
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.taskTemplatesService.deactivate(id, actor.id);
@@ -8,8 +8,8 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role, TaskPriority, TaskStatus } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { TaskPriority, TaskStatus } from '../../generated/prisma';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTaskDto } from './dto/create-task.dto';
@@ -22,6 +22,7 @@ import { TasksService } from './tasks.service';
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@RequirePermission('tasks:read')
@Get()
findAll(
@Query('status') status?: TaskStatus,
@@ -30,18 +31,19 @@ export class TasksController {
return this.tasksService.findAll({ status, priority });
}
@RequirePermission('tasks:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.tasksService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('tasks:create')
@Post()
create(@Body() dto: CreateTaskDto, @CurrentUser() actor: AuthenticatedUser) {
return this.tasksService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('tasks:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -51,7 +53,7 @@ export class TasksController {
return this.tasksService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@RequirePermission('tasks:complete')
@Post(':id/complete')
complete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.tasksService.complete(id, actor.id);
@@ -1,5 +1,6 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { TreatmentsService } from './treatments.service';
@ApiTags('treatments')
@@ -8,6 +9,7 @@ import { TreatmentsService } from './treatments.service';
export class ColonyTreatmentsController {
constructor(private readonly treatmentsService: TreatmentsService) {}
@RequirePermission('treatments:read')
@Get(':id/treatments')
findByColony(@Param('id') id: string) {
return this.treatmentsService.findByColony(id);
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTreatmentDto } from './dto/create-treatment.dto';
@@ -14,17 +13,19 @@ import { TreatmentsService } from './treatments.service';
export class TreatmentsController {
constructor(private readonly treatmentsService: TreatmentsService) {}
@RequirePermission('treatments:read')
@Get()
findAll() {
return this.treatmentsService.findAll();
}
@RequirePermission('treatments:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.treatmentsService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('treatments:create')
@Post()
create(
@Body() dto: CreateTreatmentDto,
@@ -33,7 +34,7 @@ export class TreatmentsController {
return this.treatmentsService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('treatments:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -1,6 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator';
import { Role } from '../../../generated/prisma';
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@ApiProperty()
@@ -16,7 +15,8 @@ export class CreateUserDto {
@IsString()
name!: string;
@ApiProperty({ enum: Role })
@IsEnum(Role)
role!: Role;
@ApiProperty({ description: 'Id of an active Role' })
@IsString()
@MinLength(1)
roleId!: string;
}
@@ -1,6 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { Role } from '../../../generated/prisma';
import { IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateUserDto {
@ApiPropertyOptional()
@@ -8,8 +7,9 @@ export class UpdateUserDto {
@IsString()
name?: string;
@ApiPropertyOptional({ enum: Role })
@ApiPropertyOptional({ description: 'Id of an active Role' })
@IsOptional()
@IsEnum(Role)
role?: Role;
@IsString()
@MinLength(1)
roleId?: string;
}
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateUserDto } from './dto/create-user.dto';
@@ -11,25 +10,28 @@ import { UsersService } from './users.service';
@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
@Roles(Role.ADMIN)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@RequirePermission('users:read')
@Get()
findAll() {
return this.usersService.findAll();
}
@RequirePermission('users:create')
@Post()
create(@Body() dto: CreateUserDto, @CurrentUser() actor: AuthenticatedUser) {
return this.usersService.create(dto, actor.id);
}
@RequirePermission('users:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findSummaryById(id);
}
@RequirePermission('users:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -39,11 +41,13 @@ export class UsersController {
return this.usersService.update(id, dto, actor.id);
}
@RequirePermission('users:deactivate')
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.usersService.deactivate(id, actor.id);
}
@RequirePermission('users:deactivate')
@Post(':id/activate')
activate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.usersService.activate(id, actor.id);
+146 -6
View File
@@ -2,6 +2,7 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
describe('UsersService', () => {
let usersService: UsersService;
@@ -11,21 +12,54 @@ describe('UsersService', () => {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
count: jest.Mock;
};
role: {
findUnique: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
let permissionResolver: {
invalidateUser: jest.Mock;
invalidateAll: jest.Mock;
};
const staffRole = {
id: 'role-staff',
key: 'STAFF',
name: 'Mitarbeiter',
status: 'ACTIVE',
isSystem: false,
};
const adminRole = {
id: 'role-admin',
key: 'ADMIN',
name: 'Administrator',
status: 'ACTIVE',
isSystem: true,
};
const existingUser = {
id: 'user-1',
email: '[email protected]',
passwordHash: 'hashed',
name: 'Existing User',
role: 'STAFF',
roleId: staffRole.id,
role: staffRole,
isActive: true,
createdAt: new Date(),
deactivatedAt: null,
};
const adminUser = {
...existingUser,
id: 'user-admin',
email: '[email protected]',
roleId: adminRole.id,
role: adminRole,
};
beforeEach(() => {
prisma = {
user: {
@@ -33,12 +67,21 @@ describe('UsersService', () => {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
role: {
findUnique: jest.fn().mockResolvedValue(staffRole),
},
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
invalidateUser: jest.fn(),
invalidateAll: jest.fn(),
};
usersService = new UsersService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService,
);
});
@@ -52,7 +95,9 @@ describe('UsersService', () => {
id: existingUser.id,
email: existingUser.email,
name: existingUser.name,
role: existingUser.role,
roleId: staffRole.id,
roleKey: staffRole.key,
roleName: staffRole.name,
isActive: existingUser.isActive,
createdAt: existingUser.createdAt,
deactivatedAt: existingUser.deactivatedAt,
@@ -75,7 +120,36 @@ describe('UsersService', () => {
email: existingUser.email,
password: 'password123',
name: 'New',
role: 'STAFF',
roleId: staffRole.id,
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('create throws NotFoundException when the role does not exist', async () => {
prisma.user.findUnique.mockResolvedValue(null);
prisma.role.findUnique.mockResolvedValue(null);
await expect(
usersService.create({
email: '[email protected]',
password: 'password123',
name: 'New User',
roleId: 'missing-role',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('create throws ConflictException when the role is not active', async () => {
prisma.user.findUnique.mockResolvedValue(null);
prisma.role.findUnique.mockResolvedValue({
...staffRole,
status: 'INACTIVE',
});
await expect(
usersService.create({
email: '[email protected]',
password: 'password123',
name: 'New User',
roleId: staffRole.id,
}),
).rejects.toBeInstanceOf(ConflictException);
});
@@ -92,7 +166,7 @@ describe('UsersService', () => {
email: '[email protected]',
password: 'password123',
name: 'New User',
role: 'STAFF',
roleId: staffRole.id,
});
expect(result.id).toBe('user-2');
@@ -106,7 +180,7 @@ describe('UsersService', () => {
);
});
it('deactivate sets isActive to false and records an audit entry', async () => {
it('deactivate sets isActive to false, invalidates the permission cache, and records an audit entry', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.user.update.mockResolvedValue({
...existingUser,
@@ -118,11 +192,39 @@ describe('UsersService', () => {
expect(result.isActive).toBe(false);
expect(result.deactivatedAt).not.toBeNull();
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
existingUser.id,
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'User', action: 'DEACTIVATE' }),
);
});
it('deactivate throws ConflictException when deactivating the last active user of a system role', async () => {
prisma.user.findUnique.mockResolvedValue(adminUser);
prisma.user.count.mockResolvedValue(1);
await expect(usersService.deactivate(adminUser.id)).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(permissionResolver.invalidateUser).not.toHaveBeenCalled();
});
it('deactivate succeeds for a system-role user when another active user shares that role', async () => {
prisma.user.findUnique.mockResolvedValue(adminUser);
prisma.user.count.mockResolvedValue(2);
prisma.user.update.mockResolvedValue({
...adminUser,
isActive: false,
deactivatedAt: new Date(),
});
const result = await usersService.deactivate(adminUser.id);
expect(result.isActive).toBe(false);
});
it('activate throws ConflictException when already active', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
await expect(usersService.activate(existingUser.id)).rejects.toBeInstanceOf(
@@ -130,7 +232,7 @@ describe('UsersService', () => {
);
});
it('activate sets isActive to true and clears deactivatedAt', async () => {
it('activate sets isActive to true, invalidates the permission cache, and clears deactivatedAt', async () => {
prisma.user.findUnique.mockResolvedValue({
...existingUser,
isActive: false,
@@ -146,8 +248,46 @@ describe('UsersService', () => {
expect(result.isActive).toBe(true);
expect(result.deactivatedAt).toBeNull();
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
existingUser.id,
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'User', action: 'UPDATE' }),
);
});
it('update invalidates the permission cache when the role changes', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.user.update.mockResolvedValue({
...existingUser,
roleId: adminRole.id,
role: adminRole,
});
await usersService.update(existingUser.id, { roleId: adminRole.id });
expect(permissionResolver.invalidateUser).toHaveBeenCalledWith(
existingUser.id,
);
});
it('update does not touch the permission cache when the role is unchanged', async () => {
prisma.user.findUnique.mockResolvedValue(existingUser);
prisma.user.update.mockResolvedValue({ ...existingUser, name: 'Renamed' });
await usersService.update(existingUser.id, { name: 'Renamed' });
expect(permissionResolver.invalidateUser).not.toHaveBeenCalled();
});
it('update throws ConflictException when reassigning the last active user away from a system role', async () => {
prisma.user.findUnique.mockResolvedValue(adminUser);
prisma.role.findUnique.mockResolvedValue(staffRole);
prisma.user.count.mockResolvedValue(1);
await expect(
usersService.update(adminUser.id, { roleId: staffRole.id }),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+96 -11
View File
@@ -6,17 +6,26 @@ import {
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { User } from '../../generated/prisma';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { Prisma } from '../../generated/prisma';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
const BCRYPT_SALT_ROUNDS = 12;
const USER_WITH_ROLE = {
include: { role: true },
} satisfies { include: Prisma.UserInclude };
type UserWithRole = Prisma.UserGetPayload<typeof USER_WITH_ROLE>;
export interface UserSummary {
id: string;
email: string;
name: string;
role: string;
roleId: string;
roleKey: string;
roleName: string;
isActive: boolean;
createdAt: Date;
deactivatedAt: Date | null;
@@ -27,22 +36,72 @@ export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
private toSummary(user: User): UserSummary {
const { id, email, name, role, isActive, createdAt, deactivatedAt } = user;
return { id, email, name, role, isActive, createdAt, deactivatedAt };
private toSummary(user: UserWithRole): UserSummary {
return {
id: user.id,
email: user.email,
name: user.name,
roleId: user.roleId,
roleKey: user.role.key,
roleName: user.role.name,
isActive: user.isActive,
createdAt: user.createdAt,
deactivatedAt: user.deactivatedAt,
};
}
private async assertActiveRole(roleId: string): Promise<void> {
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
if (!role) {
throw new NotFoundException('Role not found');
}
if (role.status !== 'ACTIVE') {
throw new ConflictException('Role is not active');
}
}
/**
* Blocks removing (by deactivation or role reassignment) the last active
* user still holding a system role (ADMIN). Without this, an operator
* could lock the app out of user/role management entirely, with no
* self-service recovery: prisma/seed.ts skips seeding once a user with
* its configured email already exists, so it cannot restore access to a
* merely demoted/deactivated account.
*/
private async assertNotLastSystemRoleUser(userId: string): Promise<void> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { roleId: true, role: { select: { isSystem: true } } },
});
if (!user || !user.role.isSystem) {
return;
}
const activeCount = await this.prisma.user.count({
where: { roleId: user.roleId, isActive: true },
});
if (activeCount <= 1) {
throw new ConflictException(
'Cannot remove the last active user holding a system role',
);
}
}
async findAll(): Promise<UserSummary[]> {
const users = await this.prisma.user.findMany({
...USER_WITH_ROLE,
orderBy: { createdAt: 'asc' },
});
return users.map((user) => this.toSummary(user));
}
async findById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } });
async findById(id: string): Promise<UserWithRole> {
const user = await this.prisma.user.findUnique({
where: { id },
...USER_WITH_ROLE,
});
if (!user) {
throw new NotFoundException('User not found');
}
@@ -53,8 +112,8 @@ export class UsersService {
return this.toSummary(await this.findById(id));
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
async findByEmail(email: string): Promise<UserWithRole | null> {
return this.prisma.user.findUnique({ where: { email }, ...USER_WITH_ROLE });
}
async create(
@@ -65,9 +124,16 @@ export class UsersService {
if (existing) {
throw new ConflictException('Email already in use');
}
await this.assertActiveRole(dto.roleId);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.create({
data: { email: dto.email, passwordHash, name: dto.name, role: dto.role },
data: {
email: dto.email,
passwordHash,
name: dto.name,
roleId: dto.roleId,
},
...USER_WITH_ROLE,
});
await this.auditLog.record({
entityType: 'User',
@@ -85,10 +151,24 @@ export class UsersService {
actingUserId?: string,
): Promise<UserSummary> {
const before = await this.findById(id);
const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId;
if (dto.roleId) {
await this.assertActiveRole(dto.roleId);
}
if (roleChanged) {
await this.assertNotLastSystemRoleUser(id);
}
const user = await this.prisma.user.update({
where: { id },
data: { ...dto },
data: {
name: dto.name,
roleId: dto.roleId,
},
...USER_WITH_ROLE,
});
if (roleChanged) {
this.permissionResolver.invalidateUser(id);
}
await this.auditLog.record({
entityType: 'User',
entityId: id,
@@ -102,10 +182,13 @@ export class UsersService {
async deactivate(id: string, actingUserId?: string): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertNotLastSystemRoleUser(id);
const user = await this.prisma.user.update({
where: { id },
data: { isActive: false, deactivatedAt: new Date() },
...USER_WITH_ROLE,
});
this.permissionResolver.invalidateUser(id);
await this.auditLog.record({
entityType: 'User',
entityId: id,
@@ -125,7 +208,9 @@ export class UsersService {
const user = await this.prisma.user.update({
where: { id },
data: { isActive: true, deactivatedAt: null },
...USER_WITH_ROLE,
});
this.permissionResolver.invalidateUser(id);
await this.auditLog.record({
entityType: 'User',
entityId: id,
@@ -1,5 +1,6 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { VarroaService } from './varroa.service';
@ApiTags('varroa-measurements')
@@ -8,6 +9,7 @@ import { VarroaService } from './varroa.service';
export class ColonyVarroaMeasurementsController {
constructor(private readonly varroaService: VarroaService) {}
@RequirePermission('varroa:read')
@Get(':id/varroa-measurements')
findByColony(@Param('id') id: string) {
return this.varroaService.findByColony(id);
@@ -1,7 +1,6 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
@@ -14,17 +13,19 @@ import { VarroaService } from './varroa.service';
export class VarroaController {
constructor(private readonly varroaService: VarroaService) {}
@RequirePermission('varroa:read')
@Get()
findAll() {
return this.varroaService.findAll();
}
@RequirePermission('varroa:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.varroaService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('varroa:create')
@Post()
create(
@Body() dto: CreateVarroaMeasurementDto,
@@ -33,7 +34,7 @@ export class VarroaController {
return this.varroaService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@RequirePermission('varroa:update')
@Patch(':id')
update(
@Param('id') id: string,
@@ -0,0 +1,23 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { WeatherService } from './weather.service';
@ApiTags('weather')
@ApiBearerAuth()
@Controller('apiaries')
export class WeatherController {
constructor(private readonly weatherService: WeatherService) {}
@RequirePermission('apiaries:read')
@Get(':id/weather')
getWeather(@Param('id') id: string) {
return this.weatherService.getWeather(id);
}
@RequirePermission('apiaries:read')
@Get(':id/trachtprognose')
getTrachtprognose(@Param('id') id: string) {
return this.weatherService.getTrachtprognose(id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { WeatherController } from './weather.controller';
import { WeatherService } from './weather.service';
@Module({
controllers: [WeatherController],
providers: [WeatherService],
})
export class WeatherModule {}
@@ -0,0 +1,232 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { WeatherService } from './weather.service';
import { PrismaService } from '../prisma/prisma.service';
function openMeteoResponse(
days: {
date: string;
tempMax: number | null;
tempMin: number | null;
precipitation: number | null;
}[],
): unknown {
return {
daily: {
time: days.map((day) => day.date),
temperature_2m_max: days.map((day) => day.tempMax),
temperature_2m_min: days.map((day) => day.tempMin),
precipitation_sum: days.map((day) => day.precipitation),
},
};
}
describe('WeatherService', () => {
let weatherService: WeatherService;
let prisma: {
apiary: { findUnique: jest.Mock };
harvest: { findMany: jest.Mock };
};
let fetchMock: jest.SpiedFunction<typeof fetch>;
beforeEach(() => {
prisma = {
apiary: { findUnique: jest.fn() },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
};
weatherService = new WeatherService(prisma as unknown as PrismaService);
fetchMock = jest.spyOn(global, 'fetch');
});
afterEach(() => {
fetchMock.mockRestore();
});
describe('getWeather', () => {
it('throws NotFoundException when the apiary does not exist', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(weatherService.getWeather('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('throws BadRequestException when the apiary has no coordinates', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: null,
longitude: null,
});
await expect(
weatherService.getWeather('apiary-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('returns daily weather mapped from the Open-Meteo response', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify(
openMeteoResponse([
{
date: '2026-06-01',
tempMax: 22,
tempMin: 12,
precipitation: 0,
},
]),
),
{ status: 200 },
),
);
const result = await weatherService.getWeather('apiary-1');
expect(result.daily).toEqual([
{ date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 },
]);
const [url] = fetchMock.mock.calls[0] as [URL];
expect(url.toString()).toContain('latitude=52.5');
expect(url.toString()).toContain('longitude=13.4');
});
it('throws BadRequestException when Open-Meteo responds with an error status', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(new Response('', { status: 500 }));
await expect(
weatherService.getWeather('apiary-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('throws BadRequestException instead of hanging when the request times out or the network fails', async () => {
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockRejectedValue(
new DOMException('The operation was aborted', 'TimeoutError'),
);
await expect(
weatherService.getWeather('apiary-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('getTrachtprognose', () => {
const realDate = Date;
function mockToday(isoDate: string) {
const fixed = new realDate(isoDate);
jest
.spyOn(global, 'Date')
.mockImplementation((...args: unknown[]) =>
args.length ? new realDate(...(args as [string])) : fixed,
);
}
afterEach(() => {
jest.restoreAllMocks();
});
it('returns KEINE_TRACHT outside the bloom season', async () => {
mockToday('2026-01-15');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('KEINE_TRACHT');
expect(result.season).toBe('Winterruhe');
});
it('returns GUT for warm, dry weather during bloom season', async () => {
mockToday('2026-06-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
const days = Array.from({ length: 21 }, (_, index) => ({
date: `2026-06-${String(index + 1).padStart(2, '0')}`,
tempMax: 24,
tempMin: 14,
precipitation: 0,
}));
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('GUT');
expect(result.season).toBe('Sommertracht');
});
it('returns SCHWACH for cold, wet weather during bloom season', async () => {
mockToday('2026-04-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
const days = Array.from({ length: 21 }, (_, index) => ({
date: `2026-04-${String(index + 1).padStart(2, '0')}`,
tempMax: 8,
tempMin: 2,
precipitation: 10,
}));
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('SCHWACH');
expect(result.season).toBe('Frühtracht');
});
it('returns UNBEKANNT during bloom season when there is no usable temperature data', async () => {
mockToday('2026-06-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.rating).toBe('UNBEKANNT');
expect(result.season).toBe('Sommertracht');
});
it('includes the historical average yield when past harvests exist for the same month', async () => {
mockToday('2026-06-10');
prisma.apiary.findUnique.mockResolvedValue({
latitude: 52.5,
longitude: 13.4,
});
prisma.harvest.findMany.mockResolvedValue([
{ date: new realDate('2025-06-15'), netWeight: 20 },
{ date: new realDate('2024-06-20'), netWeight: 30 },
{ date: new realDate('2025-09-01'), netWeight: 100 },
]);
fetchMock.mockResolvedValue(
new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }),
);
const result = await weatherService.getTrachtprognose('apiary-1');
expect(result.historicalAverageYieldKg).toBe(25);
});
});
});
@@ -0,0 +1,224 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const PAST_DAYS = 14;
const FORECAST_DAYS = 7;
const RECENT_WINDOW_DAYS = 7;
const FETCH_TIMEOUT_MS = 8_000;
const GOOD_TEMP_THRESHOLD_C = 18;
const GOOD_PRECIPITATION_THRESHOLD_MM = 15;
const POOR_TEMP_THRESHOLD_C = 12;
const POOR_PRECIPITATION_THRESHOLD_MM = 30;
export interface DailyWeather {
date: string;
tempMaxC: number | null;
tempMinC: number | null;
precipitationMm: number | null;
}
export interface ApiaryWeather {
daily: DailyWeather[];
}
export type TrachtprognoseRating =
'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT' | 'UNBEKANNT';
export interface Trachtprognose {
rating: TrachtprognoseRating;
season: string;
reasoning: string[];
historicalAverageYieldKg: number | null;
}
interface OpenMeteoDaily {
time: string[];
temperature_2m_max: (number | null)[];
temperature_2m_min: (number | null)[];
precipitation_sum: (number | null)[];
}
interface OpenMeteoResponse {
daily?: OpenMeteoDaily;
}
@Injectable()
export class WeatherService {
constructor(private readonly prisma: PrismaService) {}
async getWeather(apiaryId: string): Promise<ApiaryWeather> {
const { latitude, longitude } = await this.getCoordinates(apiaryId);
return { daily: await this.fetchDailyWeather(latitude, longitude) };
}
async getTrachtprognose(apiaryId: string): Promise<Trachtprognose> {
const { latitude, longitude } = await this.getCoordinates(apiaryId);
const daily = await this.fetchDailyWeather(latitude, longitude);
const today = new Date().toISOString().slice(0, 10);
const recentDays = daily
.filter((day) => day.date <= today)
.slice(-RECENT_WINDOW_DAYS);
const historicalAverageYieldKg =
await this.getHistoricalAverageYield(apiaryId);
return this.computeTrachtprognose(recentDays, historicalAverageYieldKg);
}
private async getCoordinates(
apiaryId: string,
): Promise<{ latitude: number; longitude: number }> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
select: { latitude: true, longitude: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
if (apiary.latitude === null || apiary.longitude === null) {
throw new BadRequestException(
'Apiary has no coordinates; weather data requires latitude and longitude',
);
}
return { latitude: apiary.latitude, longitude: apiary.longitude };
}
private async fetchDailyWeather(
latitude: number,
longitude: number,
): Promise<DailyWeather[]> {
const url = new URL(OPEN_METEO_FORECAST_URL);
url.searchParams.set('latitude', String(latitude));
url.searchParams.set('longitude', String(longitude));
url.searchParams.set(
'daily',
'temperature_2m_max,temperature_2m_min,precipitation_sum',
);
url.searchParams.set('past_days', String(PAST_DAYS));
url.searchParams.set('forecast_days', String(FORECAST_DAYS));
url.searchParams.set('timezone', 'auto');
let response: Response;
try {
response = await fetch(url, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch {
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
}
if (!response.ok) {
throw new BadRequestException('Wetterdaten konnten nicht geladen werden');
}
const body = (await response.json()) as OpenMeteoResponse;
if (!body.daily) {
return [];
}
const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } =
body.daily;
return time.map((date, index) => ({
date,
tempMaxC: temperature_2m_max[index] ?? null,
tempMinC: temperature_2m_min[index] ?? null,
precipitationMm: precipitation_sum[index] ?? null,
}));
}
private getSeason(month: number): { season: string; inBloom: boolean } {
if (month >= 3 && month <= 5) {
return { season: 'Frühtracht', inBloom: true };
}
if (month >= 6 && month <= 7) {
return { season: 'Sommertracht', inBloom: true };
}
if (month === 8) {
return { season: 'Spättracht', inBloom: true };
}
return { season: 'Winterruhe', inBloom: false };
}
private async getHistoricalAverageYield(
apiaryId: string,
): Promise<number | null> {
const currentMonth = new Date().getMonth() + 1;
const harvests = await this.prisma.harvest.findMany({
where: { apiaryId },
select: { date: true, netWeight: true },
});
const sameMonthYields = harvests
.filter((harvest) => harvest.date.getMonth() + 1 === currentMonth)
.map((harvest) => harvest.netWeight)
.filter((weight): weight is number => weight !== null);
if (sameMonthYields.length === 0) {
return null;
}
const total = sameMonthYields.reduce((sum, weight) => sum + weight, 0);
return Math.round((total / sameMonthYields.length) * 10) / 10;
}
private computeTrachtprognose(
recentDays: DailyWeather[],
historicalAverageYieldKg: number | null,
): Trachtprognose {
const { season, inBloom } = this.getSeason(new Date().getMonth() + 1);
const reasoning: string[] = [];
if (!inBloom) {
reasoning.push(`Aktuell keine Haupttrachtperiode (${season}).`);
return {
rating: 'KEINE_TRACHT',
season,
reasoning,
historicalAverageYieldKg,
};
}
const validTemps = recentDays
.map((day) => day.tempMaxC)
.filter((value): value is number => value !== null);
const avgTempMax = validTemps.length
? validTemps.reduce((sum, value) => sum + value, 0) / validTemps.length
: null;
const totalPrecipitation = recentDays.reduce(
(sum, day) => sum + (day.precipitationMm ?? 0),
0,
);
let rating: TrachtprognoseRating = 'UNBEKANNT';
if (avgTempMax === null) {
reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.');
} else if (
avgTempMax >= GOOD_TEMP_THRESHOLD_C &&
totalPrecipitation < GOOD_PRECIPITATION_THRESHOLD_MM
) {
rating = 'GUT';
reasoning.push(
`Warmes, trockenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) begünstigt Bienenflug.`,
);
} else if (
avgTempMax < POOR_TEMP_THRESHOLD_C ||
totalPrecipitation > POOR_PRECIPITATION_THRESHOLD_MM
) {
rating = 'SCHWACH';
reasoning.push(
`Kühles oder nasses Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) schränkt Bienenflug ein.`,
);
} else {
rating = 'MAESSIG';
reasoning.push(
`Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`,
);
}
reasoning.push(`Saison: ${season}.`);
if (historicalAverageYieldKg !== null) {
reasoning.push(
`Historischer Ernteschnitt für diesen Zeitraum an diesem Standort: ${historicalAverageYieldKg} kg.`,
);
}
return { rating, season, reasoning, historicalAverageYieldKg };
}
}
@@ -8,8 +8,7 @@ import {
Post,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateWebhookDto } from './dto/create-webhook.dto';
@@ -17,16 +16,17 @@ import { WebhooksService } from './webhooks.service';
@ApiTags('webhooks')
@ApiBearerAuth()
@Roles(Role.ADMIN)
@Controller('webhooks')
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}
@RequirePermission('webhooks:read')
@Get()
findAll() {
return this.webhooksService.findAll();
}
@RequirePermission('webhooks:create')
@Post()
create(
@Body() dto: CreateWebhookDto,
@@ -35,6 +35,7 @@ export class WebhooksController {
return this.webhooksService.create(dto, actor.id);
}
@RequirePermission('webhooks:delete')
@Delete(':id')
@HttpCode(204)
delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
+2 -2
View File
@@ -34,7 +34,7 @@ describe('Apiaries (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
@@ -43,7 +43,7 @@ describe('Apiaries (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
+7 -2
View File
@@ -30,7 +30,12 @@ describe('Audit log (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: { email: adminEmail, passwordHash, name: 'Admin', role: 'ADMIN' },
data: {
email: adminEmail,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
const beekeeper = await prisma.user.create({
@@ -38,7 +43,7 @@ describe('Audit log (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
@@ -20,7 +20,12 @@ describe('Auth login throttling (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, passwordHash, name: 'Throttle E2E User', role: 'STAFF' },
data: {
email,
passwordHash,
name: 'Throttle E2E User',
role: { connect: { key: 'STAFF' } },
},
});
userId = user.id;
});
+6 -1
View File
@@ -19,7 +19,12 @@ describe('Auth (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { email, passwordHash, name: 'Auth E2E User', role: 'ADMIN' },
data: {
email,
passwordHash,
name: 'Auth E2E User',
role: { connect: { key: 'ADMIN' } },
},
});
userId = user.id;
});
@@ -0,0 +1,91 @@
import { Test } from '@nestjs/testing';
import {
DiscoveryModule,
DiscoveryService,
MetadataScanner,
Reflector,
} from '@nestjs/core';
import { PATH_METADATA } from '@nestjs/common/constants';
import { AppModule } from '../src/app.module';
import { IS_PUBLIC_KEY } from '../src/auth/decorators/public.decorator';
import { REQUIRE_PERMISSION_KEY } from '../src/auth/decorators/require-permission.decorator';
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
// Routes intentionally left without a role/permission check: they act on the
// caller's own data (notifications) or are the auth bootstrap endpoint that
// every authenticated user must be able to reach regardless of permissions.
const ALLOWLIST = new Set([
'NotificationsController.findAll',
'NotificationsController.markRead',
'AuthController.me',
]);
describe('Authorization coverage (e2e)', () => {
it('every non-public route requires a catalog-valid permission', async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, DiscoveryModule],
}).compile();
const discovery = moduleRef.get(DiscoveryService);
const reflector = moduleRef.get(Reflector);
const scanner = moduleRef.get(MetadataScanner);
const catalogKeys = new Set<string>(
PERMISSION_CATALOG.map((entry) => entry.key),
);
const uncoveredRoutes: string[] = [];
const invalidPermissionKeys: string[] = [];
for (const wrapper of discovery.getControllers()) {
const instance = wrapper.instance as object | undefined;
if (!instance) {
continue;
}
const constructor = instance.constructor;
const controllerName = constructor.name;
const prototype = Object.getPrototypeOf(instance) as object;
const isPublicClass =
reflector.get<boolean>(IS_PUBLIC_KEY, constructor) === true;
for (const methodName of scanner.getAllMethodNames(prototype)) {
const handler = (prototype as Record<string, () => unknown>)[
methodName
];
const isRouteHandler =
Reflect.getMetadata(PATH_METADATA, handler) !== undefined;
if (!isRouteHandler) {
continue;
}
const routeKey = `${controllerName}.${methodName}`;
const isPublicMethod =
reflector.get<boolean>(IS_PUBLIC_KEY, handler) === true;
if (isPublicClass || isPublicMethod || ALLOWLIST.has(routeKey)) {
continue;
}
const permissions = reflector.getAllAndOverride<string[]>(
REQUIRE_PERMISSION_KEY,
[handler, constructor],
);
const hasPermissions =
Array.isArray(permissions) && permissions.length > 0;
if (!hasPermissions) {
uncoveredRoutes.push(routeKey);
}
if (hasPermissions) {
for (const key of permissions) {
if (!catalogKeys.has(key)) {
invalidPermissionKeys.push(`${routeKey} -> ${key}`);
}
}
}
}
}
expect(uncoveredRoutes).toEqual([]);
expect(invalidPermissionKeys).toEqual([]);
await moduleRef.close();
});
});
+2 -2
View File
@@ -38,7 +38,7 @@ describe('Automation rules (e2e)', () => {
email: adminEmail,
passwordHash,
name: 'Automation E2E Admin',
role: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
@@ -47,7 +47,7 @@ describe('Automation rules (e2e)', () => {
email: staffEmail,
passwordHash,
name: 'Automation E2E Staff',
role: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
+1 -1
View File
@@ -41,7 +41,7 @@ describe('Bottling (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
+1 -1
View File
@@ -32,7 +32,7 @@ describe('Calendar (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
+7 -2
View File
@@ -46,12 +46,17 @@ describe('Colonies (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
+1 -1
View File
@@ -37,7 +37,7 @@ describe('Dashboard (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
+2 -2
View File
@@ -41,7 +41,7 @@ describe('Documents (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Documents E2E Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
@@ -50,7 +50,7 @@ describe('Documents (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Documents E2E Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
+7 -2
View File
@@ -34,12 +34,17 @@ describe('Equipment (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+1 -1
View File
@@ -29,7 +29,7 @@ describe('Export (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
+7 -2
View File
@@ -36,12 +36,17 @@ describe('Extractions (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+7 -2
View File
@@ -36,7 +36,12 @@ describe('Feedings (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
const readonly = await prisma.user.create({
@@ -44,7 +49,7 @@ describe('Feedings (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
+7 -2
View File
@@ -33,7 +33,12 @@ describe('Harvests (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
const readonly = await prisma.user.create({
@@ -41,7 +46,7 @@ describe('Harvests (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
@@ -53,12 +53,17 @@ describe('Honey batches (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+2 -2
View File
@@ -49,7 +49,7 @@ describe('Import (e2e)', () => {
email: adminEmail,
passwordHash,
name: 'Import E2E Admin',
role: 'ADMIN',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
@@ -58,7 +58,7 @@ describe('Import (e2e)', () => {
email: staffEmail,
passwordHash,
name: 'Import E2E Staff',
role: 'STAFF',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
+7 -2
View File
@@ -37,7 +37,12 @@ describe('Inspections (e2e)', () => {
const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
const readonly = await prisma.user.create({
@@ -45,7 +50,7 @@ describe('Inspections (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
@@ -34,12 +34,17 @@ describe('Notifications (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+7 -2
View File
@@ -34,12 +34,17 @@ describe('Products (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
data: {
email: staffEmail,
passwordHash,
name: 'Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken;
+1 -1
View File
@@ -42,7 +42,7 @@ describe('Public batch origin page (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
+2 -2
View File
@@ -26,7 +26,7 @@ describe('QR codes (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
@@ -85,6 +85,6 @@ describe('QR codes (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
const body = response.body as { url: string };
expect(body.url).toContain(`colonyId=${colonyId}`);
expect(body.url).toContain(`/colonies/${colonyId}`);
});
});
+1 -1
View File
@@ -33,7 +33,7 @@ describe('Queens (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
+239
View File
@@ -0,0 +1,239 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface RoleBody {
id: string;
key: string;
name: string;
isSystem: boolean;
status: string;
userCount: number;
permissionKeys: string[];
}
interface MeBody {
id: string;
email: string;
roleKey: string;
permissions: string[];
}
describe('Roles (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const adminEmail = `roles-e2e-admin-${Date.now()}@example.com`;
const staffEmail = `roles-e2e-staff-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let adminId: string;
let staffId: string;
let adminToken: string;
let staffToken: string;
let adminRoleId: string;
let staffRoleId: string;
let createdRoleId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
const staffRole = await prisma.role.findUniqueOrThrow({
where: { key: 'STAFF' },
});
adminRoleId = adminRole.id;
staffRoleId = staffRole.id;
const passwordHash = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email: adminEmail,
passwordHash,
name: 'Roles E2E Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
const staff = await prisma.user.create({
data: {
email: staffEmail,
passwordHash,
name: 'Roles E2E Staff',
role: { connect: { key: 'STAFF' } },
},
});
staffId = staff.id;
adminToken = (await login(app, adminEmail, password)).accessToken;
staffToken = (await login(app, staffEmail, password)).accessToken;
});
afterAll(async () => {
if (createdRoleId) {
await prisma.rolePermission.deleteMany({
where: { roleId: createdRoleId },
});
await prisma.role.delete({ where: { id: createdRoleId } });
}
const userIds = [adminId, staffId];
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await app.close();
});
it('rejects a caller without roles:read from listing roles', () => {
return request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${staffToken}`)
.expect(403);
});
it('lets an admin list the seeded system roles', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const roles = response.body as RoleBody[];
const keys = roles.map((role) => role.key);
expect(keys).toEqual(
expect.arrayContaining([
'ADMIN',
'BEEKEEPER',
'STAFF',
'READONLY',
'API_CLIENT',
]),
);
const admin = roles.find((role) => role.key === 'ADMIN');
expect(admin?.isSystem).toBe(true);
expect(admin?.permissionKeys.length).toBe(83);
});
it('exposes the full permission catalog', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/roles/permission-catalog')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect((response.body as unknown[]).length).toBe(83);
});
it('lets an admin create a role with a chosen permission set', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'INSPECTOR_E2E',
name: 'Inspector (e2e)',
description: 'Read-only inspections role for e2e coverage',
permissionKeys: ['inspections:read', 'colonies:read'],
})
.expect(201);
const created = response.body as RoleBody;
createdRoleId = created.id;
expect(created.permissionKeys.sort()).toEqual(
['colonies:read', 'inspections:read'].sort(),
);
});
it('rejects creating a role with a duplicate key', () => {
return request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'INSPECTOR_E2E',
name: 'Duplicate',
permissionKeys: ['colonies:read'],
})
.expect(409);
});
it('rejects an unknown permission key', () => {
return request(app.getHttpServer())
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
key: 'BOGUS_E2E',
name: 'Bogus',
permissionKeys: ['not-a-real-permission'],
})
.expect(400);
});
it('lets an admin update a role permission set', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/roles/${createdRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ permissionKeys: ['tasks:read'] })
.expect(200);
const updated = response.body as RoleBody;
expect(updated.permissionKeys).toEqual(['tasks:read']);
});
it('blocks editing the system ADMIN role', () => {
return request(app.getHttpServer())
.patch(`/api/v1/roles/${adminRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: 'Renamed' })
.expect(403);
});
it('blocks deactivating the system ADMIN role', () => {
return request(app.getHttpServer())
.post(`/api/v1/roles/${adminRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(403);
});
it('blocks deactivating a role that still has users assigned', () => {
return request(app.getHttpServer())
.post(`/api/v1/roles/${staffRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(409);
});
it('deactivates and reactivates an unused custom role', async () => {
const deactivated = await request(app.getHttpServer())
.post(`/api/v1/roles/${createdRoleId}/deactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect((deactivated.body as RoleBody).status).toBe('INACTIVE');
const reactivated = await request(app.getHttpServer())
.post(`/api/v1/roles/${createdRoleId}/reactivate`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
expect((reactivated.body as RoleBody).status).toBe('ACTIVE');
});
it("reflects the caller's role and effective permissions on /auth/me", async () => {
const adminMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const adminProfile = adminMe.body as MeBody;
expect(adminProfile.roleKey).toBe('ADMIN');
expect(adminProfile.permissions.length).toBe(83);
const staffMe = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const staffProfile = staffMe.body as MeBody;
expect(staffProfile.roleKey).toBe('STAFF');
expect(staffProfile.permissions).toEqual(
expect.arrayContaining(['colonies:read', 'tasks:create']),
);
expect(staffProfile.permissions).not.toEqual(
expect.arrayContaining(['storage:create', 'users:read']),
);
});
});
+1 -1
View File
@@ -39,7 +39,7 @@ describe('Search and filters (e2e)', () => {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
role: { connect: { key: 'READONLY' } },
},
});
readonlyId = readonly.id;
+1 -1
View File
@@ -60,7 +60,7 @@ describe('Statistics (e2e)', () => {
email: beekeeperEmail,
passwordHash,
name: 'Beekeeper',
role: 'BEEKEEPER',
role: { connect: { key: 'BEEKEEPER' } },
},
});
beekeeperId = beekeeper.id;
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more