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:
1 parent
ef33e5549b
commit
32c61c8e14
77 files changed
+5309
-142
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -200,10 +200,16 @@ describe('Worker runtime (e2e)', () => {
|
||||
where: { aggregateId: `${suffix}-0` },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
status: 'DONE',
|
||||
deliveries: [{ handler: 'test.worker-counter', status: 'DONE' }],
|
||||
});
|
||||
// TaskDue also has the real reminder handler (the task does not exist: a no-op).
|
||||
expect(event.status).toBe('DONE');
|
||||
expect(
|
||||
event.deliveries.map(({ handler, status }) => ({ handler, status })),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ handler: 'test.worker-counter', status: 'DONE' },
|
||||
{ handler: 'notifications.task-due', status: 'DONE' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('wakes the calendar outbox loop on a calendar_sync notification', async () => {
|
||||
|
||||
Reference in new issue
Block a user