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:
2026-10-20 20:08:50 +02:00
parent d79ef418d3
commit b88296c22b
37 changed files with 2020 additions and 50 deletions

View File

@@ -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 } });
}
}

View File

@@ -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 },