feat: due-date reminders

Every hour the API reminds whoever has a ticket, and whoever follows it,
the day before its due date, on the day and once when it is overdue, in
the app and by e-mail for those who asked. Cards on the board flag what
is due soon or late.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:36:19 +02:00
parent d6a434ca1d
commit 71b807b94b
19 changed files with 371 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
export * from './notifier';
export * from './notifications.queries';
export * from './notifications.commands';
export * from './reminders';

View File

@@ -60,9 +60,8 @@ export class Notifier {
? { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME }
: refs.of(event.actorId);
const delivered: NotificationReadModel[] = [];
for (const draft of wanted) {
const notification: NotificationReadModel = {
await this.send(
wanted.map((draft) => ({
id: `${event.aggregateId}:${versionOf(event)}:${draft.userId}`,
userId: draft.userId,
kind: draft.kind,
@@ -71,13 +70,22 @@ export class Notifier {
summary: draft.summary,
readAt: null,
createdAt: event.occurredAt,
};
})),
);
}
/**
* Stores, pushes and e-mails ready-made notifications. Their id makes them
* idempotent: one that is already there is not sent again.
*/
async send(notifications: NotificationReadModel[]): Promise<void> {
const delivered: NotificationReadModel[] = [];
for (const notification of notifications) {
// Delivered again (at-least-once): already told, nothing to repeat.
if (!(await this.notifications.insert(notification))) continue;
await this.changeFeed.publishNotification(notification);
delivered.push(notification);
}
if (this.mailer.enabled && delivered.length) {
await this.email(delivered);
}

View File

@@ -0,0 +1,43 @@
import {
IssueReadModel,
NotificationKind,
} from '@task-stream/api/tracker/domain';
import { dueStage, nextDay, remindersFor } from './reminders';
const issue = (id: string, dueDate: string, watchers: string[] = []) =>
({
id,
key: `WEB-${id}`,
title: `T${id}`,
projectId: 'p',
projectKey: 'WEB',
dueDate,
assignee: { id: 'ana', name: 'Ana' },
watchers: watchers.map((w) => ({ id: w, name: w })),
}) as IssueReadModel;
describe('due reminders', () => {
it('tells the stage of a due date', () => {
expect(nextDay('2026-12-31')).toBe('2027-01-01');
expect(dueStage('2026-10-09', '2026-10-08')).toBe('tomorrow');
expect(dueStage('2026-10-08', '2026-10-08')).toBe('today');
expect(dueStage('2026-10-01', '2026-10-08')).toBe('overdue');
expect(dueStage('2026-10-20', '2026-10-08')).toBeNull();
});
it('reminds whoever has it and whoever follows it, once per stage', () => {
const reminders = remindersFor(
[issue('1', '2026-10-08', ['ana', 'bob']), issue('2', '2026-10-30')],
'2026-10-08',
'2026-10-08T07:00:00Z',
);
expect(reminders.map((r) => [r.userId, r.summary])).toEqual([
['ana', 'WEB-1 vence hoy: T1'],
['bob', 'WEB-1 vence hoy: T1'],
]);
expect(reminders[0]).toMatchObject({
kind: NotificationKind.REMINDER,
id: 'reminder:1:2026-10-08:today:ana',
});
});
});

View File

@@ -0,0 +1,111 @@
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
IssueReadModel,
NotificationKind,
NotificationReadModel,
SYSTEM_ACTOR_ID,
SYSTEM_ACTOR_NAME,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { Notifier } from './notifier';
/** Where a due date stands, from the reminder's point of view. */
export type DueStage = 'tomorrow' | 'today' | 'overdue';
const DAY = 86_400_000;
/** `YYYY-MM-DD` one day after `day`. */
export function nextDay(day: string): string {
return new Date(Date.parse(`${day}T00:00:00Z`) + DAY)
.toISOString()
.slice(0, 10);
}
export function dueStage(dueDate: string, today: string): DueStage | null {
const due = dueDate.slice(0, 10);
if (due < today) return 'overdue';
if (due === today) return 'today';
return due === nextDay(today) ? 'tomorrow' : null;
}
const day = (iso: string) =>
new Date(`${iso.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'short',
timeZone: 'UTC',
});
/**
* One reminder per ticket, due date and stage — the day before, on the day,
* and once when it is overdue — for whoever has it and whoever follows it.
* Ids make it idempotent: running it every hour, on every instance, is fine.
*/
export function remindersFor(
issues: IssueReadModel[],
today: string,
now: string,
): NotificationReadModel[] {
const out: NotificationReadModel[] = [];
for (const issue of issues) {
if (!issue.dueDate) continue;
const stage = dueStage(issue.dueDate, today);
if (!stage) continue;
const summary =
stage === 'tomorrow'
? `${issue.key} vence mañana: ${issue.title}`
: stage === 'today'
? `${issue.key} vence hoy: ${issue.title}`
: `${issue.key} venció el ${day(issue.dueDate)} y sigue abierto: ${issue.title}`;
const people = new Set(
[issue.assignee?.id, ...(issue.watchers ?? []).map((w) => w.id)].filter(
(id): id is string => !!id,
),
);
for (const userId of people)
out.push({
id: `reminder:${issue.id}:${issue.dueDate.slice(0, 10)}:${stage}:${userId}`,
userId,
kind: NotificationKind.REMINDER,
projectId: issue.projectId,
projectKey: issue.projectKey,
issueId: issue.id,
issueKey: issue.key,
actor: { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME },
summary,
readAt: null,
createdAt: now,
});
}
return out;
}
/** Scheduler only: tells people about tickets due tomorrow, today or overdue. */
export class SendDueRemindersCommand extends Command<number> {
constructor(
/** `YYYY-MM-DD` in the installation's time zone. */
public readonly today: string,
) {
super();
}
}
@CommandHandler(SendDueRemindersCommand)
export class SendDueRemindersCommandHandler
implements ICommandHandler<SendDueRemindersCommand>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly notifier: Notifier,
) {}
async execute(command: SendDueRemindersCommand): Promise<number> {
const due = await this.findIssues.findDueBy(nextDay(command.today));
const reminders = remindersFor(
due,
command.today,
new Date().toISOString(),
);
await this.notifier.send(reminders);
return reminders.length;
}
}