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,216 @@
|
||||
import type { Prisma } from '../../generated/prisma';
|
||||
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
|
||||
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
|
||||
import type { NotificationsService } from '../notifications/notifications.service';
|
||||
import { TaskDueHandlers } from './task-due.handlers';
|
||||
|
||||
function taskDue(
|
||||
taskId = 'task-1',
|
||||
dueOn = '2026-06-10',
|
||||
): AnyDomainEventEnvelope<'TaskDue'> {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
aggregateId: taskId,
|
||||
payload: { dueOn },
|
||||
actorUserId: null,
|
||||
correlationId: 'cor-1',
|
||||
occurredAt: new Date(),
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
type RecordCall = [string[], Record<string, unknown>, unknown];
|
||||
|
||||
describe('TaskDueHandlers', () => {
|
||||
let task: Record<string, unknown> | null;
|
||||
let completers: { id: string }[];
|
||||
let disabled: { userId: string }[];
|
||||
let taskFindUnique: jest.Mock;
|
||||
let userFindMany: jest.Mock;
|
||||
let preferenceFindMany: jest.Mock;
|
||||
let recordForUsers: jest.Mock;
|
||||
let handlers: TaskDueHandlers;
|
||||
const tx = {} as Prisma.TransactionClient;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ now: new Date('2026-06-10T08:00:00.000Z') });
|
||||
task = {
|
||||
id: 'task-1',
|
||||
title: 'Waben pruefen',
|
||||
status: 'OPEN',
|
||||
assignedUserId: 'u-assignee',
|
||||
dueDate: new Date('2026-06-10T00:00:00.000Z'),
|
||||
};
|
||||
completers = [{ id: 'u-a' }, { id: 'u-b' }];
|
||||
disabled = [];
|
||||
taskFindUnique = jest.fn(() => Promise.resolve(task));
|
||||
userFindMany = jest.fn(() => Promise.resolve(completers));
|
||||
preferenceFindMany = jest.fn(() => Promise.resolve(disabled));
|
||||
recordForUsers = jest.fn().mockResolvedValue([]);
|
||||
const client = {
|
||||
task: { findUnique: taskFindUnique },
|
||||
user: { findMany: userFindMany },
|
||||
notificationPreference: { findMany: preferenceFindMany },
|
||||
} as unknown as Prisma.TransactionClient;
|
||||
handlers = new TaskDueHandlers({
|
||||
recordForUsers,
|
||||
} as unknown as NotificationsService);
|
||||
Object.assign(tx, client);
|
||||
});
|
||||
|
||||
const firstCall = (): RecordCall =>
|
||||
recordForUsers.mock.calls[0] as RecordCall;
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('is registered as notifications.task-due for TaskDue', () => {
|
||||
const entries = domainEventHandlerMetadata(
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
TaskDueHandlers.prototype.taskDue,
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ types: ['TaskDue'], handlerId: 'notifications.task-due' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('notifies the assignee only', async () => {
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(userFindMany).not.toHaveBeenCalled();
|
||||
expect(recordForUsers).toHaveBeenCalledTimes(1);
|
||||
expect(firstCall()[0]).toEqual(['u-assignee']);
|
||||
});
|
||||
|
||||
it('without an assignee notifies holders of tasks:complete', async () => {
|
||||
task = { ...task, assignedUserId: null };
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(userFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
isActive: true,
|
||||
role: {
|
||||
status: 'ACTIVE',
|
||||
permissions: { some: { permission: { key: 'tasks:complete' } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(firstCall()[0]).toEqual(['u-a', 'u-b']);
|
||||
});
|
||||
|
||||
it('writes nothing for a user with taskDueEnabled false', async () => {
|
||||
task = { ...task, assignedUserId: null };
|
||||
disabled = [{ userId: 'u-a' }];
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(preferenceFindMany).toHaveBeenCalledWith({
|
||||
where: { userId: { in: ['u-a', 'u-b'] }, taskDueEnabled: false },
|
||||
select: { userId: true },
|
||||
});
|
||||
expect(firstCall()[0]).toEqual(['u-b']);
|
||||
});
|
||||
|
||||
it('writes nothing when the only recipient switched it off', async () => {
|
||||
disabled = [{ userId: 'u-assignee' }];
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(recordForUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the key task-due:<taskId>:<day> as prefix, so rows end in :<userId>', async () => {
|
||||
task = { ...task, dueDate: new Date('2026-06-09T00:00:00.000Z') };
|
||||
|
||||
await handlers.taskDue(taskDue('task-1', '2026-06-09'), tx);
|
||||
|
||||
expect(firstCall()[1]).toEqual({
|
||||
priority: 'HINWEIS',
|
||||
category: 'TASK_DUE',
|
||||
message: 'Fällig seit 09.06.: Waben pruefen',
|
||||
relatedType: 'Task',
|
||||
relatedId: 'task-1',
|
||||
link: '/tasks',
|
||||
requiredPermissions: ['tasks:read'],
|
||||
dedupeKeyPrefix: 'task-due:task-1:2026-06-09',
|
||||
});
|
||||
expect(firstCall()[2]).toBe(tx);
|
||||
});
|
||||
|
||||
it.each(['DONE', 'CANCELLED'])('skips a %s task', async (status) => {
|
||||
task = { ...task, status };
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(recordForUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says "Heute fällig" for an event of today', async () => {
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(firstCall()[1].message).toBe('Heute fällig: Waben pruefen');
|
||||
});
|
||||
|
||||
it('says "Fällig seit {TT.MM.}" for a catch-up event', async () => {
|
||||
task = { ...task, dueDate: new Date('2026-06-07T00:00:00.000Z') };
|
||||
|
||||
await handlers.taskDue(taskDue('task-1', '2026-06-07'), tx);
|
||||
|
||||
expect(firstCall()[1].message).toBe('Fällig seit 07.06.: Waben pruefen');
|
||||
});
|
||||
|
||||
it('skips a task whose due day was moved after the event', async () => {
|
||||
task = { ...task, dueDate: new Date('2026-06-20T00:00:00.000Z') };
|
||||
|
||||
await handlers.taskDue(taskDue('task-1', '2026-06-10'), tx);
|
||||
|
||||
expect(recordForUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips a task whose due date was removed', async () => {
|
||||
task = { ...task, dueDate: null };
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(recordForUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts an IN_PROGRESS task', async () => {
|
||||
task = { ...task, status: 'IN_PROGRESS' };
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(recordForUsers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips a task that no longer exists', async () => {
|
||||
task = null;
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(recordForUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cuts the title to 200 characters', async () => {
|
||||
task = { ...task, title: 'x'.repeat(300) };
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
const input = firstCall()[1];
|
||||
expect(input.message).toBe(`Heute fällig: ${'x'.repeat(200)}`);
|
||||
});
|
||||
|
||||
it('writes nothing when no user holds tasks:complete', async () => {
|
||||
task = { ...task, assignedUserId: null };
|
||||
completers = [];
|
||||
|
||||
await handlers.taskDue(taskDue(), tx);
|
||||
|
||||
expect(recordForUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user