diff --git a/ApiCore-Backend/src/qr/qr.controller.ts b/ApiCore-Backend/src/qr/qr.controller.ts index 89ab2f6..2d07689 100644 --- a/ApiCore-Backend/src/qr/qr.controller.ts +++ b/ApiCore-Backend/src/qr/qr.controller.ts @@ -19,4 +19,13 @@ export class QrController { res.setHeader('Content-Type', 'image/svg+xml'); res.send(svg); } + + @Get(':entityType/:id/link') + async link( + @Param('entityType') entityType: string, + @Param('id') id: string, + ): Promise<{ url: string }> { + const url = await this.qrService.resolveUrl(entityType, id); + return { url }; + } } diff --git a/ApiCore-Backend/src/qr/qr.service.spec.ts b/ApiCore-Backend/src/qr/qr.service.spec.ts index c5f2ebc..cefd680 100644 --- a/ApiCore-Backend/src/qr/qr.service.spec.ts +++ b/ApiCore-Backend/src/qr/qr.service.spec.ts @@ -57,4 +57,23 @@ describe('QrService', () => { const svg = await service.generateSvg('bottling-batches', 'bb-1'); expect(svg).toContain(' { + prisma.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' }); + const url = await service.resolveUrl('colonies', 'colony-1'); + expect(url).toBe('http://localhost:3000/inspections/new?colonyId=colony-1'); + }); + + it('resolveUrl rejects an unsupported entity type', async () => { + await expect(service.resolveUrl('unknown', 'id-1')).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('resolveUrl throws NotFoundException when the object does not exist', async () => { + prisma.colony.findUnique.mockResolvedValueOnce(null); + await expect( + service.resolveUrl('colonies', 'missing'), + ).rejects.toBeInstanceOf(NotFoundException); + }); }); diff --git a/ApiCore-Backend/src/qr/qr.service.ts b/ApiCore-Backend/src/qr/qr.service.ts index 9fa2f2c..af9547c 100644 --- a/ApiCore-Backend/src/qr/qr.service.ts +++ b/ApiCore-Backend/src/qr/qr.service.ts @@ -25,6 +25,11 @@ export class QrService { constructor(private readonly prisma: PrismaService) {} async generateSvg(entityType: string, id: string): Promise { + const url = await this.resolveUrl(entityType, id); + return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 }); + } + + async resolveUrl(entityType: string, id: string): Promise { if (!isEntityType(entityType)) { throw new BadRequestException( `Unsupported QR entity type: ${entityType}`, @@ -38,8 +43,7 @@ export class QrService { const frontendOrigin = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; - const url = `${frontendOrigin}${deepLink}`; - return QRCode.toString(url, { type: 'svg', margin: 1, width: 256 }); + return `${frontendOrigin}${deepLink}`; } private async resolveDeepLink( diff --git a/ApiCore-Backend/test/qr.e2e-spec.ts b/ApiCore-Backend/test/qr.e2e-spec.ts index 613ba98..98584a1 100644 --- a/ApiCore-Backend/test/qr.e2e-spec.ts +++ b/ApiCore-Backend/test/qr.e2e-spec.ts @@ -78,4 +78,13 @@ describe('QR codes (e2e)', () => { .set('Authorization', `Bearer ${beekeeperToken}`) .expect(400); }); + + it('returns the plain deep-link URL for NFC writing', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/qr/colonies/${colonyId}/link`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const body = response.body as { url: string }; + expect(body.url).toContain(`colonyId=${colonyId}`); + }); }); diff --git a/ApiCore-Frontend/src/components/qr-code-dialog.tsx b/ApiCore-Frontend/src/components/qr-code-dialog.tsx index cc417b0..32a1a37 100644 --- a/ApiCore-Frontend/src/components/qr-code-dialog.tsx +++ b/ApiCore-Frontend/src/components/qr-code-dialog.tsx @@ -2,8 +2,10 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { QrCode } from "lucide-react"; -import { fetchQrCodeSvg, QrEntityType } from "@/lib/qr/api"; +import { QrCode, Nfc } from "lucide-react"; +import { toast } from "sonner"; +import { fetchQrCodeSvg, fetchQrLink, QrEntityType } from "@/lib/qr/api"; +import { isNfcSupported, writeNfcTag } from "@/lib/nfc/write"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -21,6 +23,8 @@ interface QrCodeDialogProps { export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialogProps) { const [open, setOpen] = useState(false); + const [isWritingNfc, setIsWritingNfc] = useState(false); + const nfcSupported = useMemo(() => isNfcSupported(), []); const { data: blob, isLoading, isError } = useQuery({ queryKey: ["qr", entityType, id], @@ -35,6 +39,19 @@ export function QrCodeDialog({ entityType, id, label = "QR-Code" }: QrCodeDialog }; }, [objectUrl]); + async function handleWriteNfc() { + setIsWritingNfc(true); + try { + const url = await fetchQrLink(entityType, id); + await writeNfcTag(url); + toast.success("NFC-Tag beschrieben"); + } catch { + toast.error("NFC-Tag konnte nicht beschrieben werden."); + } finally { + setIsWritingNfc(false); + } + } + return ( ) : null} + {nfcSupported ? ( + + ) : null} ); diff --git a/ApiCore-Frontend/src/lib/nfc/write.ts b/ApiCore-Frontend/src/lib/nfc/write.ts new file mode 100644 index 0000000..785714f --- /dev/null +++ b/ApiCore-Frontend/src/lib/nfc/write.ts @@ -0,0 +1,11 @@ +export function isNfcSupported(): boolean { + return typeof window !== "undefined" && "NDEFReader" in window; +} + +export async function writeNfcTag(url: string): Promise { + if (!window.NDEFReader) { + throw new Error("Web NFC wird von diesem Browser nicht unterstützt."); + } + const reader = new window.NDEFReader(); + await reader.write({ records: [{ recordType: "url", data: url }] }); +} diff --git a/ApiCore-Frontend/src/lib/qr/api.ts b/ApiCore-Frontend/src/lib/qr/api.ts index 16162d4..1850ab0 100644 --- a/ApiCore-Frontend/src/lib/qr/api.ts +++ b/ApiCore-Frontend/src/lib/qr/api.ts @@ -1,4 +1,4 @@ -import { apiFetchBlob } from "@/lib/auth/api-client"; +import { apiFetch, apiFetchBlob } from "@/lib/auth/api-client"; export type QrEntityType = | "colonies" @@ -12,3 +12,8 @@ export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Prom const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`); return blob; } + +export async function fetchQrLink(entityType: QrEntityType, id: string): Promise { + const { url } = await apiFetch<{ url: string }>(`/qr/${entityType}/${id}/link`); + return url; +} diff --git a/ApiCore-Frontend/src/types/web-nfc.d.ts b/ApiCore-Frontend/src/types/web-nfc.d.ts new file mode 100644 index 0000000..46239f8 --- /dev/null +++ b/ApiCore-Frontend/src/types/web-nfc.d.ts @@ -0,0 +1,16 @@ +interface NDEFRecordInit { + recordType: string; + data?: string; +} + +interface NDEFWriteOptions { + records: NDEFRecordInit[]; +} + +interface NDEFReader { + write(options: NDEFWriteOptions): Promise; +} + +interface Window { + NDEFReader?: { new (): NDEFReader }; +}