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:
@@ -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