On a ticket, «Recordármelo» offers in an hour, this evening, tomorrow or
next Monday at 9, in a week, or any date and time, with an optional note.
The ticket shows the waiting one («Te lo recordaré el mié 21 a las 9:00»)
to change or drop. Every minute a scheduler tells the ones whose time
came as a REMINDER notification (bell, push, e-mail), once, and forgets
those whose ticket went to the trash or out of reach. One per person and
ticket, private, no history (Mongo `issue_reminders`).
API: setReminder / clearReminder, Issue.myReminder, myReminders;
PUT/DELETE /api/issues/{issue}/reminder, GET /api/me/reminders. MCP:
remind_me.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
6.9 KiB
TypeScript
233 lines
6.9 KiB
TypeScript
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<IssueReminder> {
|
|
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<void> {
|
|
constructor(
|
|
public readonly userId: string,
|
|
public readonly issueId: string,
|
|
) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
/** Scheduler only: tells the reminders whose time has come. */
|
|
export class SendIssueRemindersCommand extends Command<number> {
|
|
constructor(public readonly now: string = new Date().toISOString()) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
export class GetIssueReminderQuery extends Query<IssueReminder | null> {
|
|
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<SetIssueReminderCommand>
|
|
{
|
|
constructor(
|
|
private readonly reminders: IssueRemindersRepository,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
async execute(command: SetIssueReminderCommand): Promise<IssueReminder> {
|
|
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<ClearIssueReminderCommand>
|
|
{
|
|
constructor(private readonly reminders: IssueRemindersRepository) {}
|
|
|
|
execute(command: ClearIssueReminderCommand): Promise<void> {
|
|
return this.reminders.remove(command.userId, command.issueId);
|
|
}
|
|
}
|
|
|
|
@CommandHandler(SendIssueRemindersCommand)
|
|
export class SendIssueRemindersCommandHandler
|
|
implements ICommandHandler<SendIssueRemindersCommand>
|
|
{
|
|
constructor(
|
|
private readonly reminders: IssueRemindersRepository,
|
|
private readonly queryBus: QueryBus,
|
|
private readonly notifier: Notifier,
|
|
) {}
|
|
|
|
async execute(command: SendIssueRemindersCommand): Promise<number> {
|
|
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<GetIssueReminderQuery, IssueReminder | null>
|
|
{
|
|
constructor(private readonly reminders: IssueRemindersRepository) {}
|
|
|
|
execute(query: GetIssueReminderQuery): Promise<IssueReminder | null> {
|
|
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,
|
|
];
|