feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI
- Colony end releases the queen in the same transaction; open suggestions become obsolete and a CLEANUP suggestion lists open tasks and the queen; apply-cleanup validates evidence and blocks reactivated colonies - Withdrawal chain: plausibility warnings on harvest and bottling, conflict notifications also when a treatment is entered or corrected after the harvest (naming bottled lots), withdrawal spans in the calendar and M365 sync, Berlin-day end events - Next 3 steps per colony and the "Heute" endpoint with ranking and an explicit merge table by intent - Inspection-triggered rule suggestions follow the colony's latest inspection (resolve when the finding is gone, refresh when it stays) - Frontend: colony card takeover in the inspection form, feed status as condition, occupied frames 0-40; legacy offline entries keep syncing - Notifications and preferences are deleted with their user Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
02eb16924e
commit
ef33e5549b
122 files changed
+10707
-504
No files matched your search
@@ -7,8 +7,13 @@ import { createTestApp } from './utils/create-test-app';
|
||||
import { login } from './utils/api';
|
||||
|
||||
interface CalendarEventBody {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
date: string;
|
||||
endDate?: string;
|
||||
relatedType: string;
|
||||
relatedId: string;
|
||||
}
|
||||
|
||||
describe('Calendar (e2e)', () => {
|
||||
@@ -21,6 +26,11 @@ describe('Calendar (e2e)', () => {
|
||||
let apiaryId: string;
|
||||
let colonyId: string;
|
||||
let taskId: string;
|
||||
let treatmentId: string;
|
||||
let colonyCode: string;
|
||||
let calendarOnlyRoleId: string;
|
||||
let calendarOnlyId: string;
|
||||
let calendarOnlyToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -50,6 +60,39 @@ describe('Calendar (e2e)', () => {
|
||||
},
|
||||
});
|
||||
colonyId = colony.id;
|
||||
colonyCode = colony.code;
|
||||
// 2027-06-10 + 10 days: harvestable again from 2027-06-20.
|
||||
const treatment = await prisma.treatment.create({
|
||||
data: {
|
||||
colonyId,
|
||||
userId: beekeeperId,
|
||||
date: new Date('2027-06-10T00:00:00.000Z'),
|
||||
product: 'Oxuvar',
|
||||
withdrawalPeriodDays: 10,
|
||||
},
|
||||
});
|
||||
treatmentId = treatment.id;
|
||||
const calendarOnlyRole = await prisma.role.create({
|
||||
data: {
|
||||
key: `CAL_ONLY_${Date.now()}`,
|
||||
name: 'Nur Kalender',
|
||||
permissions: {
|
||||
create: [{ permission: { connect: { key: 'calendar:read' } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
calendarOnlyRoleId = calendarOnlyRole.id;
|
||||
const calendarOnly = await prisma.user.create({
|
||||
data: {
|
||||
email: `calendar-e2e-only-${Date.now()}@example.com`,
|
||||
passwordHash,
|
||||
name: 'Calendar only',
|
||||
roleId: calendarOnlyRoleId,
|
||||
},
|
||||
});
|
||||
calendarOnlyId = calendarOnly.id;
|
||||
calendarOnlyToken = (await login(app, calendarOnly.email, password))
|
||||
.accessToken;
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
title: `Kalendertest ${Date.now()}`,
|
||||
@@ -62,11 +105,16 @@ describe('Calendar (e2e)', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.task.deleteMany({ where: { id: taskId } });
|
||||
await prisma.treatment.deleteMany({ where: { id: treatmentId } });
|
||||
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 } });
|
||||
const userIds = [beekeeperId, calendarOnlyId];
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { userId: { in: userIds } },
|
||||
});
|
||||
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
||||
await prisma.role.deleteMany({ where: { id: calendarOnlyRoleId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -90,4 +138,84 @@ describe('Calendar (e2e)', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', 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[];
|
||||
|
||||
const span = events.find(
|
||||
(event) =>
|
||||
event.type === 'WITHDRAWAL' && event.id === `withdrawal:${treatmentId}`,
|
||||
);
|
||||
expect(span).toMatchObject({
|
||||
title: `Wartezeit ${colonyCode} · Oxuvar`,
|
||||
relatedType: 'Colony',
|
||||
relatedId: colonyId,
|
||||
});
|
||||
expect(span?.date.slice(0, 10)).toBe('2027-06-10');
|
||||
// The last covered day, inclusive; 2027-06-20 is harvestable again.
|
||||
expect(span?.endDate?.slice(0, 10)).toBe('2027-06-19');
|
||||
});
|
||||
|
||||
it('shows the span in a range that only touches the end of the window', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-19&to=2027-06-25')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const events = response.body as CalendarEventBody[];
|
||||
|
||||
expect(
|
||||
events.some((event) => event.id === `withdrawal:${treatmentId}`),
|
||||
).toBe(true);
|
||||
const later = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-20&to=2027-06-25')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
expect(
|
||||
(later.body as CalendarEventBody[]).some(
|
||||
(event) => event.id === `withdrawal:${treatmentId}`,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides WITHDRAWAL from users without treatments:read', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${calendarOnlyToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(
|
||||
(response.body as CalendarEventBody[]).filter(
|
||||
(event) => event.type === 'WITHDRAWAL',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists TREATMENT events only for users with treatments:read', async () => {
|
||||
const asBeekeeper = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(200);
|
||||
const asCalendarOnly = await request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=2027-06-01&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${calendarOnlyToken}`)
|
||||
.expect(200);
|
||||
|
||||
const treatmentEvent = (body: unknown) =>
|
||||
(body as CalendarEventBody[]).filter(
|
||||
(event) => event.type === 'TREATMENT' && event.id === treatmentId,
|
||||
);
|
||||
expect(treatmentEvent(asBeekeeper.body)).toHaveLength(1);
|
||||
expect(treatmentEvent(asCalendarOnly.body)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an invalid date range', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/api/v1/calendar?from=nonsense&to=2027-06-30')
|
||||
.set('Authorization', `Bearer ${beekeeperToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user