- 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]>
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
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();
|
|
});
|
|
});
|