Files
task-stream/libs/api/tracker/application/src/lib/notifications/reminders.ts
Josep Piguillem 71b807b94b 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>
2026-10-08 18:36:19 +02:00

112 lines
3.3 KiB
TypeScript

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;
}
}