From 8432313fa9d133deaf6e3255709a8738a4e6f2dc Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 31 Aug 2026 18:56:08 +0200 Subject: [PATCH] feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO - Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren - Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze - Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix) - Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt - Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand) - Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten - Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck - Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen) --- ARCHITECTURE.md | 1 + ApiCore-Backend/.env.example | 9 + ApiCore-Backend/package-lock.json | 32 +++ ApiCore-Backend/package.json | 3 +- .../migration.sql | 4 + .../migration.sql | 17 ++ ApiCore-Backend/prisma/schema.prisma | 19 ++ ApiCore-Backend/src/app.module.ts | 2 + ApiCore-Backend/src/auth/auth.controller.ts | 49 +--- ApiCore-Backend/src/auth/auth.module.ts | 12 +- ApiCore-Backend/src/auth/auth.service.spec.ts | 27 ++ ApiCore-Backend/src/auth/auth.service.ts | 20 ++ .../auth/permissions/permission-catalog.ts | 12 + .../src/auth/sso/sso.controller.ts | 158 ++++++++++++ ApiCore-Backend/src/auth/sso/sso.module.ts | 14 ++ .../src/auth/sso/sso.service.spec.ts | 209 ++++++++++++++++ ApiCore-Backend/src/auth/sso/sso.service.ts | 138 ++++++++++ .../src/auth/util/refresh-token-cookie.ts | 36 +++ .../src/automation/automation.service.spec.ts | 104 +++++++- .../src/automation/automation.service.ts | 90 ++++++- .../src/colonies/colonies.module.ts | 2 + .../src/colonies/colonies.service.spec.ts | 5 + .../src/colonies/colonies.service.ts | 3 + .../dto/create-label-template.dto.ts | 38 +++ .../dto/update-label-template.dto.ts | 6 + .../label-templates.controller.ts | 81 ++++++ .../label-templates/label-templates.module.ts | 10 + .../label-templates.service.spec.ts | 175 +++++++++++++ .../label-templates.service.ts | 236 ++++++++++++++++++ .../src/profile/dto/deactivate-account.dto.ts | 8 + .../src/profile/profile.controller.ts | 11 + ApiCore-Backend/src/profile/profile.module.ts | 3 +- .../src/profile/profile.service.spec.ts | 22 ++ .../src/profile/profile.service.ts | 17 ++ ApiCore-Backend/src/storage/storage.module.ts | 2 + .../src/storage/storage.service.spec.ts | 5 + .../src/storage/storage.service.ts | 3 + .../src/swarm-control/swarm-control.module.ts | 2 + .../swarm-control.service.spec.ts | 5 + .../swarm-control/swarm-control.service.ts | 4 + .../test/authorization-coverage.e2e-spec.ts | 1 + ApiCore-Backend/test/jest-e2e.json | 4 +- .../(app)/apiaries/[id]/apiary-edit-card.tsx | 2 +- .../src/app/(app)/apiaries/[id]/page.tsx | 50 ++-- .../src/app/(app)/bottling/[id]/page.tsx | 92 +++---- .../(app)/bottling/[id]/print-labels-card.tsx | 81 ++++++ .../(app)/colonies/[id]/colony-actions.tsx | 2 +- .../(app)/colonies/[id]/colony-edit-card.tsx | 4 +- .../(app)/colonies/[id]/colony-feedings.tsx | 2 +- .../(app)/colonies/[id]/colony-history.tsx | 2 +- .../colonies/[id]/colony-inspections.tsx | 2 +- .../colonies/[id]/colony-swarm-control.tsx | 2 +- .../(app)/colonies/[id]/colony-treatments.tsx | 2 +- .../app/(app)/colonies/[id]/colony-varroa.tsx | 2 +- .../colonies/[id]/inspection-edit-dialog.tsx | 8 +- .../src/app/(app)/colonies/[id]/page.tsx | 26 +- .../src/app/(app)/colonies/new/page.tsx | 2 +- .../[id]/honey-batch-storage-card.tsx | 2 +- .../src/app/(app)/honey-batches/[id]/page.tsx | 220 ++++++++-------- .../src/app/(app)/inspections/new/page.tsx | 8 +- .../app/(app)/profile/danger-zone-section.tsx | 117 +++++++++ .../src/app/(app)/profile/page.spec.tsx | 15 +- .../src/app/(app)/profile/page.tsx | 50 +++- .../src/app/(app)/queens/[id]/page.tsx | 20 +- .../app/(app)/queens/[id]/queen-edit-card.tsx | 2 +- .../(app)/queens/[id]/queen-evaluations.tsx | 2 +- .../app/(app)/queens/[id]/queen-history.tsx | 2 +- .../app/(app)/queens/[id]/queen-lineage.tsx | 2 +- .../(app)/settings/automation-rules/page.tsx | 27 +- .../create-label-template-dialog.tsx | 130 ++++++++++ .../edit-label-template-dialog.tsx | 128 ++++++++++ .../(app)/settings/label-templates/page.tsx | 90 +++++++ ApiCore-Frontend/src/app/(app)/sidebar.tsx | 11 + .../src/app/(auth)/login/page.spec.tsx | 37 ++- .../src/app/(auth)/login/page.tsx | 82 +++++- ApiCore-Frontend/src/lib/auth/api.ts | 33 +++ .../src/lib/auth/auth-context.tsx | 18 +- ApiCore-Frontend/src/lib/automation/types.ts | 5 +- .../src/lib/label-templates/api.ts | 36 +++ .../src/lib/label-templates/types.ts | 41 +++ ApiCore-Frontend/src/lib/profile/api.ts | 7 + deploy/k8s/backend/configmap.example.yaml | 6 + deploy/k8s/backend/secret.example.yaml | 4 + 83 files changed, 2684 insertions(+), 289 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260831152007_add_more_automation_triggers/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20260831153327_add_label_templates/migration.sql create mode 100644 ApiCore-Backend/src/auth/sso/sso.controller.ts create mode 100644 ApiCore-Backend/src/auth/sso/sso.module.ts create mode 100644 ApiCore-Backend/src/auth/sso/sso.service.spec.ts create mode 100644 ApiCore-Backend/src/auth/sso/sso.service.ts create mode 100644 ApiCore-Backend/src/auth/util/refresh-token-cookie.ts create mode 100644 ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts create mode 100644 ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts create mode 100644 ApiCore-Backend/src/label-templates/label-templates.controller.ts create mode 100644 ApiCore-Backend/src/label-templates/label-templates.module.ts create mode 100644 ApiCore-Backend/src/label-templates/label-templates.service.spec.ts create mode 100644 ApiCore-Backend/src/label-templates/label-templates.service.ts create mode 100644 ApiCore-Backend/src/profile/dto/deactivate-account.dto.ts create mode 100644 ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/edit-label-template-dialog.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/label-templates/page.tsx create mode 100644 ApiCore-Frontend/src/lib/label-templates/api.ts create mode 100644 ApiCore-Frontend/src/lib/label-templates/types.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 35fe5ec..e30ec0e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -96,6 +96,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. 7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. +8. **Entra ID SSO ohne Auto-Provisioning**: `POST/GET /api/v1/auth/sso/*` implementiert Authorization Code + PKCE gegen Entra ID über `openid-client`. Der E-Mail-Claim aus dem ID-Token wird ausschließlich gegen einen bereits existierenden, aktiven `User`-Datensatz gematcht – es wird kein neuer Benutzer automatisch angelegt (Just-in-Time-Provisioning), passend zum bestehenden Modell, dass Admins Benutzer manuell anlegen. Eine erfolgreiche SSO-Anmeldung überspringt die App-eigene TOTP-MFA-Abfrage, da der Identity Provider bereits eine eigene (typischerweise stärkere) Authentifizierung durchgesetzt hat. Der PKCE-`code_verifier` wird kurzlebig (5 min) in einem httpOnly-`SameSite=Lax`-Cookie zwischengespeichert statt serverseitig, damit der Flow ohne Sticky-Sessions über mehrere Backend-Replicas hinweg funktioniert; der Rückweg vom Callback zum Frontend nutzt ein kurzlebiges (60 s), zweckgebundenes JWT statt des echten Access-Tokens in der Redirect-URL. `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URI` kommen ausschließlich aus Environment-Variablen; fehlen sie, meldet `GET /api/v1/auth/sso/status` `enabled: false` und die Login-Seite blendet den "Mit Microsoft anmelden"-Button aus, statt beim Start zu scheitern. ## 7. Kubernetes diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index ac7c4fd..e944469 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -19,3 +19,12 @@ COOKIE_SECURE=false # Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes # Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7. DOCUMENTS_STORAGE_PATH="./uploads" + +# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert +# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start). +# OIDC_ISSUER_URL ist die v2.0-Metadaten-URL des Tenants, OIDC_REDIRECT_URI +# muss exakt der in der App-Registrierung hinterlegten Redirect-URI entsprechen. +OIDC_ISSUER_URL="https://login.microsoftonline.com//v2.0" +OIDC_CLIENT_ID="change-me-entra-client-id" +OIDC_CLIENT_SECRET="change-me-entra-client-secret" +OIDC_REDIRECT_URI="http://localhost:3001/api/v1/auth/sso/callback" diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 1f120b4..1b468ab 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -24,6 +24,7 @@ "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", "helmet": "^8.3.0", + "openid-client": "^6.8.4", "otplib": "^13.5.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -8681,6 +8682,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-md5": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", @@ -9648,6 +9658,15 @@ "node": ">=8" } }, + "node_modules/oauth4webapi": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.7.tgz", + "integrity": "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9712,6 +9731,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index a9d41d9..e3a1b43 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -38,6 +38,7 @@ "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", "helmet": "^8.3.0", + "openid-client": "^6.8.4", "otplib": "^13.5.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -90,7 +91,7 @@ "^.+\\.(t|j)s$": "ts-jest" }, "transformIgnorePatterns": [ - "node_modules/(?!(otplib|@otplib|@scure|@noble)/)" + "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)" ], "collectCoverageFrom": [ "**/*.(t|j)s" diff --git a/ApiCore-Backend/prisma/migrations/20260831152007_add_more_automation_triggers/migration.sql b/ApiCore-Backend/prisma/migrations/20260831152007_add_more_automation_triggers/migration.sql new file mode 100644 index 0000000..e4824dd --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260831152007_add_more_automation_triggers/migration.sql @@ -0,0 +1,4 @@ +-- AlterEnum +ALTER TYPE "AutomationTriggerType" ADD VALUE 'SWARM_MOOD_HIGH'; +ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_HEALTH_CRITICAL'; +ALTER TYPE "AutomationTriggerType" ADD VALUE 'STOCK_ITEM_LOW'; diff --git a/ApiCore-Backend/prisma/migrations/20260831153327_add_label_templates/migration.sql b/ApiCore-Backend/prisma/migrations/20260831153327_add_label_templates/migration.sql new file mode 100644 index 0000000..396bad3 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260831153327_add_label_templates/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "LabelTemplate" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "content" TEXT NOT NULL, + "widthMm" DOUBLE PRECISION NOT NULL DEFAULT 90, + "heightMm" DOUBLE PRECISION NOT NULL DEFAULT 35, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LabelTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LabelTemplate_active_idx" ON "LabelTemplate"("active"); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 73aee3e..328fd76 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -853,6 +853,22 @@ model Product { @@index([status]) } +// Etikettenvorlage mit {{Platzhalter}}-Text, wird beim Drucken mit den +// Daten einer Abfuellung befuellt (siehe LabelTemplatesService.renderLabelsPdf). +model LabelTemplate { + id String @id @default(cuid()) + name String + content String + widthMm Float @default(90) + heightMm Float @default(35) + isDefault Boolean @default(false) + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([active]) +} + model BottlingBatch { id String @id @default(cuid()) honeyBatchId String @@ -981,6 +997,9 @@ enum AutomationTriggerType { QUEENLESS_NO_EGGS VARROA_THRESHOLD FEED_STATUS_CRITICAL + SWARM_MOOD_HIGH + COLONY_HEALTH_CRITICAL + STOCK_ITEM_LOW } model AutomationRule { diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 284347b..50c2e02 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -33,6 +33,7 @@ import { StatisticsModule } from './statistics/statistics.module'; import { WebhooksModule } from './webhooks/webhooks.module'; import { DocumentsModule } from './documents/documents.module'; import { AutomationModule } from './automation/automation.module'; +import { LabelTemplatesModule } from './label-templates/label-templates.module'; import { ImportModule } from './import/import.module'; import { PublicModule } from './public/public.module'; import { TaskTemplatesModule } from './task-templates/task-templates.module'; @@ -79,6 +80,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; WebhooksModule, DocumentsModule, AutomationModule, + LabelTemplatesModule, ImportModule, PublicModule, TaskTemplatesModule, diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index dd1bd83..8926486 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -7,7 +7,6 @@ import { Post, Req, Res, - UnauthorizedException, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; @@ -19,49 +18,23 @@ import { LoginDto } from './dto/login.dto'; import { AccessTokenDto } from './dto/access-token.dto'; import { MfaVerifyDto } from './dto/mfa-verify.dto'; import { AuthService, CurrentUserProfile } from './auth.service'; -import { parseDurationToMs } from './util/duration'; import { SENSITIVE_ACTION_THROTTLE } from './util/throttle'; +import { + clearRefreshTokenCookie, + extractRefreshToken, + setRefreshTokenCookie, +} from './util/refresh-token-cookie'; interface MfaRequiredResponse { mfaRequired: true; mfaToken: string; } -const REFRESH_TOKEN_COOKIE = 'refreshToken'; -const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth'; - @ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} - private setRefreshTokenCookie(res: Response, refreshToken: string): void { - res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { - httpOnly: true, - // Explicit opt-out for local HTTP dev only; every real deployment must - // set COOKIE_SECURE=true so the refresh-token cookie is never sent - // over plaintext HTTP. - secure: process.env.COOKIE_SECURE !== 'false', - sameSite: 'lax', - path: REFRESH_TOKEN_COOKIE_PATH, - maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), - }); - } - - private clearRefreshTokenCookie(res: Response): void { - res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH }); - } - - private extractRefreshToken(req: Request): string { - const refreshToken = (req.cookies as Record | undefined)?.[ - REFRESH_TOKEN_COOKIE - ]; - if (!refreshToken) { - throw new UnauthorizedException('Missing refresh token'); - } - return refreshToken; - } - @Public() @Throttle(SENSITIVE_ACTION_THROTTLE) @Post('login') @@ -74,7 +47,7 @@ export class AuthController { if (result.mfaRequired) { return { mfaRequired: true, mfaToken: result.mfaToken }; } - this.setRefreshTokenCookie(res, result.refreshToken); + setRefreshTokenCookie(res, result.refreshToken); return { accessToken: result.accessToken }; } @@ -88,7 +61,7 @@ export class AuthController { ): Promise { const { accessToken, refreshToken } = await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code); - this.setRefreshTokenCookie(res, refreshToken); + setRefreshTokenCookie(res, refreshToken); return { accessToken }; } @@ -101,9 +74,9 @@ export class AuthController { @Res({ passthrough: true }) res: Response, ): Promise { const { accessToken, refreshToken } = await this.authService.refresh( - this.extractRefreshToken(req), + extractRefreshToken(req), ); - this.setRefreshTokenCookie(res, refreshToken); + setRefreshTokenCookie(res, refreshToken); return { accessToken }; } @@ -114,8 +87,8 @@ export class AuthController { @Req() req: Request, @Res({ passthrough: true }) res: Response, ): Promise { - await this.authService.logout(this.extractRefreshToken(req)); - this.clearRefreshTokenCookie(res); + await this.authService.logout(extractRefreshToken(req)); + clearRefreshTokenCookie(res); } @ApiBearerAuth() diff --git a/ApiCore-Backend/src/auth/auth.module.ts b/ApiCore-Backend/src/auth/auth.module.ts index 968932d..45d2f82 100644 --- a/ApiCore-Backend/src/auth/auth.module.ts +++ b/ApiCore-Backend/src/auth/auth.module.ts @@ -6,10 +6,18 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { MfaModule } from './mfa/mfa.module'; +import { SsoModule } from './sso/sso.module'; +import { SsoController } from './sso/sso.controller'; @Module({ - imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule], - controllers: [AuthController], + imports: [ + PassportModule, + JwtModule.register({}), + UsersModule, + MfaModule, + SsoModule, + ], + controllers: [AuthController, SsoController], providers: [AuthService, JwtStrategy], }) export class AuthModule {} diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index cd84bcc..3bdc59d 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -229,6 +229,33 @@ describe('AuthService', () => { }); }); + describe('issueTokensForSsoUser', () => { + it('issues tokens and records LOGIN for an active user', async () => { + usersService.findById.mockResolvedValue(activeUser); + prisma.refreshToken.create.mockResolvedValue({}); + + const result = await authService.issueTokensForSsoUser(activeUser.id); + + expect(result.accessToken).toBe('signed.jwt.token'); + expect(prisma.refreshToken.create).toHaveBeenCalled(); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }), + ); + }); + + it('throws when the user is deactivated', async () => { + usersService.findById.mockResolvedValue({ + ...activeUser, + isActive: false, + }); + + await expect( + authService.issueTokensForSsoUser(activeUser.id), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(prisma.refreshToken.create).not.toHaveBeenCalled(); + }); + }); + describe('refresh', () => { it('throws when the refresh token is unknown', async () => { prisma.refreshToken.findUnique.mockResolvedValue(null); diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index b56075d..3e2a6ad 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -144,6 +144,26 @@ export class AuthService { return { mfaRequired: false, accessToken, refreshToken }; } + /** Issues our own access/refresh token pair for a user already authenticated via SSO. */ + async issueTokensForSsoUser(userId: string): Promise { + const user = await this.usersService.findById(userId); + if (!user.isActive) { + throw new UnauthorizedException('Invalid credentials'); + } + const accessToken = this.signAccessToken({ + id: user.id, + email: user.email, + }); + const refreshToken = await this.issueRefreshToken(user.id); + await this.auditLog.record({ + entityType: 'AuthSession', + entityId: user.id, + action: 'LOGIN', + userId: user.id, + }); + return { accessToken, refreshToken }; + } + async verifyMfaAndLogin(mfaToken: string, code: string): Promise { const { userId } = this.mfaService.verifyChallengeToken(mfaToken); const user = await this.usersService.findById(userId); diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index c6999ee..d4685a7 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -194,6 +194,18 @@ export const PERMISSION_CATALOG = [ ], 1600, ), + ...group( + 'Etiketten', + 'label-templates', + [ + ['read', 'Ansehen'], + ['create', 'Anlegen'], + ['update', 'Bearbeiten'], + ['deactivate', 'Deaktivieren'], + ['print', 'Drucken'], + ], + 1650, + ), ...group( 'Inventar', 'equipment', diff --git a/ApiCore-Backend/src/auth/sso/sso.controller.ts b/ApiCore-Backend/src/auth/sso/sso.controller.ts new file mode 100644 index 0000000..88b344d --- /dev/null +++ b/ApiCore-Backend/src/auth/sso/sso.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Req, + Res, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; +import { Public } from '../decorators/public.decorator'; +import { AuthService } from '../auth.service'; +import { AccessTokenDto } from '../dto/access-token.dto'; +import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle'; +import { setRefreshTokenCookie } from '../util/refresh-token-cookie'; +import { SsoService } from './sso.service'; + +const PKCE_COOKIE = 'oidcPkceVerifier'; +const PKCE_COOKIE_PATH = '/api/v1/auth/sso'; +const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000; + +// Scoped to /complete only - the completion endpoint is the only place that +// ever needs to read it, and it's never sent on the wider /auth/sso/* path. +const SSO_CODE_COOKIE = 'oidcSsoCode'; +const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete'; +const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000; + +interface PkceCookiePayload { + codeVerifier: string; + state: string; +} + +@ApiTags('auth') +@Controller('auth/sso') +export class SsoController { + constructor( + private readonly ssoService: SsoService, + private readonly authService: AuthService, + ) {} + + private frontendLoginUrl(): string { + return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`; + } + + private cookieOptions(path: string, maxAge: number) { + return { + httpOnly: true, + secure: process.env.COOKIE_SECURE !== 'false', + sameSite: 'lax' as const, + path, + maxAge, + }; + } + + @Public() + @Get('status') + status(): { enabled: boolean } { + return { enabled: this.ssoService.isConfigured() }; + } + + @Public() + @Get('login') + async login(@Res() res: Response): Promise { + const { redirectUrl, codeVerifier, state } = + await this.ssoService.buildAuthorizationRequest(); + const payload: PkceCookiePayload = { codeVerifier, state }; + res.cookie( + PKCE_COOKIE, + JSON.stringify(payload), + this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS), + ); + res.redirect(redirectUrl); + } + + @Public() + @Get('callback') + async callback(@Req() req: Request, @Res() res: Response): Promise { + const rawCookie = (req.cookies as Record | undefined)?.[ + PKCE_COOKIE + ]; + res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH }); + + const pkce = this.parsePkceCookie(rawCookie); + if (!pkce) { + res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`); + return; + } + + const callbackUrl = new URL( + req.originalUrl, + `${req.protocol}://${req.get('host')}`, + ); + const result = await this.ssoService.handleCallback( + callbackUrl, + pkce.codeVerifier, + pkce.state, + ); + if (result.status === 'error') { + res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`); + return; + } + + // The one-time completion code never touches the URL (history, server + // logs, Referer headers) - it travels only in an httpOnly cookie scoped + // to the /complete endpoint, which the frontend calls right after. + res.cookie( + SSO_CODE_COOKIE, + result.ssoCode, + this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS), + ); + res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`); + } + + @Public() + @Throttle(SENSITIVE_ACTION_THROTTLE) + @Post('complete') + @HttpCode(HttpStatus.OK) + async complete( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + const ssoCode = (req.cookies as Record | undefined)?.[ + SSO_CODE_COOKIE + ]; + res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH }); + if (!ssoCode) { + throw new UnauthorizedException( + 'SSO-Anmeldung abgelaufen, bitte erneut versuchen', + ); + } + const { userId } = this.ssoService.verifySsoCode(ssoCode); + const { accessToken, refreshToken } = + await this.authService.issueTokensForSsoUser(userId); + setRefreshTokenCookie(res, refreshToken); + return { accessToken }; + } + + private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null { + if (!raw) { + return null; + } + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.codeVerifier !== 'string' || + typeof parsed.state !== 'string' + ) { + return null; + } + return { codeVerifier: parsed.codeVerifier, state: parsed.state }; + } catch { + return null; + } + } +} diff --git a/ApiCore-Backend/src/auth/sso/sso.module.ts b/ApiCore-Backend/src/auth/sso/sso.module.ts new file mode 100644 index 0000000..efe6892 --- /dev/null +++ b/ApiCore-Backend/src/auth/sso/sso.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { UsersModule } from '../../users/users.module'; +import { SsoService } from './sso.service'; + +// SsoController lives in AuthModule (not here) because it also depends on +// AuthService, which only AuthModule provides - keeping the controller here +// would require a circular import between AuthModule and SsoModule. +@Module({ + imports: [JwtModule.register({}), UsersModule], + providers: [SsoService], + exports: [SsoService], +}) +export class SsoModule {} diff --git a/ApiCore-Backend/src/auth/sso/sso.service.spec.ts b/ApiCore-Backend/src/auth/sso/sso.service.spec.ts new file mode 100644 index 0000000..fe99a26 --- /dev/null +++ b/ApiCore-Backend/src/auth/sso/sso.service.spec.ts @@ -0,0 +1,209 @@ +import { JwtService } from '@nestjs/jwt'; +import { + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import * as client from 'openid-client'; +import { SsoService } from './sso.service'; +import { UsersService } from '../../users/users.service'; + +jest.mock('openid-client', () => ({ + discovery: jest.fn(), + randomPKCECodeVerifier: jest.fn(), + calculatePKCECodeChallenge: jest.fn(), + randomState: jest.fn(), + buildAuthorizationUrl: jest.fn(), + authorizationCodeGrant: jest.fn(), +})); + +const mockedClient = client as jest.Mocked; + +describe('SsoService', () => { + let ssoService: SsoService; + let usersService: { findByEmail: jest.Mock }; + let jwtService: { sign: jest.Mock; verify: jest.Mock }; + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.OIDC_ISSUER_URL = + 'https://login.microsoftonline.com/tenant/v2.0'; + process.env.OIDC_CLIENT_ID = 'client-id'; + process.env.OIDC_CLIENT_SECRET = 'client-secret'; + process.env.OIDC_REDIRECT_URI = + 'https://api.example.com/api/v1/auth/sso/callback'; + process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret'; + usersService = { findByEmail: jest.fn() }; + jwtService = { sign: jest.fn(), verify: jest.fn() }; + ssoService = new SsoService( + usersService as unknown as UsersService, + jwtService as unknown as JwtService, + ); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('isConfigured', () => { + it('returns true when all four OIDC variables are set', () => { + expect(ssoService.isConfigured()).toBe(true); + }); + + it('returns false when a variable is missing', () => { + delete process.env.OIDC_CLIENT_SECRET; + expect(ssoService.isConfigured()).toBe(false); + }); + }); + + describe('buildAuthorizationRequest', () => { + it('throws ServiceUnavailableException when SSO is not configured', async () => { + delete process.env.OIDC_ISSUER_URL; + await expect(ssoService.buildAuthorizationRequest()).rejects.toThrow( + ServiceUnavailableException, + ); + }); + + it('discovers the issuer once and returns the authorization URL with a PKCE verifier', async () => { + const config = {} as client.Configuration; + mockedClient.discovery.mockResolvedValue(config); + mockedClient.randomPKCECodeVerifier.mockReturnValue('verifier'); + mockedClient.calculatePKCECodeChallenge.mockResolvedValue('challenge'); + mockedClient.randomState.mockReturnValue('random-state'); + mockedClient.buildAuthorizationUrl.mockReturnValue( + new URL( + 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?x=1', + ), + ); + + const result = await ssoService.buildAuthorizationRequest(); + await ssoService.buildAuthorizationRequest(); + + expect(result.codeVerifier).toBe('verifier'); + expect(result.state).toBe('random-state'); + expect(result.redirectUrl).toContain('authorize'); + expect(mockedClient.discovery).toHaveBeenCalledTimes(1); + expect(mockedClient.buildAuthorizationUrl).toHaveBeenCalledWith( + config, + expect.objectContaining({ + redirect_uri: process.env.OIDC_REDIRECT_URI, + code_challenge: 'challenge', + code_challenge_method: 'S256', + state: 'random-state', + }), + ); + }); + }); + + describe('handleCallback', () => { + beforeEach(() => { + mockedClient.discovery.mockResolvedValue({} as client.Configuration); + }); + + it('returns exchange_failed when the token exchange throws', async () => { + mockedClient.authorizationCodeGrant.mockRejectedValue( + new Error('bad code'), + ); + + const result = await ssoService.handleCallback( + new URL('https://api.example.com/callback?code=x'), + 'verifier', + 'random-state', + ); + + expect(result).toEqual({ status: 'error', reason: 'exchange_failed' }); + }); + + it('returns no_email when the ID token has neither email nor preferred_username', async () => { + mockedClient.authorizationCodeGrant.mockResolvedValue({ + claims: () => ({ sub: 'abc' }), + } as unknown as Awaited< + ReturnType + >); + + const result = await ssoService.handleCallback( + new URL('https://api.example.com/callback?code=x'), + 'verifier', + 'random-state', + ); + + expect(result).toEqual({ status: 'error', reason: 'no_email' }); + }); + + it('returns no_account when no active local user matches the email claim', async () => { + mockedClient.authorizationCodeGrant.mockResolvedValue({ + claims: () => ({ email: 'ghost@example.com' }), + } as unknown as Awaited< + ReturnType + >); + usersService.findByEmail.mockResolvedValue(null); + + const result = await ssoService.handleCallback( + new URL('https://api.example.com/callback?code=x'), + 'verifier', + 'random-state', + ); + + expect(result).toEqual({ status: 'error', reason: 'no_account' }); + }); + + it('signs a short-lived sso-complete token for an active matching user', async () => { + mockedClient.authorizationCodeGrant.mockResolvedValue({ + claims: () => ({ preferred_username: 'imker@example.com' }), + } as unknown as Awaited< + ReturnType + >); + usersService.findByEmail.mockResolvedValue({ + id: 'user-1', + isActive: true, + }); + jwtService.sign.mockReturnValue('signed-sso-code'); + + const result = await ssoService.handleCallback( + new URL('https://api.example.com/callback?code=x'), + 'verifier', + 'random-state', + ); + + expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' }); + expect(mockedClient.authorizationCodeGrant).toHaveBeenCalledWith( + {}, + expect.any(URL), + { pkceCodeVerifier: 'verifier', expectedState: 'random-state' }, + ); + expect(jwtService.sign).toHaveBeenCalledWith( + expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }), + expect.objectContaining({ expiresIn: '60s' }), + ); + }); + }); + + describe('verifySsoCode', () => { + it('returns the userId for a valid sso-complete token', () => { + jwtService.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'sso-complete', + }); + expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' }); + }); + + it('throws UnauthorizedException when verification fails', () => { + jwtService.verify.mockImplementation(() => { + throw new Error('expired'); + }); + expect(() => ssoService.verifySsoCode('token')).toThrow( + UnauthorizedException, + ); + }); + + it('throws UnauthorizedException when the token purpose does not match', () => { + jwtService.verify.mockReturnValue({ + sub: 'user-1', + purpose: 'mfa-challenge', + }); + expect(() => ssoService.verifySsoCode('token')).toThrow( + UnauthorizedException, + ); + }); + }); +}); diff --git a/ApiCore-Backend/src/auth/sso/sso.service.ts b/ApiCore-Backend/src/auth/sso/sso.service.ts new file mode 100644 index 0000000..6b7b84d --- /dev/null +++ b/ApiCore-Backend/src/auth/sso/sso.service.ts @@ -0,0 +1,138 @@ +import { + Injectable, + Logger, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as client from 'openid-client'; +import { UsersService } from '../../users/users.service'; + +const SSO_COMPLETE_TOKEN_TTL = '60s'; +const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete'; + +interface SsoCompleteTokenPayload { + sub: string; + purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE; +} + +export type SsoCallbackResult = + | { status: 'success'; ssoCode: string } + | { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' }; + +@Injectable() +export class SsoService { + private readonly logger = new Logger(SsoService.name); + private config: client.Configuration | undefined; + + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + isConfigured(): boolean { + return Boolean( + process.env.OIDC_ISSUER_URL && + process.env.OIDC_CLIENT_ID && + process.env.OIDC_CLIENT_SECRET && + process.env.OIDC_REDIRECT_URI, + ); + } + + private async getConfig(): Promise { + if (!this.isConfigured()) { + throw new ServiceUnavailableException( + 'Microsoft-Anmeldung ist nicht konfiguriert', + ); + } + if (!this.config) { + this.config = await client.discovery( + new URL(process.env.OIDC_ISSUER_URL as string), + process.env.OIDC_CLIENT_ID as string, + process.env.OIDC_CLIENT_SECRET, + ); + } + return this.config; + } + + async buildAuthorizationRequest(): Promise<{ + redirectUrl: string; + codeVerifier: string; + state: string; + }> { + const config = await this.getConfig(); + const codeVerifier = client.randomPKCECodeVerifier(); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const state = client.randomState(); + const redirectUrl = client.buildAuthorizationUrl(config, { + redirect_uri: process.env.OIDC_REDIRECT_URI as string, + scope: 'openid email profile', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + }); + return { redirectUrl: redirectUrl.href, codeVerifier, state }; + } + + async handleCallback( + callbackUrl: URL, + codeVerifier: string, + state: string, + ): Promise { + const config = await this.getConfig(); + let tokens: Awaited>; + try { + tokens = await client.authorizationCodeGrant(config, callbackUrl, { + pkceCodeVerifier: codeVerifier, + expectedState: state, + }); + } catch (error) { + this.logger.warn( + `SSO callback token exchange failed: ${(error as Error).message}`, + ); + return { status: 'error', reason: 'exchange_failed' }; + } + const claims = tokens.claims(); + const email = + (claims?.email as string | undefined) ?? + (claims?.preferred_username as string | undefined); + if (!email) { + this.logger.warn( + 'SSO callback succeeded but ID token had no email claim', + ); + return { status: 'error', reason: 'no_email' }; + } + const user = await this.usersService.findByEmail(email); + if (!user || !user.isActive) { + this.logger.warn( + `SSO login denied: no active local account for ${email}`, + ); + return { status: 'error', reason: 'no_account' }; + } + const ssoCode = this.jwtService.sign( + { sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE }, + { + secret: process.env.JWT_ACCESS_SECRET, + expiresIn: SSO_COMPLETE_TOKEN_TTL, + }, + ); + return { status: 'success', ssoCode }; + } + + verifySsoCode(ssoCode: string): { userId: string } { + let payload: SsoCompleteTokenPayload; + try { + payload = this.jwtService.verify(ssoCode, { + secret: process.env.JWT_ACCESS_SECRET, + }); + } catch { + throw new UnauthorizedException( + 'SSO-Anmeldung abgelaufen, bitte erneut versuchen', + ); + } + if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) { + throw new UnauthorizedException('Ungültiger Token'); + } + return { userId: payload.sub }; + } +} diff --git a/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts b/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts new file mode 100644 index 0000000..90907c4 --- /dev/null +++ b/ApiCore-Backend/src/auth/util/refresh-token-cookie.ts @@ -0,0 +1,36 @@ +import { UnauthorizedException } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { parseDurationToMs } from './duration'; + +export const REFRESH_TOKEN_COOKIE = 'refreshToken'; +export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth'; + +export function setRefreshTokenCookie( + res: Response, + refreshToken: string, +): void { + res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { + httpOnly: true, + // Explicit opt-out for local HTTP dev only; every real deployment must + // set COOKIE_SECURE=true so the refresh-token cookie is never sent + // over plaintext HTTP. + secure: process.env.COOKIE_SECURE !== 'false', + sameSite: 'lax', + path: REFRESH_TOKEN_COOKIE_PATH, + maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'), + }); +} + +export function clearRefreshTokenCookie(res: Response): void { + res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH }); +} + +export function extractRefreshToken(req: Request): string { + const refreshToken = (req.cookies as Record | undefined)?.[ + REFRESH_TOKEN_COOKIE + ]; + if (!refreshToken) { + throw new UnauthorizedException('Missing refresh token'); + } + return refreshToken; +} diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 4dccc79..57dfa24 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -70,7 +70,7 @@ describe('AutomationService', () => { ); }); - it('findAll seeds the three default rules before returning them', async () => { + it('findAll seeds all default rules before returning them', async () => { prisma.automationRule.findMany.mockResolvedValue([ queenlessRule, varroaRule, @@ -79,7 +79,7 @@ describe('AutomationService', () => { const result = await service.findAll(); - expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(3); + expect(prisma.automationRule.upsert).toHaveBeenCalledTimes(6); expect(result).toHaveLength(3); }); @@ -188,6 +188,106 @@ describe('AutomationService', () => { }); }); + describe('evaluateSwarmControlCheck', () => { + const swarmRule = { + id: 'rule-4', + triggerType: 'SWARM_MOOD_HIGH', + active: true, + config: {}, + actionTemplate: 'Schwarmkontrolle dringend', + }; + + it('creates a task when swarm mood is HIGH', async () => { + prisma.automationRule.findUnique.mockResolvedValue(swarmRule); + + await service.evaluateSwarmControlCheck({ + id: 'check-1', + colonyId: 'colony-1', + swarmMood: 'HIGH', + } as never); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + }); + + it('does not trigger for a low swarm mood', async () => { + prisma.automationRule.findUnique.mockResolvedValue(swarmRule); + + await service.evaluateSwarmControlCheck({ + id: 'check-2', + colonyId: 'colony-1', + swarmMood: 'LOW', + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + }); + + describe('evaluateColonyHealth', () => { + const healthRule = { + id: 'rule-5', + triggerType: 'COLONY_HEALTH_CRITICAL', + active: true, + config: {}, + actionTemplate: 'Kontrolle erforderlich', + }; + + it('creates a task on the transition into CRITICAL', async () => { + prisma.automationRule.findUnique.mockResolvedValue(healthRule); + + await service.evaluateColonyHealth( + { id: 'colony-1', healthStatus: 'AVERAGE' } as never, + { id: 'colony-1', healthStatus: 'CRITICAL' } as never, + ); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + }); + + it('does not re-trigger when already CRITICAL before the update', async () => { + prisma.automationRule.findUnique.mockResolvedValue(healthRule); + + await service.evaluateColonyHealth( + { id: 'colony-1', healthStatus: 'CRITICAL' } as never, + { id: 'colony-1', healthStatus: 'CRITICAL' } as never, + ); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + }); + + describe('evaluateStockItem', () => { + const stockRule = { + id: 'rule-6', + triggerType: 'STOCK_ITEM_LOW', + active: true, + config: {}, + actionTemplate: 'Nachbestellung prüfen', + }; + + it('creates a task when quantity drops below minThreshold', async () => { + prisma.automationRule.findUnique.mockResolvedValue(stockRule); + + await service.evaluateStockItem({ + id: 'item-1', + quantity: 2, + minThreshold: 5, + } as never); + + expect(prisma.task.create).toHaveBeenCalledTimes(1); + }); + + it('does not trigger when minThreshold is not set', async () => { + prisma.automationRule.findUnique.mockResolvedValue(stockRule); + + await service.evaluateStockItem({ + id: 'item-2', + quantity: 2, + minThreshold: null, + } as never); + + expect(prisma.task.create).not.toHaveBeenCalled(); + }); + }); + describe('update', () => { it('records an audit log entry with before/after data', async () => { prisma.automationRule.findUnique.mockResolvedValue(varroaRule); diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index db26d5e..35a752c 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -6,8 +6,12 @@ import { WebhooksService } from '../webhooks/webhooks.service'; import { AutomationRule, AutomationTriggerType, + Colony, Inspection, Prisma, + StockItem, + SwarmControlCheck, + TaskRelatedType, VarroaMeasurement, } from '../../generated/prisma'; import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto'; @@ -31,6 +35,19 @@ const DEFAULT_RULES: Record = { config: {}, actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.', }, + SWARM_MOOD_HIGH: { + config: {}, + actionTemplate: + 'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.', + }, + COLONY_HEALTH_CRITICAL: { + config: {}, + actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.', + }, + STOCK_ITEM_LOW: { + config: {}, + actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.', + }, }; @Injectable() @@ -118,6 +135,7 @@ export class AutomationService { ) { await this.triggerAction( queenlessRule, + 'COLONY', inspection.colonyId, inspection.id, ); @@ -125,7 +143,12 @@ export class AutomationService { const feedRule = ruleByType.get('FEED_STATUS_CRITICAL'); if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') { - await this.triggerAction(feedRule, inspection.colonyId, inspection.id); + await this.triggerAction( + feedRule, + 'COLONY', + inspection.colonyId, + inspection.id, + ); } } @@ -143,13 +166,59 @@ export class AutomationService { const threshold = config?.thresholdMitesPerDay ?? 10; const value = measurement.calculatedValue ?? measurement.miteCount; if (value !== null && value >= threshold) { - await this.triggerAction(rule, measurement.colonyId); + await this.triggerAction(rule, 'COLONY', measurement.colonyId); } } + async evaluateSwarmControlCheck(check: SwarmControlCheck): Promise { + await this.ensureDefaults(); + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'SWARM_MOOD_HIGH' }, + }); + if (!rule || !rule.active) { + return; + } + if (check.swarmMood === 'HIGH') { + await this.triggerAction(rule, 'COLONY', check.colonyId); + } + } + + /** Fires only on the transition into CRITICAL, not on every edit while already critical. */ + async evaluateColonyHealth(before: Colony, after: Colony): Promise { + await this.ensureDefaults(); + if ( + after.healthStatus !== 'CRITICAL' || + before.healthStatus === 'CRITICAL' + ) { + return; + } + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'COLONY_HEALTH_CRITICAL' }, + }); + if (!rule || !rule.active) { + return; + } + await this.triggerAction(rule, 'COLONY', after.id); + } + + async evaluateStockItem(item: StockItem): Promise { + await this.ensureDefaults(); + if (item.minThreshold === null || item.quantity >= item.minThreshold) { + return; + } + const rule = await this.prisma.automationRule.findUnique({ + where: { triggerType: 'STOCK_ITEM_LOW' }, + }); + if (!rule || !rule.active) { + return; + } + await this.triggerAction(rule, 'STORAGE', item.id); + } + private async triggerAction( rule: AutomationRule, - colonyId: string, + relatedType: TaskRelatedType, + relatedId: string, sourceInspectionId?: string, ): Promise { const title = rule.actionTemplate ?? 'Automatisierte Aufgabe'; @@ -157,8 +226,8 @@ export class AutomationService { data: { title, priority: 'HIGH', - relatedType: 'COLONY', - relatedId: colonyId, + relatedType, + relatedId, sourceInspectionId, }, }); @@ -166,14 +235,19 @@ export class AutomationService { await this.notifications.record({ priority: 'WARNUNG', message: title, - relatedType: 'COLONY', - relatedId: colonyId, + relatedType, + relatedId, }); await this.auditLog.record({ entityType: 'AutomationRule', entityId: rule.id, action: 'CREATE', - afterData: { taskId: task.id, colonyId, triggerType: rule.triggerType }, + afterData: { + taskId: task.id, + relatedType, + relatedId, + triggerType: rule.triggerType, + }, }); } } diff --git a/ApiCore-Backend/src/colonies/colonies.module.ts b/ApiCore-Backend/src/colonies/colonies.module.ts index 18cbd42..528c8a6 100644 --- a/ApiCore-Backend/src/colonies/colonies.module.ts +++ b/ApiCore-Backend/src/colonies/colonies.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { ColoniesController } from './colonies.controller'; import { ColoniesService } from './colonies.service'; @Module({ + imports: [AutomationModule], controllers: [ColoniesController], providers: [ColoniesService], exports: [ColoniesService], diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index fbcf78a..5fe154a 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -3,6 +3,7 @@ import { ColoniesService } from './colonies.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { AutomationService } from '../automation/automation.service'; describe('ColoniesService', () => { let service: ColoniesService; @@ -55,10 +56,14 @@ describe('ColoniesService', () => { }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const automation = { + evaluateColonyHealth: jest.fn().mockResolvedValue(undefined), + }; service = new ColoniesService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, + automation as unknown as AutomationService, ); }); diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 08234fd..77538c3 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -6,6 +6,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { AutomationService } from '../automation/automation.service'; import { Colony, ColonyStatus } from '../../generated/prisma'; import { CreateColonyDto } from './dto/create-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto'; @@ -24,6 +25,7 @@ export class ColoniesService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, + private readonly automation: AutomationService, ) {} async findAll(filter: ColonyFilter = {}): Promise { @@ -80,6 +82,7 @@ export class ColoniesService { beforeData: before, afterData: colony, }); + await this.automation.evaluateColonyHealth(before, colony); return colony; } diff --git a/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts new file mode 100644 index 0000000..81133d6 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/dto/create-label-template.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; + +export class CreateLabelTemplateDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiProperty({ + description: + 'Etikettentext mit Platzhaltern: {{batchNumber}} {{honeyType}} {{lotNumber}} {{bottlingDate}} {{bestBeforeDate}} {{jarSize}} {{productName}}', + }) + @IsString() + content!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(10) + widthMm?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + @Min(10) + heightMm?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} diff --git a/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts b/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts new file mode 100644 index 0000000..c143c87 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/dto/update-label-template.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateLabelTemplateDto } from './create-label-template.dto'; + +export class UpdateLabelTemplateDto extends PartialType( + CreateLabelTemplateDto, +) {} diff --git a/ApiCore-Backend/src/label-templates/label-templates.controller.ts b/ApiCore-Backend/src/label-templates/label-templates.controller.ts new file mode 100644 index 0000000..791d7dd --- /dev/null +++ b/ApiCore-Backend/src/label-templates/label-templates.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Res, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +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 { CreateLabelTemplateDto } from './dto/create-label-template.dto'; +import { UpdateLabelTemplateDto } from './dto/update-label-template.dto'; +import { LabelTemplatesService } from './label-templates.service'; + +@ApiTags('label-templates') +@ApiBearerAuth() +@Controller('label-templates') +export class LabelTemplatesController { + constructor(private readonly labelTemplatesService: LabelTemplatesService) {} + + @RequirePermission('label-templates:read') + @Get() + findAll() { + return this.labelTemplatesService.findAll(); + } + + @RequirePermission('label-templates:read') + @Get(':id') + findOne(@Param('id') id: string) { + return this.labelTemplatesService.findById(id); + } + + @RequirePermission('label-templates:create') + @Post() + create( + @Body() dto: CreateLabelTemplateDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.labelTemplatesService.create(dto, actor.id); + } + + @RequirePermission('label-templates:update') + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateLabelTemplateDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.labelTemplatesService.update(id, dto, actor.id); + } + + @RequirePermission('label-templates:deactivate') + @Delete(':id') + deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.labelTemplatesService.deactivate(id, actor.id); + } + + @RequirePermission('label-templates:print') + @Get(':id/print/:bottlingBatchId') + async print( + @Param('id') id: string, + @Param('bottlingBatchId') bottlingBatchId: string, + @Res() res: Response, + ): Promise { + const pdf = await this.labelTemplatesService.renderLabelsPdf( + id, + bottlingBatchId, + ); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="etiketten.pdf"', + ); + res.send(pdf); + } +} diff --git a/ApiCore-Backend/src/label-templates/label-templates.module.ts b/ApiCore-Backend/src/label-templates/label-templates.module.ts new file mode 100644 index 0000000..8e5d414 --- /dev/null +++ b/ApiCore-Backend/src/label-templates/label-templates.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LabelTemplatesController } from './label-templates.controller'; +import { LabelTemplatesService } from './label-templates.service'; + +@Module({ + controllers: [LabelTemplatesController], + providers: [LabelTemplatesService], + exports: [LabelTemplatesService], +}) +export class LabelTemplatesModule {} diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts new file mode 100644 index 0000000..9ec5a3d --- /dev/null +++ b/ApiCore-Backend/src/label-templates/label-templates.service.spec.ts @@ -0,0 +1,175 @@ +import { NotFoundException } from '@nestjs/common'; +import { LabelTemplatesService } from './label-templates.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('LabelTemplatesService', () => { + let service: LabelTemplatesService; + let tx: { + labelTemplate: { + updateMany: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let prisma: { + labelTemplate: { + findMany: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + bottlingBatch: { findUnique: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + + const template = { + id: 'template-1', + name: 'Standard', + content: 'Charge {{batchNumber}} - MHD {{bestBeforeDate}}', + widthMm: 90, + heightMm: 35, + isDefault: false, + active: true, + }; + + beforeEach(() => { + tx = { + labelTemplate: { + updateMany: jest.fn().mockResolvedValue({}), + create: jest.fn(), + update: jest.fn(), + }, + }; + prisma = { + labelTemplate: { + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + bottlingBatch: { findUnique: jest.fn() }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new LabelTemplatesService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + describe('create', () => { + it('clears other defaults first when isDefault is set', async () => { + tx.labelTemplate.create.mockResolvedValue({ + ...template, + isDefault: true, + }); + + await service.create( + { name: 'Standard', content: 'x', isDefault: true }, + 'user-1', + ); + + expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({ + where: {}, + data: { isDefault: false }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'CREATE' }), + ); + }); + + it('does not touch other defaults when isDefault is not set', async () => { + tx.labelTemplate.create.mockResolvedValue(template); + + await service.create({ name: 'Standard', content: 'x' }, 'user-1'); + + expect(tx.labelTemplate.updateMany).not.toHaveBeenCalled(); + }); + }); + + describe('deactivate', () => { + it('sets active and isDefault to false, records DEACTIVATE', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue(template); + prisma.labelTemplate.update.mockResolvedValue({ + ...template, + active: false, + isDefault: false, + }); + + const result = await service.deactivate('template-1', 'user-1'); + + expect(result.active).toBe(false); + expect(prisma.labelTemplate.update).toHaveBeenCalledWith({ + where: { id: 'template-1' }, + data: { active: false, isDefault: false }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ action: 'DEACTIVATE' }), + ); + }); + }); + + describe('renderLabelsPdf', () => { + it('fills placeholders and tiles one label per jar', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue(template); + prisma.bottlingBatch.findUnique.mockResolvedValue({ + id: 'batch-1', + jarSize: 500, + jarCount: 2, + lotNumber: 'L-001', + date: new Date('2026-06-01T00:00:00.000Z'), + bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), + honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, + product: { name: 'Glas 500g' }, + }); + + const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); + + expect(pdf).toBeInstanceOf(Buffer); + expect(pdf.length).toBeGreaterThan(0); + // %PDF header confirms a real PDF document was produced. + expect(pdf.subarray(0, 4).toString()).toBe('%PDF'); + }); + + it('throws NotFoundException when the bottling batch does not exist', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue(template); + prisma.bottlingBatch.findUnique.mockResolvedValue(null); + + await expect( + service.renderLabelsPdf('template-1', 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('embeds a QR code pointing at the Herkunft page when the template uses {{qrCode}}', async () => { + prisma.labelTemplate.findUnique.mockResolvedValue({ + ...template, + content: `${template.content} {{qrCode}}`, + }); + prisma.bottlingBatch.findUnique.mockResolvedValue({ + id: 'batch-1', + jarSize: 500, + jarCount: 1, + lotNumber: 'L-001', + date: new Date('2026-06-01T00:00:00.000Z'), + bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'), + honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' }, + product: { name: 'Glas 500g' }, + }); + + const pdf = await service.renderLabelsPdf('template-1', 'batch-1'); + + expect(pdf.subarray(0, 4).toString()).toBe('%PDF'); + // A PDF with an embedded raster image contains an XObject /Image entry. + expect(pdf.toString('latin1')).toContain('/Image'); + }); + }); +}); diff --git a/ApiCore-Backend/src/label-templates/label-templates.service.ts b/ApiCore-Backend/src/label-templates/label-templates.service.ts new file mode 100644 index 0000000..2d7c1cb --- /dev/null +++ b/ApiCore-Backend/src/label-templates/label-templates.service.ts @@ -0,0 +1,236 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import PDFDocument from 'pdfkit'; +import * as QRCode from 'qrcode'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { LabelTemplate } from '../../generated/prisma'; +import { CreateLabelTemplateDto } from './dto/create-label-template.dto'; +import { UpdateLabelTemplateDto } from './dto/update-label-template.dto'; + +const MM_TO_PT = 72 / 25.4; +const PAGE_MARGIN_MM = 5; +const LABEL_GAP_MM = 2; +const A4_WIDTH_MM = 210; +const A4_HEIGHT_MM = 297; +const QR_CODE_PLACEHOLDER = '{{qrCode}}'; + +function formatDate(date: Date): string { + return date.toLocaleDateString('de-DE'); +} + +function fillPlaceholders( + content: string, + values: Record, +): string { + return content.replace(/\{\{(\w+)\}\}/g, (match, key: string) => + key in values ? values[key] : match, + ); +} + +@Injectable() +export class LabelTemplatesService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + const template = await this.prisma.labelTemplate.findUnique({ + where: { id }, + }); + if (!template) { + throw new NotFoundException('Label template not found'); + } + return template; + } + + async create( + dto: CreateLabelTemplateDto, + actingUserId: string, + ): Promise { + const template = await this.prisma.$transaction(async (tx) => { + if (dto.isDefault) { + await tx.labelTemplate.updateMany({ + where: {}, + data: { isDefault: false }, + }); + } + return tx.labelTemplate.create({ data: { ...dto } }); + }); + await this.auditLog.record({ + entityType: 'LabelTemplate', + entityId: template.id, + action: 'CREATE', + userId: actingUserId, + afterData: template, + }); + return template; + } + + async update( + id: string, + dto: UpdateLabelTemplateDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const template = await this.prisma.$transaction(async (tx) => { + if (dto.isDefault) { + await tx.labelTemplate.updateMany({ + where: { id: { not: id } }, + data: { isDefault: false }, + }); + } + return tx.labelTemplate.update({ where: { id }, data: { ...dto } }); + }); + await this.auditLog.record({ + entityType: 'LabelTemplate', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: template, + }); + return template; + } + + async deactivate(id: string, actingUserId: string): Promise { + const before = await this.findById(id); + const template = await this.prisma.labelTemplate.update({ + where: { id }, + data: { active: false, isDefault: false }, + }); + await this.auditLog.record({ + entityType: 'LabelTemplate', + entityId: id, + action: 'DEACTIVATE', + userId: actingUserId, + beforeData: before, + afterData: template, + }); + return template; + } + + async renderLabelsPdf( + templateId: string, + bottlingBatchId: string, + ): Promise { + const template = await this.findById(templateId); + const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ + where: { id: bottlingBatchId }, + include: { honeyBatch: true, product: true }, + }); + if (!bottlingBatch) { + throw new NotFoundException('Bottling batch not found'); + } + + const values: Record = { + batchNumber: bottlingBatch.honeyBatch.batchNumber, + honeyType: bottlingBatch.honeyBatch.honeyType ?? '', + lotNumber: bottlingBatch.lotNumber, + bottlingDate: formatDate(bottlingBatch.date), + bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate), + jarSize: `${bottlingBatch.jarSize} ml`, + productName: bottlingBatch.product?.name ?? '', + }; + const labelText = fillPlaceholders(template.content, values); + + // The QR code is generated once (not per copy) - it always points at the + // public Herkunft/traceability page for this batch, which already shows + // honey type, dates, and origin, so the label itself only needs to + // reference it rather than duplicate that information. + let qrCodeBuffer: Buffer | null = null; + if (labelText.includes(QR_CODE_PLACEHOLDER)) { + const frontendOrigin = + process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; + const herkunftUrl = `${frontendOrigin}/herkunft/${encodeURIComponent(bottlingBatch.lotNumber)}`; + qrCodeBuffer = await QRCode.toBuffer(herkunftUrl, { + margin: 1, + width: 300, + }); + } + + return this.renderPdf( + labelText, + template.widthMm, + template.heightMm, + bottlingBatch.jarCount, + qrCodeBuffer, + ); + } + + private renderPdf( + labelText: string, + widthMm: number, + heightMm: number, + copies: number, + qrCodeBuffer: Buffer | null, + ): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ size: 'A4', margin: 0 }); + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', (error: Error) => reject(error)); + + const usableWidthMm = A4_WIDTH_MM - 2 * PAGE_MARGIN_MM; + const usableHeightMm = A4_HEIGHT_MM - 2 * PAGE_MARGIN_MM; + const columns = Math.max( + 1, + Math.floor((usableWidthMm + LABEL_GAP_MM) / (widthMm + LABEL_GAP_MM)), + ); + const rows = Math.max( + 1, + Math.floor((usableHeightMm + LABEL_GAP_MM) / (heightMm + LABEL_GAP_MM)), + ); + const perPage = columns * rows; + + for (let index = 0; index < copies; index += 1) { + const onPage = index % perPage; + if (index > 0 && onPage === 0) { + doc.addPage({ size: 'A4', margin: 0 }); + } + const column = onPage % columns; + const row = Math.floor(onPage / columns); + const xMm = PAGE_MARGIN_MM + column * (widthMm + LABEL_GAP_MM); + const yMm = PAGE_MARGIN_MM + row * (heightMm + LABEL_GAP_MM); + const x = xMm * MM_TO_PT; + const y = yMm * MM_TO_PT; + const w = widthMm * MM_TO_PT; + const h = heightMm * MM_TO_PT; + + doc.rect(x, y, w, h).dash(2, { space: 2 }).stroke('#999999').undash(); + + if (qrCodeBuffer) { + const qrSide = Math.min(w, h) - 8; + const qrX = x + w - qrSide - 4; + const qrY = y + (h - qrSide) / 2; + doc.image(qrCodeBuffer, qrX, qrY, { width: qrSide, height: qrSide }); + const textWidth = qrX - 4 - (x + 4); + doc + .fontSize(9) + .fillColor('#000000') + .text( + labelText.replace(QR_CODE_PLACEHOLDER, '').trim(), + x + 4, + y + 4, + { + width: Math.max(textWidth, 0), + height: h - 8, + }, + ); + } else { + doc + .fontSize(9) + .fillColor('#000000') + .text(labelText, x + 4, y + 4, { width: w - 8, height: h - 8 }); + } + } + + doc.end(); + }); + } +} diff --git a/ApiCore-Backend/src/profile/dto/deactivate-account.dto.ts b/ApiCore-Backend/src/profile/dto/deactivate-account.dto.ts new file mode 100644 index 0000000..a59440d --- /dev/null +++ b/ApiCore-Backend/src/profile/dto/deactivate-account.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class DeactivateAccountDto { + @ApiProperty() + @IsString() + currentPassword!: string; +} diff --git a/ApiCore-Backend/src/profile/profile.controller.ts b/ApiCore-Backend/src/profile/profile.controller.ts index f85b470..0be39cb 100644 --- a/ApiCore-Backend/src/profile/profile.controller.ts +++ b/ApiCore-Backend/src/profile/profile.controller.ts @@ -20,6 +20,7 @@ import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { SENSITIVE_ACTION_THROTTLE } from '../auth/util/throttle'; import { isAllowedAvatarMimeType } from './avatar-mime-types'; import { ChangePasswordDto } from './dto/change-password.dto'; +import { DeactivateAccountDto } from './dto/deactivate-account.dto'; import { DisableMfaDto } from './dto/disable-mfa.dto'; import { MfaConfirmDto } from './dto/mfa-confirm.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; @@ -64,6 +65,16 @@ export class ProfileController { ); } + @Throttle(SENSITIVE_ACTION_THROTTLE) + @Delete() + @HttpCode(204) + deactivateAccount( + @Body() dto: DeactivateAccountDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.profileService.deactivateAccount(actor.id, dto.currentPassword); + } + @Post('avatar') @ApiConsumes('multipart/form-data') @UseInterceptors( diff --git a/ApiCore-Backend/src/profile/profile.module.ts b/ApiCore-Backend/src/profile/profile.module.ts index c2f69b7..e26ba3a 100644 --- a/ApiCore-Backend/src/profile/profile.module.ts +++ b/ApiCore-Backend/src/profile/profile.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { MfaModule } from '../auth/mfa/mfa.module'; +import { UsersModule } from '../users/users.module'; import { ProfileController } from './profile.controller'; import { ProfileService } from './profile.service'; @Module({ - imports: [MfaModule], + imports: [MfaModule, UsersModule], controllers: [ProfileController], providers: [ProfileService], }) diff --git a/ApiCore-Backend/src/profile/profile.service.spec.ts b/ApiCore-Backend/src/profile/profile.service.spec.ts index 2639194..a2b3742 100644 --- a/ApiCore-Backend/src/profile/profile.service.spec.ts +++ b/ApiCore-Backend/src/profile/profile.service.spec.ts @@ -11,6 +11,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { MfaService } from '../auth/mfa/mfa.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { UsersService } from '../users/users.service'; jest.mock('node:fs/promises'); @@ -29,6 +30,7 @@ describe('ProfileService', () => { confirmEnrollment: jest.Mock; }; let permissionResolver: { invalidateUser: jest.Mock }; + let usersService: { deactivate: jest.Mock }; const baseUser = { id: 'user-1', @@ -52,11 +54,13 @@ describe('ProfileService', () => { auditLog = { record: jest.fn().mockResolvedValue(undefined) }; mfaService = { startEnrollment: jest.fn(), confirmEnrollment: jest.fn() }; permissionResolver = { invalidateUser: jest.fn() }; + usersService = { deactivate: jest.fn().mockResolvedValue(undefined) }; service = new ProfileService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, mfaService as unknown as MfaService, permissionResolver as unknown as PermissionResolverService, + usersService as unknown as UsersService, ); (fs.mkdir as jest.Mock).mockResolvedValue(undefined); (fs.writeFile as jest.Mock).mockResolvedValue(undefined); @@ -155,6 +159,24 @@ describe('ProfileService', () => { }); }); + describe('deactivateAccount', () => { + it('throws UnauthorizedException when the current password is wrong', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + await expect( + service.deactivateAccount('user-1', 'wrong-password'), + ).rejects.toBeInstanceOf(UnauthorizedException); + expect(usersService.deactivate).not.toHaveBeenCalled(); + }); + + it('deactivates the user via UsersService when the password matches', async () => { + prisma.user.findUnique.mockResolvedValue(baseUser); + + await service.deactivateAccount('user-1', 'correct-password'); + + expect(usersService.deactivate).toHaveBeenCalledWith('user-1', 'user-1'); + }); + }); + describe('uploadAvatar', () => { const file = { buffer: Buffer.from('binary'), diff --git a/ApiCore-Backend/src/profile/profile.service.ts b/ApiCore-Backend/src/profile/profile.service.ts index f91889c..02860dc 100644 --- a/ApiCore-Backend/src/profile/profile.service.ts +++ b/ApiCore-Backend/src/profile/profile.service.ts @@ -13,6 +13,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { MfaService, MfaEnrollment } from '../auth/mfa/mfa.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { UsersService } from '../users/users.service'; import { ALLOWED_AVATAR_MIME_TYPES, mimeTypeForExtension, @@ -41,6 +42,7 @@ export class ProfileService { private readonly auditLog: AuditLogService, private readonly mfaService: MfaService, private readonly permissionResolver: PermissionResolverService, + private readonly usersService: UsersService, ) {} private async requireUser(userId: string) { @@ -124,6 +126,21 @@ export class ProfileService { }); } + async deactivateAccount( + userId: string, + currentPassword: string, + ): Promise { + const user = await this.requireUser(userId); + const currentMatches = await bcrypt.compare( + currentPassword, + user.passwordHash, + ); + if (!currentMatches) { + throw new UnauthorizedException('Aktuelles Passwort ist falsch'); + } + await this.usersService.deactivate(userId, userId); + } + async uploadAvatar(userId: string, file: Express.Multer.File): Promise { if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) { throw new BadRequestException('Nicht unterstützter Dateityp'); diff --git a/ApiCore-Backend/src/storage/storage.module.ts b/ApiCore-Backend/src/storage/storage.module.ts index 2ba2abd..8e77d4f 100644 --- a/ApiCore-Backend/src/storage/storage.module.ts +++ b/ApiCore-Backend/src/storage/storage.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { StorageController } from './storage.controller'; import { StorageService } from './storage.service'; @Module({ + imports: [AutomationModule], controllers: [StorageController], providers: [StorageService], exports: [StorageService], diff --git a/ApiCore-Backend/src/storage/storage.service.spec.ts b/ApiCore-Backend/src/storage/storage.service.spec.ts index 18dea72..496def0 100644 --- a/ApiCore-Backend/src/storage/storage.service.spec.ts +++ b/ApiCore-Backend/src/storage/storage.service.spec.ts @@ -3,6 +3,7 @@ import { StorageService } from './storage.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { AutomationService } from '../automation/automation.service'; describe('StorageService', () => { let service: StorageService; @@ -47,10 +48,14 @@ describe('StorageService', () => { }; auditLog = { record: jest.fn() }; const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const automation = { + evaluateStockItem: jest.fn().mockResolvedValue(undefined), + }; service = new StorageService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, webhooks as unknown as WebhooksService, + automation as unknown as AutomationService, ); }); diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts index 352e4c0..cf48a2d 100644 --- a/ApiCore-Backend/src/storage/storage.service.ts +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; +import { AutomationService } from '../automation/automation.service'; import { StockItem, StorageContainer } from '../../generated/prisma'; import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; import { UpdateStorageContainerDto } from './dto/update-storage-container.dto'; @@ -15,6 +16,7 @@ export class StorageService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, + private readonly automation: AutomationService, ) {} async findAllContainers(): Promise { @@ -149,6 +151,7 @@ export class StorageService { }); const stockItem = await this.findStockItemById(id); await this.webhooks.dispatch('product.stock_changed', stockItem); + await this.automation.evaluateStockItem(stockItem); return stockItem; } } diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.module.ts b/ApiCore-Backend/src/swarm-control/swarm-control.module.ts index 08c273f..7868a7a 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.module.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { AutomationModule } from '../automation/automation.module'; import { SwarmControlController } from './swarm-control.controller'; import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller'; import { SwarmControlService } from './swarm-control.service'; @Module({ + imports: [AutomationModule], controllers: [SwarmControlController, ColonySwarmControlChecksController], providers: [SwarmControlService], exports: [SwarmControlService], diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts b/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts index 118a8a2..27a48d5 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.service.spec.ts @@ -2,6 +2,7 @@ import { NotFoundException } from '@nestjs/common'; import { SwarmControlService } from './swarm-control.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { AutomationService } from '../automation/automation.service'; describe('SwarmControlService', () => { let service: SwarmControlService; @@ -31,9 +32,13 @@ describe('SwarmControlService', () => { }, }; auditLog = { record: jest.fn() }; + const automation = { + evaluateSwarmControlCheck: jest.fn().mockResolvedValue(undefined), + }; service = new SwarmControlService( prisma as unknown as PrismaService, auditLog as unknown as AuditLogService, + automation as unknown as AutomationService, ); }); diff --git a/ApiCore-Backend/src/swarm-control/swarm-control.service.ts b/ApiCore-Backend/src/swarm-control/swarm-control.service.ts index b039647..054ccfe 100644 --- a/ApiCore-Backend/src/swarm-control/swarm-control.service.ts +++ b/ApiCore-Backend/src/swarm-control/swarm-control.service.ts @@ -1,6 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; +import { AutomationService } from '../automation/automation.service'; import { SwarmControlCheck } from '../../generated/prisma'; import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto'; import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto'; @@ -10,6 +11,7 @@ export class SwarmControlService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly automation: AutomationService, ) {} async findAll(): Promise { @@ -56,6 +58,7 @@ export class SwarmControlService { userId: actingUserId, afterData: check, }); + await this.automation.evaluateSwarmControlCheck(check); return check; } @@ -83,6 +86,7 @@ export class SwarmControlService { beforeData: before, afterData: check, }); + await this.automation.evaluateSwarmControlCheck(check); return check; } } diff --git a/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts b/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts index 5494e33..e093709 100644 --- a/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts +++ b/ApiCore-Backend/test/authorization-coverage.e2e-spec.ts @@ -20,6 +20,7 @@ const ALLOWLIST = new Set([ 'AuthController.me', 'ProfileController.updateProfile', 'ProfileController.changePassword', + 'ProfileController.deactivateAccount', 'ProfileController.uploadAvatar', 'ProfileController.deleteAvatar', 'ProfileController.getAvatar', diff --git a/ApiCore-Backend/test/jest-e2e.json b/ApiCore-Backend/test/jest-e2e.json index 84ea1aa..5399989 100644 --- a/ApiCore-Backend/test/jest-e2e.json +++ b/ApiCore-Backend/test/jest-e2e.json @@ -8,5 +8,7 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "transformIgnorePatterns": ["node_modules/(?!(otplib|@otplib|@scure|@noble)/)"] + "transformIgnorePatterns": [ + "node_modules/(?!(otplib|@otplib|@scure|@noble|openid-client|oauth4webapi|jose)/)" + ] } diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx index 3f6fe63..b137948 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx @@ -69,7 +69,7 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { } return ( - +
- - - -
- - +
+
+ +
+ + +
+
+
+ + +
); diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx index c9592c4..e4ddb38 100644 --- a/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/page.tsx @@ -8,6 +8,7 @@ import { fetchBottlingBatch } from "@/lib/bottling/api"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { QrCodeDialog } from "@/components/qr-code-dialog"; import { BottlingEditDialog } from "./bottling-edit-dialog"; +import { PrintLabelsCard } from "./print-labels-card"; export default function BottlingBatchDetailPage() { const params = useParams<{ id: string }>(); @@ -29,50 +30,53 @@ export default function BottlingBatchDetailPage() { return (
- - -
- - - - Abfüllung {bottlingBatch.lotNumber} -
-
- - -
-
- -

Honigcharge: {bottlingBatch.honeyBatch.batchNumber}

- {bottlingBatch.product ?

Produkt: {bottlingBatch.product.name}

: null} -

Datum: {new Date(bottlingBatch.date).toLocaleDateString("de-DE")}

-

- {bottlingBatch.jarCount} × {bottlingBatch.jarSize} g ={" "} - {bottlingBatch.totalAmount} kg -

-

- Mindesthaltbar bis:{" "} - {new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")} -

-

- Öffentliche Herkunftsseite:{" "} - - /herkunft/{bottlingBatch.lotNumber} - -

-
-
+
+ + +
+ + + + Abfüllung {bottlingBatch.lotNumber} +
+
+ + +
+
+ +

Honigcharge: {bottlingBatch.honeyBatch.batchNumber}

+ {bottlingBatch.product ?

Produkt: {bottlingBatch.product.name}

: null} +

Datum: {new Date(bottlingBatch.date).toLocaleDateString("de-DE")}

+

+ {bottlingBatch.jarCount} × {bottlingBatch.jarSize} g ={" "} + {bottlingBatch.totalAmount} kg +

+

+ Mindesthaltbar bis:{" "} + {new Date(bottlingBatch.bestBeforeDate).toLocaleDateString("de-DE")} +

+

+ Öffentliche Herkunftsseite:{" "} + + /herkunft/{bottlingBatch.lotNumber} + +

+
+
+ +
); } diff --git a/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx new file mode 100644 index 0000000..9513bcb --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/bottling/[id]/print-labels-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { Printer, Tag } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { fetchLabelTemplates, printLabels } from "@/lib/label-templates/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export function PrintLabelsCard({ bottlingBatchId }: { bottlingBatchId: string }) { + const [templateId, setTemplateId] = useState(""); + const { data: templates } = useQuery({ + queryKey: ["label-templates"], + queryFn: fetchLabelTemplates, + }); + const activeTemplates = (templates ?? []).filter((template) => template.active); + const preferredTemplateId = + activeTemplates.find((template) => template.isDefault)?.id ?? activeTemplates[0]?.id ?? ""; + const effectiveTemplateId = templateId || preferredTemplateId; + const selectedTemplate = activeTemplates.find( + (template) => template.id === effectiveTemplateId, + ); + + const mutation = useMutation({ + mutationFn: () => printLabels(effectiveTemplateId, bottlingBatchId), + onError: () => toast.error("Etiketten konnten nicht erstellt werden"), + }); + + return ( + + + + + Etiketten drucken + + + + {activeTemplates.length === 0 ? ( +

+ Noch keine Etikettenvorlage angelegt. Unter Einstellungen → Etikettenvorlagen eine + Vorlage erstellen. +

+ ) : ( + <> + + + + )} +
+
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 6cf2110..1119765 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -157,7 +157,7 @@ function SplitDialog({ colony }: { colony: Colony }) { onValueChange={(value) => setColonyType(value as ColonyType)} > - + {COLONY_TYPE_LABELS[colonyType]} {COLONY_TYPES.map((type) => ( diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx index cd150a8..402f6e4 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-edit-card.tsx @@ -113,7 +113,7 @@ export function ColonyEditCard({ colony }: { colony: Colony }) { } return ( - +
setColonyType(value as ColonyType)} > - + {COLONY_TYPE_LABELS[colonyType]} {COLONY_TYPES.map((type) => ( diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx index 7cf1c2f..a30dfd1 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-feedings.tsx @@ -21,7 +21,7 @@ export function ColonyFeedingsCard({ colonyId }: { colonyId: string }) { }); return ( - + Fütterung
- - - - - - - - - +
+
+ + +
+
+
+ + + + + + +
+ +
+
); } diff --git a/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx index db4a578..c746ab1 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/new/page.tsx @@ -101,7 +101,7 @@ export default function NewColonyPage() { onValueChange={(value) => setColonyType(value as ColonyType)} > - + {COLONY_TYPE_LABELS[colonyType]} {COLONY_TYPES.map((type) => ( diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx index 6cbc769..680f76b 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/honey-batch-storage-card.tsx @@ -73,7 +73,7 @@ export function HoneyBatchStorageCard({ honeyBatch }: { honeyBatch: HoneyBatch } } return ( - + Lagerbehälter diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx index 2936f7c..7af0c52 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx @@ -46,116 +46,126 @@ export default function HoneyBatchDetailPage() { return (
- - -
- - - - {honeyBatch.batchNumber} -
-
- {HONEY_BATCH_STATUS_LABELS[honeyBatch.status]} - - -
-
- - {honeyBatch.honeyType ?

Sorte: {honeyBatch.honeyType}

: null} - {honeyBatch.amount !== null ?

Menge: {honeyBatch.amount} kg

: null} - {honeyBatch.waterContent !== null ? ( -

Wassergehalt: {honeyBatch.waterContent}%

- ) : null} - -
-
- - - - Honigmessungen - - - - {honeyBatch.measurements.length === 0 ? ( - - ) : null} - {honeyBatch.measurements.map((measurement) => ( -
-

- {new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "} - {measurement.waterContent}% Wassergehalt -

- {measurement.device ?

{measurement.device}

: null} -
- ))} -
-
- - - - Rückverfolgbarkeit - - - - {!traceability ?

Lädt…

: null} - {traceability ? ( - <> -
-

- Schleuderung am{" "} - {new Date(traceability.extraction.date).toLocaleDateString("de-DE")} - {traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""} -

- +
+
+ + +
+ + + + {honeyBatch.batchNumber}
-
- {traceability.harvests.map((harvest) => ( - +
+ {HONEY_BATCH_STATUS_LABELS[honeyBatch.status]} + + +
+ + + {honeyBatch.honeyType ?

Sorte: {honeyBatch.honeyType}

: null} + {honeyBatch.amount !== null ?

Menge: {honeyBatch.amount} kg

: null} + {honeyBatch.waterContent !== null ? ( +

Wassergehalt: {honeyBatch.waterContent}%

+ ) : null} + +
+ + +
+ +
+
+ + + Honigmessungen + + + + {honeyBatch.measurements.length === 0 ? ( + + ) : null} + {honeyBatch.measurements.map((measurement) => ( +

- Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")} + {new Date(measurement.measuredAt).toLocaleDateString("de-DE")} ·{" "} + {measurement.waterContent}% Wassergehalt

-

- Völker: {harvest.colonies.map((colony) => colony.code).join(", ")} -

- + {measurement.device ? ( +

{measurement.device}

+ ) : null} +
))} -
- - ) : null} - - + + - - + +
+ + + + Rückverfolgbarkeit + + + + {!traceability ?

Lädt…

: null} + {traceability ? ( + <> +
+

+ Schleuderung am{" "} + {new Date(traceability.extraction.date).toLocaleDateString("de-DE")} + {traceability.extraction.room ? ` · ${traceability.extraction.room}` : ""} +

+ +
+
+ {traceability.harvests.map((harvest) => ( + +

+ Ernte am {new Date(harvest.date).toLocaleDateString("de-DE")} +

+

+ Völker: {harvest.colonies.map((colony) => colony.code).join(", ")} +

+ + ))} +
+ + ) : null} +
+
+
+
); } diff --git a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx index da3c8f2..85d0f7c 100644 --- a/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/inspections/new/page.tsx @@ -23,6 +23,12 @@ import { import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; +const FEED_STATUS_LABELS = { + ausreichend: "Ausreichend", + niedrig: "Niedrig", + kritisch: "Kritisch", +} as const; + function todayIsoDate(): string { return new Date().toISOString().slice(0, 10); } @@ -191,7 +197,7 @@ export default function NewInspectionPage() { } > - + {FEED_STATUS_LABELS[feedStatus]} Ausreichend diff --git a/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx b/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx new file mode 100644 index 0000000..0d2ec4e --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/profile/danger-zone-section.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Trash2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useAuth } from "@/lib/auth/auth-context"; +import { deactivateAccount } from "@/lib/profile/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function DeactivateAccountDialog() { + const router = useRouter(); + const { logout } = useAuth(); + const [open, setOpen] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: () => deactivateAccount(currentPassword), + onSuccess: async () => { + toast.success("Konto deaktiviert"); + await logout(); + router.push("/login"); + }, + onError: () => setError("Passwort ist falsch oder das Konto kann nicht deaktiviert werden."), + }); + + function handleClose() { + setOpen(false); + setCurrentPassword(""); + setError(null); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate(); + } + + return ( + (next ? undefined : handleClose())}> + + + + Konto löschen + + Dein Konto wird deaktiviert und du wirst abgemeldet. Die Historie deiner bisherigen + Einträge bleibt erhalten. Zur Bestätigung aktuelles Passwort eingeben. + + +
+
+ + setCurrentPassword(event.target.value)} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + + + + +
+
+
+ ); +} + +export function DangerZoneSection() { + return ( + + + Konto löschen + + Unwiderruflich für dich: Danach kannst du dich nicht mehr anmelden. + + + + + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx index 9fb8d1b..0c773d0 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/page.spec.tsx @@ -6,6 +6,10 @@ import type { ReactElement } from "react"; import ProfilePage from "./page"; import { updateProfile, changePassword, uploadAvatar } from "@/lib/profile/api"; +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + vi.mock("@/lib/profile/api", () => ({ updateProfile: vi.fn(), changePassword: vi.fn(), @@ -15,6 +19,7 @@ vi.mock("@/lib/profile/api", () => ({ enrollMfa: vi.fn(), confirmMfaEnrollment: vi.fn(), disableMfa: vi.fn(), + deactivateAccount: vi.fn(), })); const refreshUserMock = vi.fn(); @@ -29,7 +34,7 @@ const mockUser = { hasAvatar: false, }; vi.mock("@/lib/auth/auth-context", () => ({ - useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock }), + useAuth: () => ({ user: mockUser, refreshUser: refreshUserMock, logout: vi.fn() }), })); function renderWithClient(ui: ReactElement) { @@ -53,13 +58,13 @@ describe("ProfilePage", () => { }); renderWithClient(); - const nameInput = screen.getByLabelText("Name"); - await userEvent.clear(nameInput); - await userEvent.type(nameInput, "New Name"); + const lastNameInput = screen.getByLabelText("Nachname"); + await userEvent.clear(lastNameInput); + await userEvent.type(lastNameInput, "Name"); await userEvent.click(screen.getByRole("button", { name: "Speichern" })); expect(updateProfile).toHaveBeenCalledWith({ - name: "New Name", + name: "Existing Name", email: "existing@example.com", }); await vi.waitFor(() => expect(refreshUserMock).toHaveBeenCalled()); diff --git a/ApiCore-Frontend/src/app/(app)/profile/page.tsx b/ApiCore-Frontend/src/app/(app)/profile/page.tsx index 666616c..2c918c1 100644 --- a/ApiCore-Frontend/src/app/(app)/profile/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/profile/page.tsx @@ -13,6 +13,7 @@ import { } from "@/lib/profile/api"; import { PageHeader } from "@/components/page-header"; import { UserAvatar } from "@/components/user-avatar"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -24,6 +25,12 @@ import { CardTitle, } from "@/components/ui/card"; import { MfaSection } from "./mfa-section"; +import { DangerZoneSection } from "./danger-zone-section"; + +function splitName(fullName: string): { firstName: string; lastName: string } { + const [firstName, ...rest] = fullName.trim().split(/\s+/); + return { firstName: firstName ?? "", lastName: rest.join(" ") }; +} function AvatarSection() { const { user, refreshUser } = useAuth(); @@ -100,14 +107,21 @@ function AvatarSection() { function ProfileInfoSection() { const { user, refreshUser } = useAuth(); - const [name, setName] = useState(user?.name ?? ""); + const initialName = splitName(user?.name ?? ""); + const [firstName, setFirstName] = useState(initialName.firstName); + const [lastName, setLastName] = useState(initialName.lastName); const [email, setEmail] = useState(user?.email ?? ""); const [currentPassword, setCurrentPassword] = useState(""); const [error, setError] = useState(null); const emailChanged = email !== user?.email; const mutation = useMutation({ - mutationFn: () => updateProfile({ name, email, currentPassword: currentPassword || undefined }), + mutationFn: () => + updateProfile({ + name: [firstName, lastName].filter(Boolean).join(" "), + email, + currentPassword: currentPassword || undefined, + }), onSuccess: async () => { setCurrentPassword(""); await refreshUser(); @@ -131,18 +145,31 @@ function ProfileInfoSection() { Profil - Name und E-Mail-Adresse + + Name, E-Mail-Adresse und Rolle + {user ? {user.roleName} : null} +
-
- - setName(event.target.value)} - /> +
+
+ + setFirstName(event.target.value)} + /> +
+
+ + setLastName(event.target.value)} + /> +
@@ -270,6 +297,7 @@ export default function ProfilePage() { void refreshUser()} /> +
); diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx index b3fb067..1c62b5b 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx @@ -34,12 +34,20 @@ export default function QueenDetailPage() {
- - - - - - +
+
+ + +
+
+
+ + + +
+ +
+
); } diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx index f285ee7..15a98fa 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx @@ -61,7 +61,7 @@ export function QueenEditCard({ queen }: { queen: Queen }) { } return ( - +
+ Bewertung diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-history.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-history.tsx index bf869d6..2617f42 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-history.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-history.tsx @@ -22,7 +22,7 @@ export function QueenHistoryCard({ queenId }: { queenId: string }) { } return ( - + Volk-Historie diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-lineage.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-lineage.tsx index f457c64..9c74fd4 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-lineage.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-lineage.tsx @@ -48,7 +48,7 @@ export function QueenLineageCard({ queenId }: { queenId: string }) { const hasLineage = ancestors.length > 0 || (lineage?.descendants.length ?? 0) > 0; return ( - + diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx index ca2293b..5b40a5a 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx @@ -14,18 +14,27 @@ import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; const TRIGGER_LABELS: Record = { - QUEENLESS_NO_EGGS: "Keine Königin gesehen & keine Stifte", + QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte", VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten", FEED_STATUS_CRITICAL: "Futterstatus kritisch", + SWARM_MOOD_HIGH: "Hohe Schwarmstimmung", + COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch", + STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand", }; const TRIGGER_DESCRIPTIONS: Record = { QUEENLESS_NO_EGGS: - "Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden → Kontrollaufgabe.", + "Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.", VARROA_THRESHOLD: - "Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert → Behandlung prüfen.", + "Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert, dann Behandlung prüfen.", FEED_STATUS_CRITICAL: - "Futterstatus bei einer Durchsicht auf „kritisch“ gesetzt → Fütterungsaufgabe.", + "Futterstatus bei einer Durchsicht auf kritisch gesetzt, dann Fütterungsaufgabe.", + SWARM_MOOD_HIGH: + "Bei einer Schwarmkontrolle Schwarmstimmung hoch erfasst, dann Kontrollaufgabe.", + COLONY_HEALTH_CRITICAL: + "Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.", + STOCK_ITEM_LOW: + "Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.", }; function ruleThreshold(rule: AutomationRule): string { @@ -59,7 +68,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) { } return ( - + {TRIGGER_LABELS[rule.triggerType]} @@ -128,9 +137,11 @@ export default function AutomationRulesPage() {

) : null} - {rules?.map((rule) => ( - - ))} +
+ {rules?.map((rule) => ( + + ))} +
); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx new file mode 100644 index 0000000..3cc08c2 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/create-label-template-dialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { createLabelTemplate } from "@/lib/label-templates/api"; +import { LABEL_PLACEHOLDERS } from "@/lib/label-templates/types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function CreateLabelTemplateDialog() { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [content, setContent] = useState(""); + const [widthMm, setWidthMm] = useState("90"); + const [heightMm, setHeightMm] = useState("35"); + const [error, setError] = useState(null); + + function reset() { + setName(""); + setContent(""); + setWidthMm("90"); + setHeightMm("35"); + setError(null); + } + + const mutation = useMutation({ + mutationFn: createLabelTemplate, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["label-templates"] }); + setOpen(false); + reset(); + }, + onError: () => setError("Vorlage konnte nicht angelegt werden."), + }); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + mutation.mutate({ + name, + content, + widthMm: widthMm ? Number(widthMm) : undefined, + heightMm: heightMm ? Number(heightMm) : undefined, + }); + } + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + Neue Etikettenvorlage + + +
+ + setName(event.target.value)} + /> +
+
+
+ + setWidthMm(event.target.value)} + /> +
+
+ + setHeightMm(event.target.value)} + /> +
+
+
+ +