diff --git a/ApiCore-Backend/prisma/migrations/20260822204038_notifications/migration.sql b/ApiCore-Backend/prisma/migrations/20260822204038_notifications/migration.sql new file mode 100644 index 0000000..4303056 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260822204038_notifications/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "NotificationPriority" AS ENUM ('INFO', 'HINWEIS', 'WARNUNG', 'KRITISCH'); + +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "priority" "NotificationPriority" NOT NULL, + "message" TEXT NOT NULL, + "relatedType" TEXT, + "relatedId" TEXT, + "read" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read"); + +-- CreateIndex +CREATE INDEX "Notification_priority_idx" ON "Notification"("priority"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index e1c8eb0..a326784 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -50,6 +50,7 @@ model User { colonyMoves ColonyApiaryHistory[] auditLogs AuditLog[] bottlingBatches BottlingBatch[] + notifications Notification[] @@index([role]) } @@ -698,3 +699,29 @@ model Equipment { @@index([condition]) } + +// --------------------------------------------------------------------------- +// Benachrichtigungssystem (Abschnitt 24) – v1.x Modul 26 +// --------------------------------------------------------------------------- + +enum NotificationPriority { + INFO + HINWEIS + WARNUNG + KRITISCH +} + +model Notification { + id String @id @default(cuid()) + userId String? + user User? @relation(fields: [userId], references: [id]) + priority NotificationPriority + message String + relatedType String? + relatedId String? + read Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([userId, read]) + @@index([priority]) +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 0fa342b..cb10733 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -26,6 +26,7 @@ import { ProductsModule } from './products/products.module'; import { BottlingModule } from './bottling/bottling.module'; import { EquipmentModule } from './equipment/equipment.module'; import { CalendarModule } from './calendar/calendar.module'; +import { NotificationsModule } from './notifications/notifications.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -57,6 +58,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; BottlingModule, EquipmentModule, CalendarModule, + NotificationsModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/notifications/notifications.controller.ts b/ApiCore-Backend/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..bbc73eb --- /dev/null +++ b/ApiCore-Backend/src/notifications/notifications.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { NotificationsService } from './notifications.service'; + +@ApiTags('notifications') +@ApiBearerAuth() +@Controller('notifications') +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + findAll( + @CurrentUser() actor: AuthenticatedUser, + @Query('unreadOnly') unreadOnly?: string, + ) { + return this.notificationsService.findAll({ + userId: actor.id, + unreadOnly: unreadOnly === 'true', + }); + } + + @Post(':id/read') + markRead(@Param('id') id: string) { + return this.notificationsService.markRead(id); + } +} diff --git a/ApiCore-Backend/src/notifications/notifications.module.ts b/ApiCore-Backend/src/notifications/notifications.module.ts new file mode 100644 index 0000000..e311df4 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notifications.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; + +@Module({ + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/ApiCore-Backend/src/notifications/notifications.service.spec.ts b/ApiCore-Backend/src/notifications/notifications.service.spec.ts new file mode 100644 index 0000000..b1892c6 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notifications.service.spec.ts @@ -0,0 +1,74 @@ +import { NotFoundException } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('NotificationsService', () => { + let service: NotificationsService; + let prisma: { + notification: { + create: jest.Mock; + findMany: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + }; + + beforeEach(() => { + prisma = { + notification: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + service = new NotificationsService(prisma as unknown as PrismaService); + }); + + it('records a notification with the given priority and message', async () => { + const created = { + id: 'n1', + priority: 'KRITISCH', + message: 'Keine Königin gesehen', + }; + prisma.notification.create.mockResolvedValueOnce(created); + + const result = await service.record({ + priority: 'KRITISCH', + message: 'Keine Königin gesehen', + }); + + expect(result).toEqual(created); + expect(prisma.notification.create).toHaveBeenCalledWith({ + data: { priority: 'KRITISCH', message: 'Keine Königin gesehen' }, + }); + }); + + it('lists notifications for a user including broadcast notifications', async () => { + await service.findAll({ userId: 'user-1' }); + + const [[queryArg]] = prisma.notification.findMany.mock.calls as [ + [{ where: { OR?: { userId: string | null }[] } }], + ]; + expect(queryArg.where.OR).toEqual([{ userId: 'user-1' }, { userId: null }]); + }); + + it('throws NotFoundException when marking an unknown notification as read', async () => { + prisma.notification.findUnique.mockResolvedValueOnce(null); + await expect(service.markRead('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('marks a notification as read', async () => { + prisma.notification.findUnique.mockResolvedValueOnce({ + id: 'n1', + read: false, + }); + prisma.notification.update.mockResolvedValueOnce({ id: 'n1', read: true }); + + const result = await service.markRead('n1'); + + expect(result.read).toBe(true); + }); +}); diff --git a/ApiCore-Backend/src/notifications/notifications.service.ts b/ApiCore-Backend/src/notifications/notifications.service.ts new file mode 100644 index 0000000..3ef3608 --- /dev/null +++ b/ApiCore-Backend/src/notifications/notifications.service.ts @@ -0,0 +1,51 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Notification, NotificationPriority } from '../../generated/prisma'; + +export interface CreateNotificationInput { + userId?: string; + priority: NotificationPriority; + message: string; + relatedType?: string; + relatedId?: string; +} + +export interface NotificationFilter { + userId?: string; + unreadOnly?: boolean; +} + +@Injectable() +export class NotificationsService { + constructor(private readonly prisma: PrismaService) {} + + async record(input: CreateNotificationInput): Promise { + return this.prisma.notification.create({ data: { ...input } }); + } + + async findAll(filter: NotificationFilter = {}): Promise { + return this.prisma.notification.findMany({ + where: { + OR: filter.userId + ? [{ userId: filter.userId }, { userId: null }] + : undefined, + read: filter.unreadOnly ? false : undefined, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + } + + async markRead(id: string): Promise { + const notification = await this.prisma.notification.findUnique({ + where: { id }, + }); + if (!notification) { + throw new NotFoundException('Notification not found'); + } + return this.prisma.notification.update({ + where: { id }, + data: { read: true }, + }); + } +} diff --git a/ApiCore-Backend/test/notifications.e2e-spec.ts b/ApiCore-Backend/test/notifications.e2e-spec.ts new file mode 100644 index 0000000..d1b0765 --- /dev/null +++ b/ApiCore-Backend/test/notifications.e2e-spec.ts @@ -0,0 +1,81 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface NotificationBody { + id: string; + priority: string; + read: boolean; +} + +describe('Notifications (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `notifications-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let notificationId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + + const notification = await prisma.notification.create({ + data: { + userId: beekeeperId, + priority: 'KRITISCH', + message: `Kritischer Testalarm ${Date.now()}`, + }, + }); + notificationId = notification.id; + }); + + afterAll(async () => { + await prisma.notification.deleteMany({ where: { id: notificationId } }); + await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); + await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); + await prisma.user.deleteMany({ where: { id: beekeeperId } }); + await app.close(); + }); + + it('lists notifications for the authenticated user', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/notifications') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const notifications = response.body as NotificationBody[]; + expect( + notifications.some( + (notification) => + notification.id === notificationId && + notification.priority === 'KRITISCH', + ), + ).toBe(true); + }); + + it('marks a notification as read', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/notifications/${notificationId}/read`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(201); + const notification = response.body as NotificationBody; + expect(notification.read).toBe(true); + }); +}); diff --git a/TASKS.md b/TASKS.md index ba0cad8..0bb2d0e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -211,7 +211,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `NotificationsModule` (`Notification`: Priorität INFO/HINWEIS/WARNUNG/KRITISCH, Nachricht, verknüpftes Objekt, gelesen); erzeugt aus Automatisierungen/Schwellenwerten - Endpunkte: `GET /api/v1/notifications`, `POST /api/v1/notifications/:id/read` - Frontend: Glocken-Icon in der Navigation mit Liste/Badge für ungelesene Einträge -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 27. Statistik und Reporting