feat: «Recordármelo», a reminder of a ticket only for me
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>
This commit is contained in:
@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
|
||||
export * from './lib/follows/tree-follows';
|
||||
export * from './lib/ports/tree-follows.repository';
|
||||
export * from './lib/tree-follows.service';
|
||||
export * from './lib/reminders/issue-reminders';
|
||||
export * from './lib/ports/issue-reminders.repository';
|
||||
export * from './lib/issue-reminders.service';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { IssueReminder } from './ports/issue-reminders.repository';
|
||||
import {
|
||||
ClearIssueReminderCommand,
|
||||
GetIssueReminderQuery,
|
||||
GetMyRemindersQuery,
|
||||
SendIssueRemindersCommand,
|
||||
SetIssueReminderCommand,
|
||||
} from './reminders/issue-reminders';
|
||||
|
||||
/** «Remind me on Thursday»: one's own reminders on tickets. */
|
||||
@Injectable()
|
||||
export class IssueRemindersService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
set(
|
||||
userId: string,
|
||||
issueId: string,
|
||||
at: string,
|
||||
note: string | null,
|
||||
): Promise<IssueReminder> {
|
||||
return this.commands.execute(
|
||||
new SetIssueReminderCommand(userId, issueId, at, note),
|
||||
);
|
||||
}
|
||||
|
||||
clear(userId: string, issueId: string): Promise<void> {
|
||||
return this.commands.execute(
|
||||
new ClearIssueReminderCommand(userId, issueId),
|
||||
);
|
||||
}
|
||||
|
||||
of(userId: string, issueId: string): Promise<IssueReminder | null> {
|
||||
return this.queryBus.execute(new GetIssueReminderQuery(userId, issueId));
|
||||
}
|
||||
|
||||
mine(
|
||||
userId: string,
|
||||
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
|
||||
return this.queryBus.execute(new GetMyRemindersQuery(userId));
|
||||
}
|
||||
|
||||
/** Scheduler only. */
|
||||
sendDue(): Promise<number> {
|
||||
return this.commands.execute(new SendIssueRemindersCommand());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** «Remind me on Thursday»: one person's, on one ticket. */
|
||||
export interface IssueReminder {
|
||||
id: string;
|
||||
userId: string;
|
||||
issueId: string;
|
||||
projectId: string;
|
||||
/** When to tell (ISO, UTC). */
|
||||
at: string;
|
||||
/** What to remember, in one line. */
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
/** When it was told (null: still waiting). */
|
||||
sentAt: string | null;
|
||||
}
|
||||
|
||||
export abstract class IssueRemindersRepository {
|
||||
/** Each person has at most one waiting per ticket: setting it replaces it. */
|
||||
abstract save(reminder: IssueReminder): Promise<void>;
|
||||
abstract waitingFor(
|
||||
userId: string,
|
||||
issueId: string,
|
||||
): Promise<IssueReminder | null>;
|
||||
/** One person's waiting reminders, soonest first. */
|
||||
abstract waitingOf(userId: string): Promise<IssueReminder[]>;
|
||||
abstract remove(userId: string, issueId: string): Promise<void>;
|
||||
/** Waiting ones whose time has come, oldest first. */
|
||||
abstract due(now: string, limit: number): Promise<IssueReminder[]>;
|
||||
abstract markSent(id: string, at: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CqrsModule,
|
||||
IQueryHandler,
|
||||
QueryBus,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NotFoundError, ValidationError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssueReadModel,
|
||||
NotificationReadModel,
|
||||
} 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';
|
||||
import {
|
||||
GetMyRemindersQuery,
|
||||
ISSUE_REMINDERS,
|
||||
SendIssueRemindersCommand,
|
||||
SetIssueReminderCommand,
|
||||
} from './issue-reminders';
|
||||
|
||||
class InMemoryReminders extends IssueRemindersRepository {
|
||||
rows: IssueReminder[] = [];
|
||||
async save(reminder: IssueReminder) {
|
||||
await this.remove(reminder.userId, reminder.issueId);
|
||||
this.rows.push(reminder);
|
||||
}
|
||||
async waitingFor(userId: string, issueId: string) {
|
||||
return (
|
||||
this.rows.find(
|
||||
(r) => r.userId === userId && r.issueId === issueId && !r.sentAt,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
async waitingOf(userId: string) {
|
||||
return this.rows
|
||||
.filter((r) => r.userId === userId && !r.sentAt)
|
||||
.sort((a, b) => a.at.localeCompare(b.at));
|
||||
}
|
||||
async remove(userId: string, issueId: string) {
|
||||
this.rows = this.rows.filter(
|
||||
(r) => !(r.userId === userId && r.issueId === issueId && !r.sentAt),
|
||||
);
|
||||
}
|
||||
async due(now: string) {
|
||||
return this.rows.filter((r) => !r.sentAt && r.at <= now);
|
||||
}
|
||||
async markSent(id: string, at: string) {
|
||||
const row = this.rows.find((r) => r.id === id);
|
||||
if (row) row.sentAt = at;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ana reads i1 and i2; i2 goes to the trash in one test. */
|
||||
const issues = new Map<string, IssueReadModel>();
|
||||
|
||||
@QueryHandler(GetIssuesByIdsQuery)
|
||||
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
|
||||
async execute(query: GetIssuesByIdsQuery) {
|
||||
return query.viewerId === 'ana'
|
||||
? query.ids.flatMap((id) => issues.get(id) ?? [])
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
const inHours = (h: number) =>
|
||||
new Date(Date.now() + h * 3_600_000).toISOString();
|
||||
|
||||
async function setup() {
|
||||
issues.clear();
|
||||
for (const id of ['i1', 'i2'])
|
||||
issues.set(id, {
|
||||
id,
|
||||
key: id === 'i1' ? 'WEB-1' : 'WEB-2',
|
||||
title: 'Pagos',
|
||||
projectId: 'p1',
|
||||
projectKey: 'WEB',
|
||||
trashedAt: null,
|
||||
} as IssueReadModel);
|
||||
const rows = new InMemoryReminders();
|
||||
const sent: NotificationReadModel[] = [];
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...ISSUE_REMINDERS,
|
||||
FakeIssuesByIds,
|
||||
{ provide: IssueRemindersRepository, useValue: rows },
|
||||
{
|
||||
provide: Notifier,
|
||||
useValue: {
|
||||
send: async (n: NotificationReadModel[]) => void sent.push(...n),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
return {
|
||||
rows,
|
||||
sent,
|
||||
commands: moduleRef.get(CommandBus),
|
||||
queries: moduleRef.get(QueryBus),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ticket reminders', () => {
|
||||
it('keeps one per person and ticket, in the future and within a year', async () => {
|
||||
const { commands, queries } = await setup();
|
||||
await commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i1', inHours(2)),
|
||||
);
|
||||
await commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i1', inHours(5), ' Llamar a QA '),
|
||||
);
|
||||
await commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
|
||||
);
|
||||
const mine = await queries.execute(new GetMyRemindersQuery('ana'));
|
||||
expect(mine.map((m: { issue: IssueReadModel }) => m.issue.key)).toEqual([
|
||||
'WEB-2',
|
||||
'WEB-1',
|
||||
]);
|
||||
expect(mine[1].reminder.note).toBe('Llamar a QA');
|
||||
await expect(
|
||||
commands.execute(new SetIssueReminderCommand('ana', 'i1', inHours(-2))),
|
||||
).rejects.toBeInstanceOf(ValidationError);
|
||||
await expect(
|
||||
commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i1', inHours(24 * 400)),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ValidationError);
|
||||
await expect(
|
||||
commands.execute(new SetIssueReminderCommand('eve', 'i1', inHours(2))),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('tells the ones whose time came, once, and forgets those out of reach', async () => {
|
||||
const { commands, rows, sent } = await setup();
|
||||
await commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i1', inHours(1), 'Revisar el PR'),
|
||||
);
|
||||
await commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
|
||||
);
|
||||
await commands.execute(
|
||||
new SetIssueReminderCommand('ana', 'i1', inHours(1)),
|
||||
);
|
||||
expect(await commands.execute(new SendIssueRemindersCommand())).toBe(0);
|
||||
|
||||
issues.set('i2', {
|
||||
...(issues.get('i2') as IssueReadModel),
|
||||
trashedAt: 'x',
|
||||
});
|
||||
const later = inHours(2);
|
||||
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
|
||||
1,
|
||||
);
|
||||
expect(sent.map((n) => [n.userId, n.issueKey, n.summary])).toEqual([
|
||||
['ana', 'WEB-1', 'Recordatorio: WEB-1 · Pagos'],
|
||||
]);
|
||||
expect(rows.rows.every((r) => r.sentAt)).toBe(true);
|
||||
// Run again: nothing left.
|
||||
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
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,
|
||||
];
|
||||
@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
|
||||
import { IssueRemindersService } from './issue-reminders.service';
|
||||
import { TreeFollowsService } from './tree-follows.service';
|
||||
import { AlertsService } from './alerts.service';
|
||||
import { PinsService } from './pins.service';
|
||||
@@ -87,6 +89,7 @@ const SERVICES = [
|
||||
PinsService,
|
||||
AlertsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -120,6 +123,7 @@ const SERVICES = [
|
||||
...PINS,
|
||||
...ALERT_HANDLERS,
|
||||
...TREE_FOLLOWS,
|
||||
...ISSUE_REMINDERS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
IssueReminder,
|
||||
IssueRemindersRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels } from './read-model.schemas';
|
||||
|
||||
type Stored = IssueReminder & { _id?: unknown };
|
||||
|
||||
const strip = ({ _id: _ignored, ...reminder }: Stored): IssueReminder =>
|
||||
reminder;
|
||||
|
||||
@Injectable()
|
||||
export class MongoIssueRemindersRepository extends IssueRemindersRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.IssueReminder)
|
||||
private readonly reminders: Model<Stored>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async save(reminder: IssueReminder): Promise<void> {
|
||||
await this.remove(reminder.userId, reminder.issueId);
|
||||
await this.reminders.create(reminder);
|
||||
}
|
||||
|
||||
async waitingFor(
|
||||
userId: string,
|
||||
issueId: string,
|
||||
): Promise<IssueReminder | null> {
|
||||
const doc = await this.reminders
|
||||
.findOne({ userId, issueId, sentAt: null })
|
||||
.lean<Stored>();
|
||||
return doc ? strip(doc) : null;
|
||||
}
|
||||
|
||||
async waitingOf(userId: string): Promise<IssueReminder[]> {
|
||||
const docs = await this.reminders
|
||||
.find({ userId, sentAt: null })
|
||||
.sort({ at: 1 })
|
||||
.lean<Stored[]>();
|
||||
return docs.map(strip);
|
||||
}
|
||||
|
||||
async remove(userId: string, issueId: string): Promise<void> {
|
||||
await this.reminders.deleteMany({ userId, issueId, sentAt: null });
|
||||
}
|
||||
|
||||
async due(now: string, limit: number): Promise<IssueReminder[]> {
|
||||
const docs = await this.reminders
|
||||
.find({ sentAt: null, at: { $lte: now } })
|
||||
.sort({ at: 1 })
|
||||
.limit(limit)
|
||||
.lean<Stored[]>();
|
||||
return docs.map(strip);
|
||||
}
|
||||
|
||||
async markSent(id: string, at: string): Promise<void> {
|
||||
await this.reminders.updateOne({ id }, { $set: { sentAt: at } });
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const ReadModels = {
|
||||
AlertIntegration: 'AlertIntegration',
|
||||
AlertTrace: 'AlertTrace',
|
||||
TreeFollow: 'TreeFollow',
|
||||
IssueReminder: 'IssueReminder',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
@@ -370,6 +371,20 @@ export const TreeFollowSchema =
|
||||
SchemaFactory.createForClass(TreeFollowDocument);
|
||||
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'issue_reminders' })
|
||||
class IssueReminderDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String }) userId!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
@Prop({ type: String }) at!: string;
|
||||
@Prop({ type: String, default: null }) sentAt!: string | null;
|
||||
}
|
||||
export const IssueReminderSchema = SchemaFactory.createForClass(
|
||||
IssueReminderDocument,
|
||||
);
|
||||
IssueReminderSchema.index({ sentAt: 1, at: 1 });
|
||||
IssueReminderSchema.index({ userId: 1, issueId: 1 });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@@ -476,6 +491,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PinsRepository,
|
||||
AlertIntegrationsRepository,
|
||||
TreeFollowsRepository,
|
||||
IssueRemindersRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
AutomationsRepository,
|
||||
@@ -84,6 +85,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
@@ -166,6 +168,10 @@ const PORTS = [
|
||||
useClass: MongoAlertIntegrationsRepository,
|
||||
},
|
||||
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
||||
{
|
||||
provide: IssueRemindersRepository,
|
||||
useClass: MongoIssueRemindersRepository,
|
||||
},
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IssuesService,
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
IssueParentGql,
|
||||
IssueReminderGql,
|
||||
ReminderOnIssueGql,
|
||||
PresenceGql,
|
||||
} from './types';
|
||||
|
||||
@@ -65,6 +68,7 @@ export class IssueResolver {
|
||||
private readonly presence: PresenceService,
|
||||
private readonly pins: PinsService,
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
@@ -282,6 +286,17 @@ export class IssueResolver {
|
||||
return this.commentsService.decisions(viewerOf(me), issue.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => IssueReminderGql, {
|
||||
nullable: true,
|
||||
description: 'My waiting reminder on it (null without one or a token)',
|
||||
})
|
||||
myReminder(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() issue: IssueReadModel,
|
||||
) {
|
||||
return me ? this.reminders.of(me.id, issue.id) : null;
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean, {
|
||||
description:
|
||||
'Whether I follow it with everything under it (false without a token)',
|
||||
@@ -427,6 +442,35 @@ export class IssueResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Query(() => [ReminderOnIssueGql], {
|
||||
description: 'My waiting reminders on tickets, soonest first',
|
||||
})
|
||||
myReminders(@CurrentUser() me: User) {
|
||||
return this.reminders.mine(me.id);
|
||||
}
|
||||
|
||||
@Mutation(() => IssueReminderGql, {
|
||||
description:
|
||||
'Remind me of a ticket at a date and time (a notification), with an optional note. One per ticket: it replaces the waiting one',
|
||||
})
|
||||
setReminder(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('at', { description: 'ISO date-time, in the future' }) at: string,
|
||||
@Args('note', { nullable: true }) note: string | null,
|
||||
) {
|
||||
return this.reminders.set(me.id, issueId, at, note ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: 'Drop my reminder on a ticket' })
|
||||
async clearReminder(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
): Promise<boolean> {
|
||||
await this.reminders.clear(me.id, issueId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
|
||||
unwatchIssue(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -1377,3 +1377,19 @@ export class AlertIntegrationGql {
|
||||
typeId!: string | null;
|
||||
@Field(() => IssuePriority) priority!: IssuePriority;
|
||||
}
|
||||
|
||||
@ObjectType('IssueReminder', {
|
||||
description: 'My own reminder on a ticket («remind me on Thursday»)',
|
||||
})
|
||||
export class IssueReminderGql {
|
||||
@Field({ description: 'When it tells me (ISO, UTC)' }) at!: string;
|
||||
@Field(() => String, { nullable: true }) note!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('ReminderOnIssue', {
|
||||
description: 'One of my waiting reminders, with its ticket',
|
||||
})
|
||||
export class ReminderOnIssueGql {
|
||||
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
|
||||
@Field(() => IssueGql) issue!: IssueGql;
|
||||
}
|
||||
|
||||
@@ -1552,6 +1552,21 @@ export class PushEndpointDto {
|
||||
endpoint!: string;
|
||||
}
|
||||
|
||||
export class SetReminderDto {
|
||||
@ApiProperty({
|
||||
format: 'date-time',
|
||||
description: 'When to tell me; in the future, at most a year ahead',
|
||||
})
|
||||
@IsISO8601()
|
||||
at!: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 280, description: 'What to remember' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(280)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class ResolveThreadDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -1417,3 +1417,13 @@ export class AlertOutcomeDto {
|
||||
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
|
||||
issueKey!: string | null;
|
||||
}
|
||||
|
||||
export class IssueReminderDto {
|
||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) note!: string | null;
|
||||
}
|
||||
|
||||
export class ReminderOnIssueDto {
|
||||
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
||||
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
IssuesService,
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
PresenceService,
|
||||
ProjectsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
AddCommentDto,
|
||||
CreateIssueDto,
|
||||
DecisionsQueryDto,
|
||||
SetReminderDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
LinkIssueDto,
|
||||
@@ -66,6 +68,8 @@ import {
|
||||
CommentDto,
|
||||
CommentPageDto,
|
||||
DecisionDto,
|
||||
IssueReminderDto,
|
||||
ReminderOnIssueDto,
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
@@ -101,6 +105,7 @@ export class IssuesController {
|
||||
private readonly projects: ProjectsService,
|
||||
private readonly pins: PinsService,
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@@ -205,6 +210,55 @@ export class IssuesController {
|
||||
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
||||
}
|
||||
|
||||
@Get('me/reminders')
|
||||
@ApiOperation({ summary: 'My waiting reminders on tickets, soonest first' })
|
||||
@ApiOkResponse({ type: [ReminderOnIssueDto] })
|
||||
@ApiReadErrors()
|
||||
async myReminders(@CurrentUser() me: User) {
|
||||
const mine = await this.reminders.mine(me.id);
|
||||
const issues = await this.refs.issueDtos(
|
||||
mine.map((m) => m.issue),
|
||||
viewerOf(me),
|
||||
);
|
||||
return mine.map((m, i) => ({
|
||||
reminder: { at: m.reminder.at, note: m.reminder.note },
|
||||
issue: issues[i],
|
||||
}));
|
||||
}
|
||||
|
||||
@Put('issues/:issue/reminder')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Remind me of a ticket at a date and time',
|
||||
description:
|
||||
'A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.',
|
||||
})
|
||||
@ApiOkResponse({ type: IssueReminderDto })
|
||||
@ApiCommandErrors()
|
||||
async setReminder(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: SetReminderDto,
|
||||
) {
|
||||
const reminder = await this.reminders.set(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.at,
|
||||
body.note ?? null,
|
||||
);
|
||||
return { at: reminder.at, note: reminder.note };
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/reminder')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'Drop my reminder on a ticket' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async clearReminder(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue));
|
||||
}
|
||||
|
||||
@Put('issues/:issue/follow-tree')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IssueRemindersService } from '@task-stream/api/tracker/application';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/**
|
||||
* Tells people's own ticket reminders («remind me on Thursday») every
|
||||
* minute. Each is told once (its notification id), so several instances
|
||||
* running this at once only repeat lookups. REMINDERS_ENABLED=false turns it
|
||||
* off, with the due-date ones.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueReminderScheduler
|
||||
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(IssueReminderScheduler.name);
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false')
|
||||
return;
|
||||
this.timer = setInterval(() => void this.run(), MINUTE);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
onApplicationShutdown(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
// A slow round must not pile up with the next.
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const sent = await this.reminders.sendDue();
|
||||
if (sent) this.logger.log(`Ticket reminders sent (${sent})`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Ticket reminders failed: ${(error as Error).message}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller';
|
||||
import { CalendarResolver } from './graphql/calendar.resolver';
|
||||
import { ReminderScheduler } from './schedulers/reminder.scheduler';
|
||||
import { TrashScheduler } from './schedulers/trash.scheduler';
|
||||
import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler';
|
||||
import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
@@ -80,6 +81,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
// Timers: entry points like the HTTP and GraphQL ones.
|
||||
ReminderScheduler,
|
||||
TrashScheduler,
|
||||
IssueReminderScheduler,
|
||||
ProjectResolver,
|
||||
IssueResolver,
|
||||
CommentResolver,
|
||||
|
||||
Reference in New Issue
Block a user