diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 2aee6be..1b3e4c6 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -18,6 +18,7 @@ import { HarvestsModule } from './harvests/harvests.module'; import { ExtractionsModule } from './extractions/extractions.module'; import { HoneyBatchesModule } from './honey-batches/honey-batches.module'; import { StorageModule } from './storage/storage.module'; +import { DashboardModule } from './dashboard/dashboard.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -41,6 +42,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; ExtractionsModule, HoneyBatchesModule, StorageModule, + DashboardModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/dashboard/dashboard.controller.ts b/ApiCore-Backend/src/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..375de51 --- /dev/null +++ b/ApiCore-Backend/src/dashboard/dashboard.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { DashboardService } from './dashboard.service'; + +@ApiTags('dashboard') +@ApiBearerAuth() +@Controller('dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @Get('summary') + summary() { + return this.dashboardService.summary(); + } + + @Get('alerts') + alerts() { + return this.dashboardService.alerts(); + } +} diff --git a/ApiCore-Backend/src/dashboard/dashboard.module.ts b/ApiCore-Backend/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..c4a4a45 --- /dev/null +++ b/ApiCore-Backend/src/dashboard/dashboard.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; + +@Module({ + controllers: [DashboardController], + providers: [DashboardService], +}) +export class DashboardModule {} diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..43e5b33 --- /dev/null +++ b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts @@ -0,0 +1,66 @@ +import { DashboardService } from './dashboard.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('DashboardService', () => { + let service: DashboardService; + let prisma: { + apiary: { count: jest.Mock }; + colony: { count: jest.Mock; findMany: jest.Mock }; + queen: { count: jest.Mock }; + task: { count: jest.Mock; findMany: jest.Mock }; + honeyBatch: { count: jest.Mock }; + stockItem: { findMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + apiary: { count: jest.fn().mockResolvedValue(2) }, + colony: { + count: jest.fn().mockResolvedValue(5), + findMany: jest.fn().mockResolvedValue([]), + }, + queen: { count: jest.fn().mockResolvedValue(4) }, + task: { + count: jest.fn().mockResolvedValue(3), + findMany: jest.fn().mockResolvedValue([]), + }, + honeyBatch: { count: jest.fn().mockResolvedValue(1) }, + stockItem: { findMany: jest.fn().mockResolvedValue([]) }, + }; + service = new DashboardService(prisma as unknown as PrismaService); + }); + + it('summary counts low-stock items below their minimum threshold', async () => { + prisma.stockItem.findMany.mockResolvedValue([ + { id: 'item-1', quantity: 2, minThreshold: 5 }, + { id: 'item-2', quantity: 10, minThreshold: 5 }, + ]); + + const summary = await service.summary(); + + expect(summary.stockItemsLowCount).toBe(1); + expect(summary.apiariesActive).toBe(2); + expect(summary.coloniesActive).toBe(5); + }); + + it('alerts flags overdue tasks, attention colonies, and low stock', async () => { + prisma.task.findMany.mockResolvedValue([ + { id: 'task-1', title: 'Beute prüfen' }, + ]); + prisma.colony.findMany.mockResolvedValue([ + { id: 'colony-1', code: 'V-001', status: 'WEAK' }, + ]); + prisma.stockItem.findMany.mockResolvedValue([ + { id: 'item-1', name: 'Zuckerlösung', quantity: 2, minThreshold: 5 }, + ]); + + const alerts = await service.alerts(); + + expect(alerts).toHaveLength(3); + expect(alerts.map((alert) => alert.type)).toEqual([ + 'OVERDUE_TASK', + 'COLONY_ATTENTION', + 'LOW_STOCK', + ]); + }); +}); diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.ts b/ApiCore-Backend/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..0cd6129 --- /dev/null +++ b/ApiCore-Backend/src/dashboard/dashboard.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface DashboardSummary { + apiariesActive: number; + coloniesActive: number; + queensActive: number; + openTasks: number; + honeyBatchesTotal: number; + stockItemsLowCount: number; +} + +export interface DashboardAlert { + type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK'; + message: string; + relatedType: string; + relatedId: string; +} + +const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const; + +@Injectable() +export class DashboardService { + constructor(private readonly prisma: PrismaService) {} + + async summary(): Promise { + const [ + apiariesActive, + coloniesActive, + queensActive, + openTasks, + honeyBatchesTotal, + stockItems, + ] = await Promise.all([ + this.prisma.apiary.count({ where: { status: 'ACTIVE' } }), + this.prisma.colony.count({ where: { status: 'ACTIVE' } }), + this.prisma.queen.count({ + where: { status: { in: ['ACTIVE', 'RESERVE'] } }, + }), + this.prisma.task.count({ + where: { status: { in: ['OPEN', 'IN_PROGRESS'] } }, + }), + this.prisma.honeyBatch.count(), + this.prisma.stockItem.findMany({ + where: { minThreshold: { not: null } }, + }), + ]); + const stockItemsLowCount = stockItems.filter( + (item) => item.minThreshold !== null && item.quantity < item.minThreshold, + ).length; + + return { + apiariesActive, + coloniesActive, + queensActive, + openTasks, + honeyBatchesTotal, + stockItemsLowCount, + }; + } + + async alerts(): Promise { + const [overdueTasks, attentionColonies, stockItems] = await Promise.all([ + this.prisma.task.findMany({ + where: { + status: { in: ['OPEN', 'IN_PROGRESS'] }, + dueDate: { lt: new Date() }, + }, + }), + this.prisma.colony.findMany({ + where: { status: { in: [...ATTENTION_COLONY_STATUSES] } }, + }), + this.prisma.stockItem.findMany({ + where: { minThreshold: { not: null } }, + }), + ]); + + const alerts: DashboardAlert[] = []; + for (const task of overdueTasks) { + alerts.push({ + type: 'OVERDUE_TASK', + message: `Aufgabe "${task.title}" ist überfällig`, + relatedType: 'Task', + relatedId: task.id, + }); + } + for (const colony of attentionColonies) { + alerts.push({ + type: 'COLONY_ATTENTION', + message: `Volk ${colony.code} benötigt Aufmerksamkeit (${colony.status})`, + relatedType: 'Colony', + relatedId: colony.id, + }); + } + for (const item of stockItems) { + if (item.minThreshold !== null && item.quantity < item.minThreshold) { + alerts.push({ + type: 'LOW_STOCK', + message: `Lagerartikel "${item.name}" unter Mindestbestand`, + relatedType: 'StockItem', + relatedId: item.id, + }); + } + } + return alerts; + } +} diff --git a/ApiCore-Backend/test/dashboard.e2e-spec.ts b/ApiCore-Backend/test/dashboard.e2e-spec.ts new file mode 100644 index 0000000..e2412ed --- /dev/null +++ b/ApiCore-Backend/test/dashboard.e2e-spec.ts @@ -0,0 +1,92 @@ +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 SummaryBody { + apiariesActive: number; + coloniesActive: number; +} + +interface AlertBody { + type: string; + relatedType: string; + relatedId: string; +} + +describe('Dashboard (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const readonlyEmail = `dashboard-e2e-readonly-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let readonlyId: string; + let readonlyToken: string; + let apiaryId: string; + let colonyId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Readonly', + role: 'READONLY', + }, + }); + readonlyId = readonly.id; + readonlyToken = (await login(app, readonlyEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + status: 'WEAK', + }, + }); + colonyId = colony.id; + }); + + afterAll(async () => { + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId: readonlyId } }); + await prisma.auditLog.deleteMany({ where: { userId: readonlyId } }); + await prisma.user.deleteMany({ where: { id: readonlyId } }); + await app.close(); + }); + + it('returns aggregate counts in the summary', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/dashboard/summary') + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const summary = response.body as SummaryBody; + expect(summary.apiariesActive).toBeGreaterThanOrEqual(1); + }); + + it('flags a colony requiring attention in the alerts', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/dashboard/alerts') + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const alerts = response.body as AlertBody[]; + expect( + alerts.some( + (alert) => + alert.relatedType === 'Colony' && alert.relatedId === colonyId, + ), + ).toBe(true); + }); +}); diff --git a/TASKS.md b/TASKS.md index 30f72e2..eb3b2bf 100644 --- a/TASKS.md +++ b/TASKS.md @@ -114,7 +114,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `DashboardModule` - Endpunkte: `GET /api/v1/dashboard/summary`, `GET /api/v1/dashboard/alerts` - Frontend: `/dashboard` (Startseite nach Login) -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 14. Suche und Filter