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>
112 lines
3.3 KiB
TypeScript
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;
|
|
}
|
|
}
|