feat: T4 wave 6 - reminders and morning digest, next steps UI, warnings and notifications UI

- In-app reminders: task due (skips rescheduled tasks, catch-up wording)
  and a per-user morning digest step in the daily job
- Frontend: "Als Nächstes" on the colony page, "Heute" on the dashboard,
  inspection-due card, season chip, apiary overview fields, cleanup
  sheet for ended colonies
- Frontend: plausibility warnings that stay until dismissed, withdrawal
  spans in the calendar, notification bell with categories and links,
  notification settings in the profile
- ARCHITECTURE.md: T4a sections 11.1-11.10, assumptions 31-41, handler
  table and daily job step order

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 17:05:32 +02:00
1 parent ef33e5549b
commit 32c61c8e14
77 files changed
+5309 -142

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { App } from 'supertest/types';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import type { AnyDomainEventEnvelope } from '../src/domain-events/domain-event-types';
import { PrismaService } from '../src/prisma/prisma.service';
import { DigestService } from '../src/reminders/digest.service';
import { TaskDueHandlers } from '../src/reminders/task-due.handlers';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/**
* Fixed day in 2001 on which the swarm season begins by calendar
* (KIRSCHE 15 April), so an apiary's segment starts "today". Other files
* share the database: assertions only look at the rows and lines of this
* file's own users, apiary and tasks.
*/
const TODAY = '2001-04-15';
describe('Reminders (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let digests: DigestService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const roleIds: string[] = [];
const userIds: string[] = [];
const taskIds: string[] = [];
const colonyIds: string[] = [];
let apiaryId: string;
let apiaryName: string;
const users = {} as Record<
'full' | 'noInspection' | 'completer' | 'off' | 'nothing',
string
>;
async function createUser(
label: string,
permissions: string[],
): Promise<string> {
const role = await prisma.role.create({
data: {
key: `REMINDERS_${label.toUpperCase()}_${suffix}`,
name: `Reminders ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const user = await prisma.user.create({
data: {
email: `reminders-e2e-${label}-${suffix}@example.com`,
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
name: `Reminders ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return user.id;
}
async function createTask(
title: string,
assignedUserId: string | null,
dueDate: string,
): Promise<string> {
const task = await prisma.task.create({
data: { title, assignedUserId, dueDate: d(dueDate) },
});
taskIds.push(task.id);
return task.id;
}
async function recordTaskDue(taskId: string, dueOn: string): Promise<void> {
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn },
dedupeKey: `TaskDue:${taskId}:${dueOn}`,
}),
);
}
const notificationsOf = (userId: string, relatedId: string) =>
prisma.notification.findMany({ where: { userId, relatedId } });
const digestOf = (userId: string) =>
prisma.notification.findMany({
where: { userId, dedupeKey: `digest:${TODAY}:${userId}` },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
digests = app.get(DigestService);
users.full = await createUser('full', [
'tasks:read',
'colonies:read',
'inspections:read',
'apiaries:read',
]);
users.noInspection = await createUser('noinspection', [
'tasks:read',
'apiaries:read',
]);
users.completer = await createUser('completer', [
'tasks:read',
'tasks:complete',
]);
users.off = await createUser('off', ['tasks:read', 'apiaries:read']);
users.nothing = await createUser('nothing', ['colonies:read']);
await prisma.notificationPreference.create({
data: { userId: users.off, taskDueEnabled: false, digestEnabled: false },
});
apiaryName = `Reminders e2e ${suffix}`;
apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id;
for (const code of ['A', 'B']) {
const colony = await prisma.colony.create({
data: {
code: `REM-E2E-${suffix}-${code}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-03-01'),
},
});
colonyIds.push(colony.id);
await prisma.inspection.create({
data: {
colonyId: colony.id,
userId: users.full,
date: d('2001-03-10'),
},
});
}
});
afterAll(async () => {
await prisma.notification.deleteMany({
where: {
OR: [{ userId: { in: userIds } }, { relatedId: { in: taskIds } }],
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: taskIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
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: { in: roleIds } } });
await app.close();
});
it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', async () => {
const taskId = await createTask('Reminders e2e Waben', users.full, TODAY);
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
const rows = await notificationsOf(users.full, taskId);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
priority: 'HINWEIS',
category: 'TASK_DUE',
message: 'Fällig seit 15.04.: Reminders e2e Waben',
link: '/tasks',
relatedType: 'Task',
dedupeKey: `task-due:${taskId}:${TODAY}:${users.full}`,
requiredPermissions: ['tasks:read'],
});
// A redelivery runs the handler a second time.
const envelope: AnyDomainEventEnvelope<'TaskDue'> = {
id: 'redelivery',
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: taskId,
payload: { dueOn: TODAY },
actorUserId: null,
correlationId: 'redelivery',
occurredAt: new Date(),
attempts: 1,
};
await prisma.$transaction((tx) =>
app.get(TaskDueHandlers).taskDue(envelope, tx),
);
expect(await notificationsOf(users.full, taskId)).toHaveLength(1);
for (const other of [users.noInspection, users.completer, users.nothing]) {
expect(await notificationsOf(other, taskId)).toHaveLength(0);
}
});
it('a task without an assignee reaches holders of tasks:complete and not users who switched it off', async () => {
const taskId = await createTask('Reminders e2e offen', null, TODAY);
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
expect(await notificationsOf(users.completer, taskId)).toHaveLength(1);
for (const other of [users.full, users.noInspection, users.off]) {
expect(await notificationsOf(other, taskId)).toHaveLength(0);
}
});
it('writes nothing for a task that was completed before delivery', async () => {
const taskId = await createTask(
'Reminders e2e erledigt',
users.full,
TODAY,
);
await prisma.task.update({
where: { id: taskId },
data: { status: 'DONE', completedAt: new Date() },
});
await recordTaskDue(taskId, TODAY);
await drainDomainEvents(app, [taskId]);
expect(await notificationsOf(users.full, taskId)).toHaveLength(0);
});
it('running the digest step twice yields one digest per user', async () => {
// Plus the open task of the first test: three due, one overdue.
await createTask('Reminders e2e heute', users.full, TODAY);
await createTask('Reminders e2e ueberfaellig', users.full, '2001-04-10');
const own = [
users.full,
users.noInspection,
users.completer,
users.off,
users.nothing,
];
const first = await digests.runFor(TODAY, own);
const second = await digests.runFor(TODAY, own);
expect(first).toBeGreaterThanOrEqual(2);
expect(second).toBe(0);
for (const userId of own) {
expect((await digestOf(userId)).length).toBeLessThanOrEqual(1);
}
const [full] = await digestOf(users.full);
expect(full).toMatchObject({
priority: 'INFO',
category: 'DIGEST',
link: '/dashboard',
});
const lines = full.message.split('\n');
expect(lines[0]).toBe('Guten Morgen. Heute für dich:');
expect(lines).toContain('3 Aufgaben fällig, 1 davon überfällig.');
expect(lines).toContain(
`Durchsicht fällig: 2 Völker am Stand ${apiaryName}.`,
);
expect(lines).toContain(
`Stand ${apiaryName}: Schwarmzeit beginnt. Durchsicht alle 7 Tage.`,
);
expect(full.requiredPermissions).toEqual(
expect.arrayContaining([
'tasks:read',
'colonies:read',
'inspections:read',
'apiaries:read',
]),
);
});
it('the digest of a user without varroa or inspection rights has no such line', async () => {
await digests.runFor(TODAY, [users.noInspection]);
const [digest] = await digestOf(users.noInspection);
expect(digest.message).toContain(`Stand ${apiaryName}: Schwarmzeit`);
expect(digest.message).not.toContain('Durchsicht fällig');
expect(digest.requiredPermissions).not.toContain('inspections:read');
expect(digest.requiredPermissions).not.toContain('colonies:read');
});
it('builds no digest for a user who can see nothing in it, and none for a user who switched it off', async () => {
await digests.runFor(TODAY, [users.nothing, users.off]);
expect(await digestOf(users.nothing)).toHaveLength(0);
expect(await digestOf(users.off)).toHaveLength(0);
});
});