feat(backend): QR-Code-System (Modul 22)

QrModule mit GET /qr/:entityType/:id.svg fuer colonies (Deep-Link
direkt zu /inspections/new), apiaries, queens, honey-batches und
storage-containers. Nutzt pdfkit-Nachbar qrcode zur SVG-Erzeugung.
This commit is contained in:
Nick Szaunig committed 2026-08-22 10:51:42 +02:00
1 parent a7ded02015
commit dd88b6a92c
9 files changed
+427 -15

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { QrService } from './qr.service';
@ApiTags('qr')
@ApiBearerAuth()
@Controller('qr')
export class QrController {
constructor(private readonly qrService: QrService) {}
@Get(':entityType/:id.svg')
async svg(
@Param('entityType') entityType: string,
@Param('id') id: string,
@Res() res: Response,
): Promise<void> {
const svg = await this.qrService.generateSvg(entityType, id);
res.setHeader('Content-Type', 'image/svg+xml');
res.send(svg);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { QrController } from './qr.controller';
import { QrService } from './qr.service';
@Module({
controllers: [QrController],
providers: [QrService],
})
export class QrModule {}
+50
View File
@@ -0,0 +1,50 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { QrService } from './qr.service';
import { PrismaService } from '../prisma/prisma.service';
describe('QrService', () => {
let service: QrService;
let prisma: {
colony: { findUnique: jest.Mock };
apiary: { findUnique: jest.Mock };
queen: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
storageContainer: { findUnique: jest.Mock };
};
beforeEach(() => {
prisma = {
colony: { findUnique: jest.fn() },
apiary: { findUnique: jest.fn() },
queen: { findUnique: jest.fn() },
honeyBatch: { findUnique: jest.fn() },
storageContainer: { findUnique: jest.fn() },
};
service = new QrService(prisma as unknown as PrismaService);
});
it('rejects an unsupported entity type', async () => {
await expect(service.generateSvg('unknown', 'id-1')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('throws NotFoundException when the object does not exist', async () => {
prisma.colony.findUnique.mockResolvedValueOnce(null);
await expect(
service.generateSvg('colonies', 'missing'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('generates an SVG QR code for an existing colony', async () => {
prisma.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
const svg = await service.generateSvg('colonies', 'colony-1');
expect(svg).toContain('<svg');
});
it('generates an SVG QR code for an existing storage container', async () => {
prisma.storageContainer.findUnique.mockResolvedValueOnce({ id: 'sc-1' });
const svg = await service.generateSvg('storage-containers', 'sc-1');
expect(svg).toContain('<svg');
});
});
+85
View File
@@ -0,0 +1,85 @@
import {
Injectable,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
const ENTITY_TYPES = [
'colonies',
'apiaries',
'queens',
'honey-batches',
'storage-containers',
] as const;
type EntityType = (typeof ENTITY_TYPES)[number];
function isEntityType(value: string): value is EntityType {
return (ENTITY_TYPES as readonly string[]).includes(value);
}
@Injectable()
export class QrService {
constructor(private readonly prisma: PrismaService) {}
async generateSvg(entityType: string, id: string): Promise<string> {
if (!isEntityType(entityType)) {
throw new BadRequestException(
`Unsupported QR entity type: ${entityType}`,
);
}
const exists = await this.entityExists(entityType, id);
if (!exists) {
throw new NotFoundException('Object not found');
}
const frontendOrigin =
process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const url = `${frontendOrigin}${this.deepLink(entityType, id)}`;
return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 });
}
private async entityExists(
entityType: EntityType,
id: string,
): Promise<boolean> {
switch (entityType) {
case 'colonies':
return (
(await this.prisma.colony.findUnique({ where: { id } })) !== null
);
case 'apiaries':
return (
(await this.prisma.apiary.findUnique({ where: { id } })) !== null
);
case 'queens':
return (await this.prisma.queen.findUnique({ where: { id } })) !== null;
case 'honey-batches':
return (
(await this.prisma.honeyBatch.findUnique({ where: { id } })) !== null
);
case 'storage-containers':
return (
(await this.prisma.storageContainer.findUnique({ where: { id } })) !==
null
);
}
}
private deepLink(entityType: EntityType, id: string): string {
switch (entityType) {
case 'colonies':
return `/inspections/new?colonyId=${id}`;
case 'apiaries':
return `/apiaries/${id}`;
case 'queens':
return `/queens/${id}`;
case 'honey-batches':
return `/honey-batches/${id}`;
case 'storage-containers':
return `/storage`;
}
}
}