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)
This commit is contained in:
1 parent
0a52b29c57
commit
8432313fa9
83 files changed
+2684
-289
No files matched your search
@@ -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/<tenant-id>/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"
|
||||
Generated
+32
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+4
@@ -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';
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string> | 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<AccessTokenDto> {
|
||||
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<AccessTokenDto> {
|
||||
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<void> {
|
||||
await this.authService.logout(this.extractRefreshToken(req));
|
||||
this.clearRefreshTokenCookie(res);
|
||||
await this.authService.logout(extractRefreshToken(req));
|
||||
clearRefreshTokenCookie(res);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AuthTokens> {
|
||||
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<AuthTokens> {
|
||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const rawCookie = (req.cookies as Record<string, string> | 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<AccessTokenDto> {
|
||||
const ssoCode = (req.cookies as Record<string, string> | 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<PkceCookiePayload>;
|
||||
if (
|
||||
typeof parsed.codeVerifier !== 'string' ||
|
||||
typeof parsed.state !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<typeof client>;
|
||||
|
||||
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<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
|
||||
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: '[email protected]' }),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
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: '[email protected]' }),
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof client.authorizationCodeGrant>
|
||||
>);
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<client.Configuration> {
|
||||
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<SsoCallbackResult> {
|
||||
const config = await this.getConfig();
|
||||
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
|
||||
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<SsoCompleteTokenPayload>(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 };
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> | undefined)?.[
|
||||
REFRESH_TOKEN_COOKIE
|
||||
];
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedException('Missing refresh token');
|
||||
}
|
||||
return refreshToken;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AutomationTriggerType, RuleDefaults> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Colony[]> {
|
||||
@@ -80,6 +82,7 @@ export class ColoniesService {
|
||||
beforeData: before,
|
||||
afterData: colony,
|
||||
});
|
||||
await this.automation.evaluateColonyHealth(before, colony);
|
||||
return colony;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateLabelTemplateDto } from './create-label-template.dto';
|
||||
|
||||
export class UpdateLabelTemplateDto extends PartialType(
|
||||
CreateLabelTemplateDto,
|
||||
) {}
|
||||
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, string>,
|
||||
): 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<LabelTemplate[]> {
|
||||
return this.prisma.labelTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<LabelTemplate> {
|
||||
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<LabelTemplate> {
|
||||
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<LabelTemplate> {
|
||||
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<LabelTemplate> {
|
||||
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<Buffer> {
|
||||
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<string, string> = {
|
||||
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<Buffer> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DeactivateAccountDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
currentPassword!: string;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
if (!(file.mimetype in ALLOWED_AVATAR_MIME_TYPES)) {
|
||||
throw new BadRequestException('Nicht unterstützter Dateityp');
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<StorageContainer[]> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<SwarmControlCheck[]> {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const ALLOWLIST = new Set([
|
||||
'AuthController.me',
|
||||
'ProfileController.updateProfile',
|
||||
'ProfileController.changePassword',
|
||||
'ProfileController.deactivateAccount',
|
||||
'ProfileController.uploadAvatar',
|
||||
'ProfileController.deleteAvatar',
|
||||
'ProfileController.getAvatar',
|
||||
|
||||
@@ -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)/)"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user