import { randomUUID } from 'node:crypto'; import { Command, CommandHandler, ICommandHandler, IQueryHandler, Query, QueryBus, QueryHandler, } from '@nestjs/cqrs'; import { assertThat, NotFoundError } from '@task-stream/api/shared/domain'; import { IssueReadModel, NotificationKind, SYSTEM_ACTOR_ID, SYSTEM_ACTOR_NAME, } from '@task-stream/api/tracker/domain'; import { Notifier } from '../notifications/notifier'; import { IssueReminder, IssueRemindersRepository, } from '../ports/issue-reminders.repository'; import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; /* * «Remind me on Thursday»: a person's own reminder on a ticket, told as a * notification (bell, push, e-mail) when its time comes. One waiting per * person and ticket; private, no history. */ /** As far ahead as a reminder goes. */ export const MAX_REMINDER_DAYS = 366; export const MAX_REMINDER_NOTE = 280; export class SetIssueReminderCommand extends Command { constructor( public readonly userId: string, public readonly issueId: string, /** ISO date-time. */ public readonly at: string, public readonly note: string | null = null, ) { super(); } } export class ClearIssueReminderCommand extends Command { constructor( public readonly userId: string, public readonly issueId: string, ) { super(); } } /** Scheduler only: tells the reminders whose time has come. */ export class SendIssueRemindersCommand extends Command { constructor(public readonly now: string = new Date().toISOString()) { super(); } } export class GetIssueReminderQuery extends Query { constructor( public readonly userId: string, public readonly issueId: string, ) { super(); } } /** My waiting reminders with their tickets, soonest first. */ export class GetMyRemindersQuery extends Query< { reminder: IssueReminder; issue: IssueReadModel }[] > { constructor(public readonly userId: string) { super(); } } @CommandHandler(SetIssueReminderCommand) export class SetIssueReminderCommandHandler implements ICommandHandler { constructor( private readonly reminders: IssueRemindersRepository, private readonly queryBus: QueryBus, ) {} async execute(command: SetIssueReminderCommand): Promise { const at = Date.parse(command.at); const now = Date.now(); assertThat(!Number.isNaN(at), 'A reminder needs a date and time'); assertThat(at > now - 60_000, 'A reminder must be in the future'); assertThat( at <= now + MAX_REMINDER_DAYS * 86_400_000, `A reminder can be at most a year ahead`, ); const note = command.note?.replace(/\s+/g, ' ').trim() || null; assertThat( !note || note.length <= MAX_REMINDER_NOTE, `A reminder's note takes ${MAX_REMINDER_NOTE} characters at most`, ); const [issue]: IssueReadModel[] = await this.queryBus.execute( new GetIssuesByIdsQuery(command.userId, [command.issueId]), ); // A ticket I cannot read does not exist, as far as I know. if (!issue || issue.trashedAt) throw new NotFoundError('Issue', command.issueId); const reminder: IssueReminder = { id: randomUUID(), userId: command.userId, issueId: issue.id, projectId: issue.projectId, at: new Date(at).toISOString(), note, createdAt: new Date(now).toISOString(), sentAt: null, }; await this.reminders.save(reminder); return reminder; } } @CommandHandler(ClearIssueReminderCommand) export class ClearIssueReminderCommandHandler implements ICommandHandler { constructor(private readonly reminders: IssueRemindersRepository) {} execute(command: ClearIssueReminderCommand): Promise { return this.reminders.remove(command.userId, command.issueId); } } @CommandHandler(SendIssueRemindersCommand) export class SendIssueRemindersCommandHandler implements ICommandHandler { constructor( private readonly reminders: IssueRemindersRepository, private readonly queryBus: QueryBus, private readonly notifier: Notifier, ) {} async execute(command: SendIssueRemindersCommand): Promise { const due = await this.reminders.due(command.now, 200); let sent = 0; for (const reminder of due) { const [issue]: IssueReadModel[] = await this.queryBus.execute( new GetIssuesByIdsQuery(reminder.userId, [reminder.issueId]), ); // Out of reach or in the trash by now: nothing to remind of. if (issue && !issue.trashedAt) { // Its id makes it idempotent: two instances tell it once. await this.notifier.send([ { id: `issue-reminder:${reminder.id}`, userId: reminder.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: reminder.note ? `Recordatorio de ${issue.key}: ${reminder.note}` : `Recordatorio: ${issue.key} · ${issue.title}`, readAt: null, createdAt: command.now, }, ]); sent++; } await this.reminders.markSent(reminder.id, command.now); } return sent; } } @QueryHandler(GetIssueReminderQuery) export class GetIssueReminderQueryHandler implements IQueryHandler { constructor(private readonly reminders: IssueRemindersRepository) {} execute(query: GetIssueReminderQuery): Promise { return this.reminders.waitingFor(query.userId, query.issueId); } } @QueryHandler(GetMyRemindersQuery) export class GetMyRemindersQueryHandler implements IQueryHandler< GetMyRemindersQuery, { reminder: IssueReminder; issue: IssueReadModel }[] > { constructor( private readonly reminders: IssueRemindersRepository, private readonly queryBus: QueryBus, ) {} async execute( query: GetMyRemindersQuery, ): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> { const waiting = await this.reminders.waitingOf(query.userId); if (!waiting.length) return []; const issues: IssueReadModel[] = await this.queryBus.execute( new GetIssuesByIdsQuery( query.userId, waiting.map((r) => r.issueId), ), ); const byId = new Map( issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]), ); return waiting.flatMap((reminder) => { const issue = byId.get(reminder.issueId); return issue ? [{ reminder, issue }] : []; }); } } export const ISSUE_REMINDERS = [ SetIssueReminderCommandHandler, ClearIssueReminderCommandHandler, SendIssueRemindersCommandHandler, GetIssueReminderQueryHandler, GetMyRemindersQueryHandler, ];