diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index b12efd2..0fa342b 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -25,6 +25,7 @@ import { QrModule } from './qr/qr.module'; 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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -55,6 +56,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; ProductsModule, BottlingModule, EquipmentModule, + CalendarModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/calendar/calendar.controller.ts b/ApiCore-Backend/src/calendar/calendar.controller.ts new file mode 100644 index 0000000..1cfbc27 --- /dev/null +++ b/ApiCore-Backend/src/calendar/calendar.controller.ts @@ -0,0 +1,28 @@ +import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CalendarService } from './calendar.service'; + +@ApiTags('calendar') +@ApiBearerAuth() +@Controller('calendar') +export class CalendarController { + constructor(private readonly calendarService: CalendarService) {} + + @Get() + getEvents( + @Query('from') from: string, + @Query('to') to: string, + @Query('apiaryId') apiaryId?: string, + ) { + if (!from || !to) { + throw new BadRequestException( + 'Query parameters from and to are required', + ); + } + return this.calendarService.getEvents({ + from: new Date(from), + to: new Date(to), + apiaryId, + }); + } +} diff --git a/ApiCore-Backend/src/calendar/calendar.module.ts b/ApiCore-Backend/src/calendar/calendar.module.ts new file mode 100644 index 0000000..674b0b8 --- /dev/null +++ b/ApiCore-Backend/src/calendar/calendar.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { CalendarController } from './calendar.controller'; +import { CalendarService } from './calendar.service'; + +@Module({ + controllers: [CalendarController], + providers: [CalendarService], +}) +export class CalendarModule {} diff --git a/ApiCore-Backend/src/calendar/calendar.service.spec.ts b/ApiCore-Backend/src/calendar/calendar.service.spec.ts new file mode 100644 index 0000000..8b507bf --- /dev/null +++ b/ApiCore-Backend/src/calendar/calendar.service.spec.ts @@ -0,0 +1,85 @@ +import { CalendarService } from './calendar.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('CalendarService', () => { + let service: CalendarService; + let prisma: { + task: { findMany: jest.Mock }; + inspection: { findMany: jest.Mock }; + treatment: { findMany: jest.Mock }; + feeding: { findMany: jest.Mock }; + harvest: { findMany: jest.Mock }; + queenColonyHistory: { findMany: jest.Mock }; + }; + + beforeEach(() => { + prisma = { + task: { findMany: jest.fn().mockResolvedValue([]) }, + inspection: { findMany: jest.fn().mockResolvedValue([]) }, + treatment: { findMany: jest.fn().mockResolvedValue([]) }, + feeding: { findMany: jest.fn().mockResolvedValue([]) }, + harvest: { findMany: jest.fn().mockResolvedValue([]) }, + queenColonyHistory: { findMany: jest.fn().mockResolvedValue([]) }, + }; + service = new CalendarService(prisma as unknown as PrismaService); + }); + + it('merges tasks, inspections, and harvests into one sorted event list', async () => { + prisma.task.findMany.mockResolvedValueOnce([ + { id: 't1', title: 'Kontrolle', dueDate: new Date('2026-06-05') }, + ]); + prisma.inspection.findMany.mockResolvedValueOnce([ + { + id: 'i1', + date: new Date('2026-06-01'), + colonyId: 'c1', + colony: { code: 'V-001' }, + }, + ]); + prisma.harvest.findMany.mockResolvedValueOnce([ + { id: 'h1', date: new Date('2026-06-10'), apiaryId: 'a1' }, + ]); + + const events = await service.getEvents({ + from: new Date('2026-06-01'), + to: new Date('2026-06-30'), + }); + + expect(events.map((event) => event.type)).toEqual([ + 'INSPECTION', + 'TASK', + 'HARVEST', + ]); + }); + + it('filters colony-linked events by apiary when apiaryId is given', async () => { + await service.getEvents({ + from: new Date('2026-06-01'), + to: new Date('2026-06-30'), + apiaryId: 'a1', + }); + + const [[inspectionArg]] = prisma.inspection.findMany.mock.calls as [ + [{ where: { colony?: { apiaryId?: string } } }], + ]; + expect(inspectionArg.where.colony).toEqual({ apiaryId: 'a1' }); + + const [[harvestArg]] = prisma.harvest.findMany.mock.calls as [ + [{ where: { apiaryId?: string } }], + ]; + expect(harvestArg.where.apiaryId).toBe('a1'); + }); + + it('excludes tasks without a due date', async () => { + prisma.task.findMany.mockResolvedValueOnce([ + { id: 't1', title: 'Ohne Datum', dueDate: null }, + ]); + + const events = await service.getEvents({ + from: new Date('2026-06-01'), + to: new Date('2026-06-30'), + }); + + expect(events).toHaveLength(0); + }); +}); diff --git a/ApiCore-Backend/src/calendar/calendar.service.ts b/ApiCore-Backend/src/calendar/calendar.service.ts new file mode 100644 index 0000000..3aa5ec4 --- /dev/null +++ b/ApiCore-Backend/src/calendar/calendar.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export type CalendarEventType = + | 'TASK' + | 'INSPECTION' + | 'TREATMENT' + | 'FEEDING' + | 'HARVEST' + | 'QUEEN_ASSIGNMENT'; + +export interface CalendarEvent { + id: string; + type: CalendarEventType; + date: Date; + title: string; + relatedType: string; + relatedId: string; +} + +export interface CalendarQuery { + from: Date; + to: Date; + apiaryId?: string; +} + +@Injectable() +export class CalendarService { + constructor(private readonly prisma: PrismaService) {} + + async getEvents(query: CalendarQuery): Promise { + const { from, to, apiaryId } = query; + const colonyFilter = apiaryId ? { colony: { apiaryId } } : {}; + + const [ + tasks, + inspections, + treatments, + feedings, + harvests, + queenAssignments, + ] = await Promise.all([ + this.prisma.task.findMany({ + where: { dueDate: { gte: from, lte: to } }, + }), + this.prisma.inspection.findMany({ + where: { date: { gte: from, lte: to }, ...colonyFilter }, + include: { colony: true }, + }), + this.prisma.treatment.findMany({ + where: { date: { gte: from, lte: to }, ...colonyFilter }, + include: { colony: true }, + }), + this.prisma.feeding.findMany({ + where: { date: { gte: from, lte: to }, ...colonyFilter }, + include: { colony: true }, + }), + this.prisma.harvest.findMany({ + where: { + date: { gte: from, lte: to }, + ...(apiaryId ? { apiaryId } : {}), + }, + }), + this.prisma.queenColonyHistory.findMany({ + where: { startDate: { gte: from, lte: to }, ...colonyFilter }, + include: { colony: true, queen: true }, + }), + ]); + + const events: CalendarEvent[] = [ + ...tasks + .filter((task) => task.dueDate !== null) + .map((task) => ({ + id: task.id, + type: 'TASK' as const, + date: task.dueDate!, + title: task.title, + relatedType: 'Task', + relatedId: task.id, + })), + ...inspections.map((inspection) => ({ + id: inspection.id, + type: 'INSPECTION' as const, + date: inspection.date, + title: `Durchsicht ${inspection.colony.code}`, + relatedType: 'Colony', + relatedId: inspection.colonyId, + })), + ...treatments.map((treatment) => ({ + id: treatment.id, + type: 'TREATMENT' as const, + date: treatment.date, + title: `Behandlung ${treatment.colony.code} · ${treatment.product}`, + relatedType: 'Colony', + relatedId: treatment.colonyId, + })), + ...feedings.map((feeding) => ({ + id: feeding.id, + type: 'FEEDING' as const, + date: feeding.date, + title: `Fütterung ${feeding.colony.code}`, + relatedType: 'Colony', + relatedId: feeding.colonyId, + })), + ...harvests.map((harvest) => ({ + id: harvest.id, + type: 'HARVEST' as const, + date: harvest.date, + title: 'Ernte', + relatedType: 'Apiary', + relatedId: harvest.apiaryId, + })), + ...queenAssignments.map((assignment) => ({ + id: assignment.id, + type: 'QUEEN_ASSIGNMENT' as const, + date: assignment.startDate, + title: `Königin ${assignment.queen.code} → ${assignment.colony.code}`, + relatedType: 'Queen', + relatedId: assignment.queenId, + })), + ]; + + return events.sort((a, b) => a.date.getTime() - b.date.getTime()); + } +} diff --git a/ApiCore-Backend/test/calendar.e2e-spec.ts b/ApiCore-Backend/test/calendar.e2e-spec.ts new file mode 100644 index 0000000..6390bd1 --- /dev/null +++ b/ApiCore-Backend/test/calendar.e2e-spec.ts @@ -0,0 +1,93 @@ +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 CalendarEventBody { + type: string; + title: string; +} + +describe('Calendar (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `calendar-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryId: string; + let colonyId: string; + let taskId: 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 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', + }, + }); + colonyId = colony.id; + const task = await prisma.task.create({ + data: { + title: `Kalendertest ${Date.now()}`, + dueDate: new Date('2027-06-15'), + createdByUserId: beekeeperId, + }, + }); + taskId = task.id; + }); + + afterAll(async () => { + await prisma.task.deleteMany({ where: { id: taskId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + 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('rejects the request when from/to are missing', () => { + return request(app.getHttpServer()) + .get('/api/v1/calendar') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + }); + + it('returns events for a task due within the queried range', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/calendar?from=2027-06-01&to=2027-06-30') + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const events = response.body as CalendarEventBody[]; + expect( + events.some( + (event) => + event.type === 'TASK' && event.title.includes('Kalendertest'), + ), + ).toBe(true); + }); +}); diff --git a/TASKS.md b/TASKS.md index 9723d8b..90e62f8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -203,7 +203,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `CalendarModule` – aggregiert Durchsichten, Aufgaben, Behandlungen, Fütterungen, Ernten, Königinnenarbeiten in einem Zeitfenster - Endpunkte: `GET /api/v1/calendar?from=&to=&apiaryId=` - Frontend: `/calendar` (Monats-/Wochenansicht) -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 26. Benachrichtigungssystem