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:
1 parent
8b42a86115
commit
fef1177a2c
7 files changed
+343
-1
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<App>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user