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 { constructor( /** `YYYY-MM-DD` in the installation's time zone. */ public readonly today: string, ) { super(); } } @CommandHandler(SendDueRemindersCommand) export class SendDueRemindersCommandHandler implements ICommandHandler { constructor( private readonly findIssues: FindIssuesRepository, private readonly notifier: Notifier, ) {} async execute(command: SendDueRemindersCommand): Promise { 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; } }