feat(backend): Kalender (Modul 25)

CalendarModule mit GET /calendar?from=&to=&apiaryId= aggregiert
Durchsichten, Aufgaben, Behandlungen, Fuetterungen, Ernten und
Koeniginnen-Zuweisungen zu einem einheitlichen Ereignisfeed. Rein
lesende Aggregation, kein neues Datenmodell.
This commit is contained in:
Nick Szaunig committed 2026-08-22 22:34:47 +02:00
1 parent 8b42a86115
commit fef1177a2c
7 files changed
+343 -1

No files matched your search

@@ -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,
});
}
}
@@ -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 {}
@@ -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);
});
});
@@ -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<CalendarEvent[]> {
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());
}
}