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

@@ -2776,6 +2776,283 @@
]
}
},
"/api/me/reminders": {
"get": {
"operationId": "IssuesController_myReminders",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReminderOnIssueDto"
}
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My waiting reminders on tickets, soonest first",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/reminder": {
"put": {
"description": "A notification (bell, push, e-mail) when the time comes. One per ticket: it replaces the waiting one. Private to me.",
"operationId": "IssuesController_setReminder",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetReminderDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IssueReminderDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Remind me of a ticket at a date and time",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_clearReminder",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Drop my reminder on a ticket",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/follow-tree": {
"put": {
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.",
@@ -16062,6 +16339,56 @@
"at"
]
},
"IssueReminderDto": {
"type": "object",
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"note": {
"type": "string",
"nullable": true
}
},
"required": [
"at",
"note"
]
},
"ReminderOnIssueDto": {
"type": "object",
"properties": {
"reminder": {
"$ref": "#/components/schemas/IssueReminderDto"
},
"issue": {
"$ref": "#/components/schemas/IssueDto"
}
},
"required": [
"reminder",
"issue"
]
},
"SetReminderDto": {
"type": "object",
"properties": {
"at": {
"type": "string",
"format": "date-time",
"description": "When to tell me; in the future, at most a year ahead"
},
"note": {
"type": "string",
"maxLength": 280,
"description": "What to remember"
}
},
"required": [
"at"
]
},
"IssuePageDto": {
"type": "object",
"properties": {

View File

@@ -705,6 +705,9 @@ type Issue {
"""Linked tickets I can see"""
linkedIssues: [Issue!]!
links: [IssueLink!]!
"""My waiting reminder on it (null without one or a token)"""
myReminder: IssueReminder
number: Int!
"""The epic or ticket this one breaks down"""
@@ -845,6 +848,13 @@ enum IssuePriority {
MEDIUM
}
"""My own reminder on a ticket («remind me on Thursday»)"""
type IssueReminder {
"""When it tells me (ISO, UTC)"""
at: String!
note: String
}
"""RECENT: last updated first. RANK: backlog order."""
enum IssueSort {
RANK
@@ -971,6 +981,9 @@ type Mutation {
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
"""Drop my reminder on a ticket"""
clearReminder(issueId: ID!): Boolean!
"""
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
"""
@@ -1103,6 +1116,16 @@ type Mutation {
"""Put a ticket in a sprint (null: back to the backlog)"""
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
"""
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(
"""ISO date-time, in the future"""
at: String!
issueId: ID!
note: String
): IssueReminder!
"""Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare!
@@ -1645,6 +1668,9 @@ type Query {
"""Projects where I am a member"""
myProjects: [Project!]!
"""My waiting reminders on tickets, soonest first"""
myReminders: [ReminderOnIssue!]!
"""My saved views of my work, across my projects"""
myViews: [MyView!]!
notificationSettings: NotificationSettings!
@@ -1779,6 +1805,12 @@ enum ReleaseStatus {
RELEASED
}
"""One of my waiting reminders, with its ticket"""
type ReminderOnIssue {
issue: Issue!
reminder: IssueReminder!
}
"""Tickets to look at now (worst first)"""
type Risks {
blocked: [InsightIssue!]!

View File

@@ -502,6 +502,44 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/me/reminders": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My waiting reminders on tickets, soonest first */
get: operations["IssuesController_myReminders"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/reminder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* 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.
*/
put: operations["IssuesController_setReminder"];
post?: never;
/** Drop my reminder on a ticket */
delete: operations["IssuesController_clearReminder"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/follow-tree": {
parameters: {
query?: never;
@@ -2913,6 +2951,24 @@ export interface components {
*/
at: string;
};
IssueReminderDto: {
/** Format: date-time */
at: string;
note: string | null;
};
ReminderOnIssueDto: {
reminder: components["schemas"]["IssueReminderDto"];
issue: components["schemas"]["IssueDto"];
};
SetReminderDto: {
/**
* Format: date-time
* @description When to tell me; in the future, at most a year ahead
*/
at: string;
/** @description What to remember */
note?: string;
};
IssuePageDto: {
items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */
@@ -5753,6 +5809,215 @@ export interface operations {
};
};
};
IssuesController_myReminders: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReminderOnIssueDto"][];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_setReminder: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SetReminderDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IssueReminderDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_clearReminder: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_followTree: {
parameters: {
query?: never;

View File

@@ -783,6 +783,41 @@ export class IssuesResolver {
);
}
@Tool({
name: 'remind_me',
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. clear: true drops it. Times are ISO with offset, e.g. 2026-10-23T09:00:00+02:00.',
paramsSchema: z.object({
issue: issueRef,
at: z.string().optional(),
note: z.string().max(280).optional(),
clear: z.boolean().default(false),
}),
annotations: { ...WRITE, idempotentHint: true },
})
remindMe(
params: { issue: string; at?: string; note?: string; clear: boolean },
ctx: McpContext,
) {
const client = this.api.as(ctx);
const path = { issue: params.issue };
return toolResult(async () => {
if (params.clear) {
await this.api.data(
client.DELETE('/api/issues/{issue}/reminder', { params: { path } }),
);
return { issue: params.issue, reminder: null };
}
if (!params.at) throw new Error('at is required unless clear is true');
return this.api.data(
client.PUT('/api/issues/{issue}/reminder', {
params: { path },
body: { at: params.at, note: params.note },
}),
);
});
}
@Tool({
name: 'resolve_thread',
description:

View File

@@ -425,7 +425,12 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
`?watching=true`; sin sesión, ninguno). **Recordármelo**: cada persona pone en un ticket un recordatorio con fecha y hora y nota opcional
(`setReminder(issueId, at, note)`, `clearReminder`, `Issue.myReminder`, `myReminders`; `PUT/DELETE
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`);
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
@@ -544,17 +549,17 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
| Ámbito | Mutations |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions
@@ -752,25 +757,25 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
| Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto

View File

@@ -300,25 +300,25 @@ visualización de la plataforma:
## 4.6 Rutas
| Ruta | Página |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas) |
| `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
| Ruta | Página |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional |
| `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de

View File

@@ -68,6 +68,7 @@ flowchart LR
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
| | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla |
| | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) |
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |

View File

@@ -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';

View File

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

View File

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

View File

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

View File

@@ -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,
];

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,12 @@ export const issuePageEvents = eventGroup({
watchRequested: type<{ issueId: string; watching: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(),
followTreeRequested: type<{ issueId: string; on: boolean }>(),
reminderRequested: type<{
issueId: string;
/** null: drop it. */
at: string | null;
note: string | null;
}>(),
trashRequested: type<{
issueId: string;
trashed: boolean;

View File

@@ -120,6 +120,17 @@ export class IssueFacade {
);
}
/** Remind me of the ticket then (null: drop it); one per ticket. */
setReminder(at: string | null, note: string | null = null): void {
this.withIssue((issueId) =>
this.dispatch.reminderRequested({
issueId,
at,
note: note?.trim() || null,
}),
);
}
/** Follow the ticket with everything under it (an epic), or stop. */
setFollowingTree(on: boolean): void {
this.withIssue((issueId) =>

View File

@@ -22,6 +22,7 @@ import {
IssueSummary,
SprintsGateway,
TrackerRealtimeGateway,
whenLabel,
} from '@task-stream/web/tracker/domain';
import {
concatMap,
@@ -379,6 +380,26 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.reminderRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
payload.at
? issues.setReminder(
payload.issueId,
payload.at,
payload.note,
)
: issues.clearReminder(payload.issueId),
payload.at
? `Te lo recordaré ${whenLabel(payload.at)}`
: 'Recordatorio quitado',
done,
failed,
),
),
),
events
.on(issuePageEvents.followTreeRequested)
.pipe(

View File

@@ -1,6 +1,7 @@
export * from './lib/models/enums';
export * from './lib/models/models';
export * from './lib/models/bulk';
export * from './lib/models/reminders';
export * from './lib/models/inputs';
export * from './lib/models/workflow';
export * from './lib/ports/gateways';

View File

@@ -196,6 +196,8 @@ export interface Issue extends IssueSummary {
pinned: boolean;
/** Whether I follow it with everything under it (an epic). */
followingTree: boolean;
/** My waiting reminder on it («remind me on Thursday»). */
myReminder: IssueReminder | null;
/** Its comments marked as decisions, oldest first. */
decisions: Comment[];
checklist: ChecklistItem[];
@@ -206,6 +208,13 @@ export interface Issue extends IssueSummary {
}
/** Someone else on a ticket's page right now. */
/** My own reminder on a ticket. */
export interface IssueReminder {
/** ISO date-time. */
at: string;
note: string | null;
}
/** Someone writing on a ticket right now. */
export interface Typing {
user: UserRef;

View File

@@ -0,0 +1,40 @@
import { reminderChoices } from './reminders';
const local = (y: number, m: number, d: number, h: number, min = 0) =>
new Date(y, m - 1, d, h, min);
describe('reminder choices', () => {
it('offers this evening only while there is time', () => {
// Tuesday 20 October 2026.
const morning = reminderChoices(local(2026, 10, 20, 10)).map(
(c) => c.label,
);
expect(morning).toEqual([
'Dentro de 1 hora',
'Esta tarde, a las 18:00',
'Mañana a las 9:00',
'El lunes a las 9:00',
'Dentro de una semana',
]);
const late = reminderChoices(local(2026, 10, 20, 19)).map((c) => c.label);
expect(late).not.toContain('Esta tarde, a las 18:00');
});
it('lands on the right days and hours, local time', () => {
const [, evening, tomorrow, monday, week] = reminderChoices(
local(2026, 10, 20, 10, 30),
).map((c) => new Date(c.at));
expect([evening.getDate(), evening.getHours()]).toEqual([20, 18]);
expect([tomorrow.getDate(), tomorrow.getHours()]).toEqual([21, 9]);
expect([monday.getDay(), monday.getDate(), monday.getHours()]).toEqual([
1, 26, 9,
]);
expect([week.getDate(), week.getHours()]).toEqual([27, 9]);
});
it('does not offer Monday twice on a Sunday (it is tomorrow)', () => {
const sunday = reminderChoices(local(2026, 10, 25, 10)).map((c) => c.label);
expect(sunday.filter((l) => l.includes('lunes'))).toEqual([]);
expect(sunday).toContain('Mañana a las 9:00');
});
});

View File

@@ -0,0 +1,48 @@
/** A ready-made moment for «remind me…». */
export interface ReminderChoice {
label: string;
/** ISO date-time. */
at: string;
}
/** «el jue 23 a las 9:00», in the browser's time zone. */
export function whenLabel(at: string): string {
const date = new Date(at);
const day = date.toLocaleDateString('es', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
const time = date.toLocaleTimeString('es', {
hour: '2-digit',
minute: '2-digit',
});
return `el ${day} a las ${time}`;
}
/** `now` moved `days` days on, at `hour`:00 (local time). */
function at(now: Date, days: number, hour: number): Date {
const date = new Date(now);
date.setDate(date.getDate() + days);
date.setHours(hour, 0, 0, 0);
return date;
}
/**
* The usual moments, from `now` in local time: in an hour, this evening
* (while there is time), tomorrow and next Monday at 9, and in a week.
*/
export function reminderChoices(now: Date = new Date()): ReminderChoice[] {
const choices: { label: string; at: Date }[] = [
{ label: 'Dentro de 1 hora', at: new Date(now.getTime() + 3_600_000) },
];
if (now.getHours() < 17)
choices.push({ label: 'Esta tarde, a las 18:00', at: at(now, 0, 18) });
choices.push({ label: 'Mañana a las 9:00', at: at(now, 1, 9) });
// Monday is 1; from Sunday it is tomorrow, from Monday next week.
const toMonday = (8 - now.getDay()) % 7 || 7;
if (toMonday > 1)
choices.push({ label: 'El lunes a las 9:00', at: at(now, toMonday, 9) });
choices.push({ label: 'Dentro de una semana', at: at(now, 7, 9) });
return choices.map((c) => ({ label: c.label, at: c.at.toISOString() }));
}

View File

@@ -150,6 +150,13 @@ export abstract class IssuesGateway {
abstract pin(issueId: string, pinned: boolean): Observable<void>;
/** Follows the ticket with everything under it (or stops). */
abstract followTree(issueId: string, on: boolean): Observable<void>;
/** Reminds me of the ticket then (replaces the waiting one). */
abstract setReminder(
issueId: string,
at: string,
note: string | null,
): Observable<void>;
abstract clearReminder(issueId: string): Observable<void>;
/**
* Decisions (comments marked as such) of all my projects by their text,
* most recent first: here and not in CommentsGateway, for the palette.

View File

@@ -52,6 +52,8 @@ import {
WatchIssueDocument,
MyPinsDocument,
FollowTreeDocument,
SetReminderDocument,
ClearReminderDocument,
UnfollowTreeDocument,
SearchDecisionsDocument,
PinIssueDocument,
@@ -220,6 +222,22 @@ export class GraphqlIssuesGateway extends IssuesGateway {
),
);
}
setReminder(
issueId: string,
at: string,
note: string | null,
): Observable<void> {
return this.gql
.mutate(SetReminderDocument, { issueId, at, note })
.pipe(map(() => undefined));
}
clearReminder(issueId: string): Observable<void> {
return this.gql
.mutate(ClearReminderDocument, { issueId })
.pipe(map(() => undefined));
}
followTree(issueId: string, on: boolean): Observable<void> {
const done$: Observable<unknown> = on
? this.gql.mutate(FollowTreeDocument, { issueId })

View File

@@ -122,6 +122,10 @@ fragment IssueFields on Issue {
...IssueSummaryFields
pinned
followingTree
myReminder {
at
note
}
decisions {
...CommentFields
}

View File

@@ -714,6 +714,7 @@ export type IssueFieldsFragment = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
decisions: Array<{
id: string;
issueId: string;
@@ -960,6 +961,7 @@ export type IssueQuery = {
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
myReminder: { at: string; note: string | null } | null;
decisions: Array<{
id: string;
issueId: string;
@@ -1153,6 +1155,20 @@ export type MyPinsQuery = {
}>;
};
export type SetReminderMutationVariables = Exact<{
issueId: string;
at: string;
note?: string | null | undefined;
}>;
export type SetReminderMutation = { setReminder: { at: string } };
export type ClearReminderMutationVariables = Exact<{
issueId: string;
}>;
export type ClearReminderMutation = { clearReminder: boolean };
export type FollowTreeMutationVariables = Exact<{
issueId: string;
}>;
@@ -3836,6 +3852,17 @@ export const IssueFieldsFragmentDoc = {
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'myReminder' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -6437,6 +6464,17 @@ export const IssueDocument = {
},
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'myReminder' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'decisions' },
@@ -7469,6 +7507,131 @@ export const MyPinsDocument = {
},
],
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
export const SetReminderDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SetReminder' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'at' } },
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'setReminder' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'at' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'at' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'note' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'note' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<SetReminderMutation, SetReminderMutationVariables>;
export const ClearReminderDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ClearReminder' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'clearReminder' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
ClearReminderMutation,
ClearReminderMutationVariables
>;
export const FollowTreeDocument = {
kind: 'Document',
definitions: [

View File

@@ -101,6 +101,16 @@ query MyPins {
}
}
mutation SetReminder($issueId: ID!, $at: String!, $note: String) {
setReminder(issueId: $issueId, at: $at, note: $note) {
at
}
}
mutation ClearReminder($issueId: ID!) {
clearReminder(issueId: $issueId)
}
mutation FollowTree($issueId: ID!) {
followTree(issueId: $issueId)
}

View File

@@ -768,3 +768,72 @@ a.ts-key {
border-radius: 50%;
background: var(--ts-c-green, #2e9e6a);
}
/* My own reminder on the ticket. */
.ts-reminder {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.625rem;
margin: 0;
padding: 0.5rem 0.875rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent);
font-size: 0.875rem;
}
/* Small text buttons, not @pi/ui's sized ones. */
.ts-reminder__action {
height: auto !important;
min-width: 0 !important;
padding: 0 !important;
border: 0;
background: none !important;
color: var(--ts-brand);
font: inherit;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.ts-reminder__choices {
display: grid;
gap: 0.375rem;
}
.ts-reminder__choice {
display: flex;
justify-content: space-between;
gap: 1rem;
height: auto !important;
min-width: 0 !important;
padding: 0.625rem 0.875rem !important;
border: 1px solid var(--ts-line);
border-radius: 0.5rem;
background: var(--ts-surface);
color: var(--ts-ink);
font: inherit;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ts-brand);
}
}
.ts-reminder__field {
display: grid;
gap: 0.375rem;
color: var(--ts-muted);
font-size: 0.8125rem;
.ts-input {
font: inherit;
color: var(--ts-ink);
}
}

View File

@@ -53,7 +53,10 @@ import {
isTyping,
POINT_SCALE,
ReleaseStatus,
ReminderChoice,
reminderChoices,
StatusCategory,
whenLabel,
} from '@task-stream/web/tracker/domain';
import {
clock,
@@ -206,6 +209,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())"
/>
}
@if (project.myRole()) {
<pi-icon-button
icon="bell"
color="grey"
appearance="outline"
[text]="issue.myReminder ? 'Recordatorio' : 'Recordármelo'"
[attr.aria-pressed]="!!issue.myReminder"
title="Avisarme de este ticket cuando yo diga"
(execute)="openReminder()"
/>
}
@if (
project.myRole() &&
(facade.children().length || issue.followingTree)
@@ -265,6 +279,30 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
</div>
</header>
@if (issue.myReminder; as reminder) {
<p class="ts-reminder" role="status">
<span aria-hidden="true">⏰</span>
Te lo recordaré {{ when(reminder.at) }}
@if (reminder.note) {
· «{{ reminder.note }}»
}
<button
type="button"
class="ts-reminder__action"
(click)="openReminder()"
>
Cambiar
</button>
<button
type="button"
class="ts-reminder__action"
(click)="facade.setReminder(null)"
>
Quitar
</button>
</p>
}
@if (project.canContribute() && issue.allowedTransitions.length) {
<div class="ts-transitions" role="group" aria-label="Mover a">
<span class="ts-eyebrow">Mover a</span>
@@ -1163,6 +1201,58 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-skeleton shape="text" [lines]="8" />
}
<ng-template #reminderForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveReminder(reminderAt())"
novalidate
>
<p class="ts-muted">
Te llegará un aviso (en la campana y, si los tienes activados, en este
dispositivo o por correo). Solo lo ves tú.
</p>
<div class="ts-reminder__choices" role="group" aria-label="Cuándo">
@for (choice of reminderChoices(); track choice.label) {
<button
type="button"
class="ts-reminder__choice"
(click)="saveReminder(choice.at)"
>
<span>{{ choice.label }}</span>
<span class="ts-muted">{{ when(choice.at) }}</span>
</button>
}
</div>
<label class="ts-reminder__field">
Otro momento
<input
class="ts-input"
type="datetime-local"
[min]="nowLocal()"
[value]="reminderAt()"
(input)="reminderAt.set($any($event.target).value)"
/>
</label>
<label class="ts-reminder__field">
Nota (opcional)
<input
class="ts-input"
maxlength="280"
placeholder="Llamar a QA antes de la release"
[value]="reminderNote()"
(input)="reminderNote.set($any($event.target).value)"
/>
</label>
<pi-action-button
size="w100"
color="primary"
text="Recordármelo"
[disabled]="!reminderAt()"
(execute)="saveReminder(reminderAt())"
/>
</form>
</ng-template>
<ng-template #reasonForm>
<form
class="ts-form"
@@ -1264,6 +1354,13 @@ export class IssuePage {
private readonly route = inject(ActivatedRoute);
private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly reminderForm =
viewChild.required<TemplateRef<unknown>>('reminderForm');
/** The reminder being written: `datetime-local` value and note. */
protected readonly reminderAt = signal('');
protected readonly reminderNote = signal('');
protected readonly reminderChoices = signal<ReminderChoice[]>([]);
protected readonly when = whenLabel;
private readonly editForm =
viewChild.required<TemplateRef<unknown>>('editForm');
private readonly commentEditForm =
@@ -1638,6 +1735,28 @@ export class IssuePage {
this.parentKey.set(null);
}
protected openReminder(): void {
const current = this.facade.issue()?.myReminder;
this.reminderChoices.set(reminderChoices());
this.reminderAt.set(current ? toLocalInput(new Date(current.at)) : '');
this.reminderNote.set(current?.note ?? '');
this.drawer.open('Recordármelo', this.reminderForm());
}
/** `at`: ISO, or the `datetime-local` value (local time). */
protected saveReminder(at: string): void {
if (!at) return;
const when = new Date(at);
if (Number.isNaN(when.getTime())) return;
this.facade.setReminder(when.toISOString(), this.reminderNote());
this.drawer.closeAll();
}
/** Now, as the `min` of a `datetime-local` input. */
protected nowLocal(): string {
return toLocalInput(new Date());
}
protected move(to: string): void {
const open = this.openThreads();
if (
@@ -1934,3 +2053,9 @@ export class IssuePage {
this.drawer.closeAll();
}
}
/** A date as a `datetime-local` value (local time, to the minute). */
function toLocalInput(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}