feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s

- 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:
Nick Szaunig committed 2026-08-31 18:56:08 +02:00
1 parent 0a52b29c57
commit 8432313fa9
83 files changed
+2684 -289

No files matched your search

+2
View File
@@ -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,
+11 -38
View File
@@ -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()
+10 -2
View File
@@ -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);
+20
View File
@@ -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,
);
});
});
});
+138
View File
@@ -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;
}
}