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:
@@ -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": {
|
"/api/issues/{issue}/follow-tree": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.",
|
"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"
|
"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": {
|
"IssuePageDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -705,6 +705,9 @@ type Issue {
|
|||||||
"""Linked tickets I can see"""
|
"""Linked tickets I can see"""
|
||||||
linkedIssues: [Issue!]!
|
linkedIssues: [Issue!]!
|
||||||
links: [IssueLink!]!
|
links: [IssueLink!]!
|
||||||
|
|
||||||
|
"""My waiting reminder on it (null without one or a token)"""
|
||||||
|
myReminder: IssueReminder
|
||||||
number: Int!
|
number: Int!
|
||||||
|
|
||||||
"""The epic or ticket this one breaks down"""
|
"""The epic or ticket this one breaks down"""
|
||||||
@@ -845,6 +848,13 @@ enum IssuePriority {
|
|||||||
MEDIUM
|
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."""
|
"""RECENT: last updated first. RANK: backlog order."""
|
||||||
enum IssueSort {
|
enum IssueSort {
|
||||||
RANK
|
RANK
|
||||||
@@ -971,6 +981,9 @@ type Mutation {
|
|||||||
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
||||||
changeProjectMemberRole(input: ProjectMemberInput!): 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.
|
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)"""
|
"""Put a ticket in a sprint (null: back to the backlog)"""
|
||||||
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
|
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"""
|
"""Turn the link on or change its blocks; renew for a new secret"""
|
||||||
shareDashboard(input: ShareDashboardInput!): DashboardShare!
|
shareDashboard(input: ShareDashboardInput!): DashboardShare!
|
||||||
|
|
||||||
@@ -1645,6 +1668,9 @@ type Query {
|
|||||||
"""Projects where I am a member"""
|
"""Projects where I am a member"""
|
||||||
myProjects: [Project!]!
|
myProjects: [Project!]!
|
||||||
|
|
||||||
|
"""My waiting reminders on tickets, soonest first"""
|
||||||
|
myReminders: [ReminderOnIssue!]!
|
||||||
|
|
||||||
"""My saved views of my work, across my projects"""
|
"""My saved views of my work, across my projects"""
|
||||||
myViews: [MyView!]!
|
myViews: [MyView!]!
|
||||||
notificationSettings: NotificationSettings!
|
notificationSettings: NotificationSettings!
|
||||||
@@ -1779,6 +1805,12 @@ enum ReleaseStatus {
|
|||||||
RELEASED
|
RELEASED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""One of my waiting reminders, with its ticket"""
|
||||||
|
type ReminderOnIssue {
|
||||||
|
issue: Issue!
|
||||||
|
reminder: IssueReminder!
|
||||||
|
}
|
||||||
|
|
||||||
"""Tickets to look at now (worst first)"""
|
"""Tickets to look at now (worst first)"""
|
||||||
type Risks {
|
type Risks {
|
||||||
blocked: [InsightIssue!]!
|
blocked: [InsightIssue!]!
|
||||||
|
|||||||
@@ -502,6 +502,44 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/issues/{issue}/follow-tree": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2913,6 +2951,24 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
at: string;
|
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: {
|
IssuePageDto: {
|
||||||
items: components["schemas"]["IssueDto"][];
|
items: components["schemas"]["IssueDto"][];
|
||||||
/** @description Total matches (for pagination) */
|
/** @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: {
|
IssuesController_followTree: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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({
|
@Tool({
|
||||||
name: 'resolve_thread',
|
name: 'resolve_thread',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -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,
|
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
|
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 })`,
|
`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
|
`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
|
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
|
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
|
||||||
@@ -545,9 +550,9 @@ que funcionan también sin token.
|
|||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| 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` |
|
| 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)` |
|
| 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` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
@@ -753,10 +758,10 @@ 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`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| 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) |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
|
|||||||
@@ -301,14 +301,14 @@ visualización de la plataforma:
|
|||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/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» |
|
| `/` | 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 |
|
| `/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) |
|
| `/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/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/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/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/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/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 |
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ flowchart LR
|
|||||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||||
| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla |
|
| | `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 |
|
| | `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 |
|
| | `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 |
|
| | `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 |
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
|
|||||||
export * from './lib/follows/tree-follows';
|
export * from './lib/follows/tree-follows';
|
||||||
export * from './lib/ports/tree-follows.repository';
|
export * from './lib/ports/tree-follows.repository';
|
||||||
export * from './lib/tree-follows.service';
|
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/tracker-application.module';
|
||||||
export * from './lib/ports/presence.store';
|
export * from './lib/ports/presence.store';
|
||||||
export * from './lib/presence.service';
|
export * from './lib/presence.service';
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { QueryBus } from '@nestjs/cqrs';
|
||||||
|
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
import { IssueReminder } from './ports/issue-reminders.repository';
|
||||||
|
import {
|
||||||
|
ClearIssueReminderCommand,
|
||||||
|
GetIssueReminderQuery,
|
||||||
|
GetMyRemindersQuery,
|
||||||
|
SendIssueRemindersCommand,
|
||||||
|
SetIssueReminderCommand,
|
||||||
|
} from './reminders/issue-reminders';
|
||||||
|
|
||||||
|
/** «Remind me on Thursday»: one's own reminders on tickets. */
|
||||||
|
@Injectable()
|
||||||
|
export class IssueRemindersService {
|
||||||
|
constructor(
|
||||||
|
private readonly commands: CommandRunner,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
set(
|
||||||
|
userId: string,
|
||||||
|
issueId: string,
|
||||||
|
at: string,
|
||||||
|
note: string | null,
|
||||||
|
): Promise<IssueReminder> {
|
||||||
|
return this.commands.execute(
|
||||||
|
new SetIssueReminderCommand(userId, issueId, at, note),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(userId: string, issueId: string): Promise<void> {
|
||||||
|
return this.commands.execute(
|
||||||
|
new ClearIssueReminderCommand(userId, issueId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
of(userId: string, issueId: string): Promise<IssueReminder | null> {
|
||||||
|
return this.queryBus.execute(new GetIssueReminderQuery(userId, issueId));
|
||||||
|
}
|
||||||
|
|
||||||
|
mine(
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
|
||||||
|
return this.queryBus.execute(new GetMyRemindersQuery(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduler only. */
|
||||||
|
sendDue(): Promise<number> {
|
||||||
|
return this.commands.execute(new SendIssueRemindersCommand());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** «Remind me on Thursday»: one person's, on one ticket. */
|
||||||
|
export interface IssueReminder {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
issueId: string;
|
||||||
|
projectId: string;
|
||||||
|
/** When to tell (ISO, UTC). */
|
||||||
|
at: string;
|
||||||
|
/** What to remember, in one line. */
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
/** When it was told (null: still waiting). */
|
||||||
|
sentAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class IssueRemindersRepository {
|
||||||
|
/** Each person has at most one waiting per ticket: setting it replaces it. */
|
||||||
|
abstract save(reminder: IssueReminder): Promise<void>;
|
||||||
|
abstract waitingFor(
|
||||||
|
userId: string,
|
||||||
|
issueId: string,
|
||||||
|
): Promise<IssueReminder | null>;
|
||||||
|
/** One person's waiting reminders, soonest first. */
|
||||||
|
abstract waitingOf(userId: string): Promise<IssueReminder[]>;
|
||||||
|
abstract remove(userId: string, issueId: string): Promise<void>;
|
||||||
|
/** Waiting ones whose time has come, oldest first. */
|
||||||
|
abstract due(now: string, limit: number): Promise<IssueReminder[]>;
|
||||||
|
abstract markSent(id: string, at: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
CommandBus,
|
||||||
|
CqrsModule,
|
||||||
|
IQueryHandler,
|
||||||
|
QueryBus,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { NotFoundError, ValidationError } from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
NotificationReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { Notifier } from '../notifications/notifier';
|
||||||
|
import {
|
||||||
|
IssueReminder,
|
||||||
|
IssueRemindersRepository,
|
||||||
|
} from '../ports/issue-reminders.repository';
|
||||||
|
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||||
|
import {
|
||||||
|
GetMyRemindersQuery,
|
||||||
|
ISSUE_REMINDERS,
|
||||||
|
SendIssueRemindersCommand,
|
||||||
|
SetIssueReminderCommand,
|
||||||
|
} from './issue-reminders';
|
||||||
|
|
||||||
|
class InMemoryReminders extends IssueRemindersRepository {
|
||||||
|
rows: IssueReminder[] = [];
|
||||||
|
async save(reminder: IssueReminder) {
|
||||||
|
await this.remove(reminder.userId, reminder.issueId);
|
||||||
|
this.rows.push(reminder);
|
||||||
|
}
|
||||||
|
async waitingFor(userId: string, issueId: string) {
|
||||||
|
return (
|
||||||
|
this.rows.find(
|
||||||
|
(r) => r.userId === userId && r.issueId === issueId && !r.sentAt,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async waitingOf(userId: string) {
|
||||||
|
return this.rows
|
||||||
|
.filter((r) => r.userId === userId && !r.sentAt)
|
||||||
|
.sort((a, b) => a.at.localeCompare(b.at));
|
||||||
|
}
|
||||||
|
async remove(userId: string, issueId: string) {
|
||||||
|
this.rows = this.rows.filter(
|
||||||
|
(r) => !(r.userId === userId && r.issueId === issueId && !r.sentAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async due(now: string) {
|
||||||
|
return this.rows.filter((r) => !r.sentAt && r.at <= now);
|
||||||
|
}
|
||||||
|
async markSent(id: string, at: string) {
|
||||||
|
const row = this.rows.find((r) => r.id === id);
|
||||||
|
if (row) row.sentAt = at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ana reads i1 and i2; i2 goes to the trash in one test. */
|
||||||
|
const issues = new Map<string, IssueReadModel>();
|
||||||
|
|
||||||
|
@QueryHandler(GetIssuesByIdsQuery)
|
||||||
|
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
|
||||||
|
async execute(query: GetIssuesByIdsQuery) {
|
||||||
|
return query.viewerId === 'ana'
|
||||||
|
? query.ids.flatMap((id) => issues.get(id) ?? [])
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inHours = (h: number) =>
|
||||||
|
new Date(Date.now() + h * 3_600_000).toISOString();
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
issues.clear();
|
||||||
|
for (const id of ['i1', 'i2'])
|
||||||
|
issues.set(id, {
|
||||||
|
id,
|
||||||
|
key: id === 'i1' ? 'WEB-1' : 'WEB-2',
|
||||||
|
title: 'Pagos',
|
||||||
|
projectId: 'p1',
|
||||||
|
projectKey: 'WEB',
|
||||||
|
trashedAt: null,
|
||||||
|
} as IssueReadModel);
|
||||||
|
const rows = new InMemoryReminders();
|
||||||
|
const sent: NotificationReadModel[] = [];
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [CqrsModule.forRoot()],
|
||||||
|
providers: [
|
||||||
|
...ISSUE_REMINDERS,
|
||||||
|
FakeIssuesByIds,
|
||||||
|
{ provide: IssueRemindersRepository, useValue: rows },
|
||||||
|
{
|
||||||
|
provide: Notifier,
|
||||||
|
useValue: {
|
||||||
|
send: async (n: NotificationReadModel[]) => void sent.push(...n),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
await moduleRef.init();
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
sent,
|
||||||
|
commands: moduleRef.get(CommandBus),
|
||||||
|
queries: moduleRef.get(QueryBus),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ticket reminders', () => {
|
||||||
|
it('keeps one per person and ticket, in the future and within a year', async () => {
|
||||||
|
const { commands, queries } = await setup();
|
||||||
|
await commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i1', inHours(2)),
|
||||||
|
);
|
||||||
|
await commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i1', inHours(5), ' Llamar a QA '),
|
||||||
|
);
|
||||||
|
await commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
|
||||||
|
);
|
||||||
|
const mine = await queries.execute(new GetMyRemindersQuery('ana'));
|
||||||
|
expect(mine.map((m: { issue: IssueReadModel }) => m.issue.key)).toEqual([
|
||||||
|
'WEB-2',
|
||||||
|
'WEB-1',
|
||||||
|
]);
|
||||||
|
expect(mine[1].reminder.note).toBe('Llamar a QA');
|
||||||
|
await expect(
|
||||||
|
commands.execute(new SetIssueReminderCommand('ana', 'i1', inHours(-2))),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
await expect(
|
||||||
|
commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i1', inHours(24 * 400)),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ValidationError);
|
||||||
|
await expect(
|
||||||
|
commands.execute(new SetIssueReminderCommand('eve', 'i1', inHours(2))),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the ones whose time came, once, and forgets those out of reach', async () => {
|
||||||
|
const { commands, rows, sent } = await setup();
|
||||||
|
await commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i1', inHours(1), 'Revisar el PR'),
|
||||||
|
);
|
||||||
|
await commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i2', inHours(1)),
|
||||||
|
);
|
||||||
|
await commands.execute(
|
||||||
|
new SetIssueReminderCommand('ana', 'i1', inHours(1)),
|
||||||
|
);
|
||||||
|
expect(await commands.execute(new SendIssueRemindersCommand())).toBe(0);
|
||||||
|
|
||||||
|
issues.set('i2', {
|
||||||
|
...(issues.get('i2') as IssueReadModel),
|
||||||
|
trashedAt: 'x',
|
||||||
|
});
|
||||||
|
const later = inHours(2);
|
||||||
|
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(sent.map((n) => [n.userId, n.issueKey, n.summary])).toEqual([
|
||||||
|
['ana', 'WEB-1', 'Recordatorio: WEB-1 · Pagos'],
|
||||||
|
]);
|
||||||
|
expect(rows.rows.every((r) => r.sentAt)).toBe(true);
|
||||||
|
// Run again: nothing left.
|
||||||
|
expect(await commands.execute(new SendIssueRemindersCommand(later))).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandHandler,
|
||||||
|
ICommandHandler,
|
||||||
|
IQueryHandler,
|
||||||
|
Query,
|
||||||
|
QueryBus,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import { assertThat, NotFoundError } from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
NotificationKind,
|
||||||
|
SYSTEM_ACTOR_ID,
|
||||||
|
SYSTEM_ACTOR_NAME,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { Notifier } from '../notifications/notifier';
|
||||||
|
import {
|
||||||
|
IssueReminder,
|
||||||
|
IssueRemindersRepository,
|
||||||
|
} from '../ports/issue-reminders.repository';
|
||||||
|
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* «Remind me on Thursday»: a person's own reminder on a ticket, told as a
|
||||||
|
* notification (bell, push, e-mail) when its time comes. One waiting per
|
||||||
|
* person and ticket; private, no history.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** As far ahead as a reminder goes. */
|
||||||
|
export const MAX_REMINDER_DAYS = 366;
|
||||||
|
export const MAX_REMINDER_NOTE = 280;
|
||||||
|
|
||||||
|
export class SetIssueReminderCommand extends Command<IssueReminder> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
/** ISO date-time. */
|
||||||
|
public readonly at: string,
|
||||||
|
public readonly note: string | null = null,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClearIssueReminderCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduler only: tells the reminders whose time has come. */
|
||||||
|
export class SendIssueRemindersCommand extends Command<number> {
|
||||||
|
constructor(public readonly now: string = new Date().toISOString()) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetIssueReminderQuery extends Query<IssueReminder | null> {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** My waiting reminders with their tickets, soonest first. */
|
||||||
|
export class GetMyRemindersQuery extends Query<
|
||||||
|
{ reminder: IssueReminder; issue: IssueReadModel }[]
|
||||||
|
> {
|
||||||
|
constructor(public readonly userId: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(SetIssueReminderCommand)
|
||||||
|
export class SetIssueReminderCommandHandler
|
||||||
|
implements ICommandHandler<SetIssueReminderCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly reminders: IssueRemindersRepository,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SetIssueReminderCommand): Promise<IssueReminder> {
|
||||||
|
const at = Date.parse(command.at);
|
||||||
|
const now = Date.now();
|
||||||
|
assertThat(!Number.isNaN(at), 'A reminder needs a date and time');
|
||||||
|
assertThat(at > now - 60_000, 'A reminder must be in the future');
|
||||||
|
assertThat(
|
||||||
|
at <= now + MAX_REMINDER_DAYS * 86_400_000,
|
||||||
|
`A reminder can be at most a year ahead`,
|
||||||
|
);
|
||||||
|
const note = command.note?.replace(/\s+/g, ' ').trim() || null;
|
||||||
|
assertThat(
|
||||||
|
!note || note.length <= MAX_REMINDER_NOTE,
|
||||||
|
`A reminder's note takes ${MAX_REMINDER_NOTE} characters at most`,
|
||||||
|
);
|
||||||
|
const [issue]: IssueReadModel[] = await this.queryBus.execute(
|
||||||
|
new GetIssuesByIdsQuery(command.userId, [command.issueId]),
|
||||||
|
);
|
||||||
|
// A ticket I cannot read does not exist, as far as I know.
|
||||||
|
if (!issue || issue.trashedAt)
|
||||||
|
throw new NotFoundError('Issue', command.issueId);
|
||||||
|
const reminder: IssueReminder = {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: command.userId,
|
||||||
|
issueId: issue.id,
|
||||||
|
projectId: issue.projectId,
|
||||||
|
at: new Date(at).toISOString(),
|
||||||
|
note,
|
||||||
|
createdAt: new Date(now).toISOString(),
|
||||||
|
sentAt: null,
|
||||||
|
};
|
||||||
|
await this.reminders.save(reminder);
|
||||||
|
return reminder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(ClearIssueReminderCommand)
|
||||||
|
export class ClearIssueReminderCommandHandler
|
||||||
|
implements ICommandHandler<ClearIssueReminderCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly reminders: IssueRemindersRepository) {}
|
||||||
|
|
||||||
|
execute(command: ClearIssueReminderCommand): Promise<void> {
|
||||||
|
return this.reminders.remove(command.userId, command.issueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(SendIssueRemindersCommand)
|
||||||
|
export class SendIssueRemindersCommandHandler
|
||||||
|
implements ICommandHandler<SendIssueRemindersCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly reminders: IssueRemindersRepository,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
private readonly notifier: Notifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SendIssueRemindersCommand): Promise<number> {
|
||||||
|
const due = await this.reminders.due(command.now, 200);
|
||||||
|
let sent = 0;
|
||||||
|
for (const reminder of due) {
|
||||||
|
const [issue]: IssueReadModel[] = await this.queryBus.execute(
|
||||||
|
new GetIssuesByIdsQuery(reminder.userId, [reminder.issueId]),
|
||||||
|
);
|
||||||
|
// Out of reach or in the trash by now: nothing to remind of.
|
||||||
|
if (issue && !issue.trashedAt) {
|
||||||
|
// Its id makes it idempotent: two instances tell it once.
|
||||||
|
await this.notifier.send([
|
||||||
|
{
|
||||||
|
id: `issue-reminder:${reminder.id}`,
|
||||||
|
userId: reminder.userId,
|
||||||
|
kind: NotificationKind.REMINDER,
|
||||||
|
projectId: issue.projectId,
|
||||||
|
projectKey: issue.projectKey,
|
||||||
|
issueId: issue.id,
|
||||||
|
issueKey: issue.key,
|
||||||
|
actor: { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME },
|
||||||
|
summary: reminder.note
|
||||||
|
? `Recordatorio de ${issue.key}: ${reminder.note}`
|
||||||
|
: `Recordatorio: ${issue.key} · ${issue.title}`,
|
||||||
|
readAt: null,
|
||||||
|
createdAt: command.now,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
await this.reminders.markSent(reminder.id, command.now);
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetIssueReminderQuery)
|
||||||
|
export class GetIssueReminderQueryHandler
|
||||||
|
implements IQueryHandler<GetIssueReminderQuery, IssueReminder | null>
|
||||||
|
{
|
||||||
|
constructor(private readonly reminders: IssueRemindersRepository) {}
|
||||||
|
|
||||||
|
execute(query: GetIssueReminderQuery): Promise<IssueReminder | null> {
|
||||||
|
return this.reminders.waitingFor(query.userId, query.issueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetMyRemindersQuery)
|
||||||
|
export class GetMyRemindersQueryHandler
|
||||||
|
implements
|
||||||
|
IQueryHandler<
|
||||||
|
GetMyRemindersQuery,
|
||||||
|
{ reminder: IssueReminder; issue: IssueReadModel }[]
|
||||||
|
>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly reminders: IssueRemindersRepository,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetMyRemindersQuery,
|
||||||
|
): Promise<{ reminder: IssueReminder; issue: IssueReadModel }[]> {
|
||||||
|
const waiting = await this.reminders.waitingOf(query.userId);
|
||||||
|
if (!waiting.length) return [];
|
||||||
|
const issues: IssueReadModel[] = await this.queryBus.execute(
|
||||||
|
new GetIssuesByIdsQuery(
|
||||||
|
query.userId,
|
||||||
|
waiting.map((r) => r.issueId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const byId = new Map(
|
||||||
|
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
|
||||||
|
);
|
||||||
|
return waiting.flatMap((reminder) => {
|
||||||
|
const issue = byId.get(reminder.issueId);
|
||||||
|
return issue ? [{ reminder, issue }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ISSUE_REMINDERS = [
|
||||||
|
SetIssueReminderCommandHandler,
|
||||||
|
ClearIssueReminderCommandHandler,
|
||||||
|
SendIssueRemindersCommandHandler,
|
||||||
|
GetIssueReminderQueryHandler,
|
||||||
|
GetMyRemindersQueryHandler,
|
||||||
|
];
|
||||||
@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
|
|||||||
import { PINS } from './saved-filters/pins';
|
import { PINS } from './saved-filters/pins';
|
||||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
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 { TreeFollowsService } from './tree-follows.service';
|
||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { PinsService } from './pins.service';
|
import { PinsService } from './pins.service';
|
||||||
@@ -87,6 +89,7 @@ const SERVICES = [
|
|||||||
PinsService,
|
PinsService,
|
||||||
AlertsService,
|
AlertsService,
|
||||||
TreeFollowsService,
|
TreeFollowsService,
|
||||||
|
IssueRemindersService,
|
||||||
SprintsService,
|
SprintsService,
|
||||||
GitService,
|
GitService,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
@@ -120,6 +123,7 @@ const SERVICES = [
|
|||||||
...PINS,
|
...PINS,
|
||||||
...ALERT_HANDLERS,
|
...ALERT_HANDLERS,
|
||||||
...TREE_FOLLOWS,
|
...TREE_FOLLOWS,
|
||||||
|
...ISSUE_REMINDERS,
|
||||||
...GIT_HANDLERS,
|
...GIT_HANDLERS,
|
||||||
...WEBHOOK_HANDLERS,
|
...WEBHOOK_HANDLERS,
|
||||||
...TRANSFER_HANDLERS,
|
...TRANSFER_HANDLERS,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
IssueReminder,
|
||||||
|
IssueRemindersRepository,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { ReadModels } from './read-model.schemas';
|
||||||
|
|
||||||
|
type Stored = IssueReminder & { _id?: unknown };
|
||||||
|
|
||||||
|
const strip = ({ _id: _ignored, ...reminder }: Stored): IssueReminder =>
|
||||||
|
reminder;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoIssueRemindersRepository extends IssueRemindersRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.IssueReminder)
|
||||||
|
private readonly reminders: Model<Stored>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(reminder: IssueReminder): Promise<void> {
|
||||||
|
await this.remove(reminder.userId, reminder.issueId);
|
||||||
|
await this.reminders.create(reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitingFor(
|
||||||
|
userId: string,
|
||||||
|
issueId: string,
|
||||||
|
): Promise<IssueReminder | null> {
|
||||||
|
const doc = await this.reminders
|
||||||
|
.findOne({ userId, issueId, sentAt: null })
|
||||||
|
.lean<Stored>();
|
||||||
|
return doc ? strip(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitingOf(userId: string): Promise<IssueReminder[]> {
|
||||||
|
const docs = await this.reminders
|
||||||
|
.find({ userId, sentAt: null })
|
||||||
|
.sort({ at: 1 })
|
||||||
|
.lean<Stored[]>();
|
||||||
|
return docs.map(strip);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(userId: string, issueId: string): Promise<void> {
|
||||||
|
await this.reminders.deleteMany({ userId, issueId, sentAt: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async due(now: string, limit: number): Promise<IssueReminder[]> {
|
||||||
|
const docs = await this.reminders
|
||||||
|
.find({ sentAt: null, at: { $lte: now } })
|
||||||
|
.sort({ at: 1 })
|
||||||
|
.limit(limit)
|
||||||
|
.lean<Stored[]>();
|
||||||
|
return docs.map(strip);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markSent(id: string, at: string): Promise<void> {
|
||||||
|
await this.reminders.updateOne({ id }, { $set: { sentAt: at } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,7 @@ export const ReadModels = {
|
|||||||
AlertIntegration: 'AlertIntegration',
|
AlertIntegration: 'AlertIntegration',
|
||||||
AlertTrace: 'AlertTrace',
|
AlertTrace: 'AlertTrace',
|
||||||
TreeFollow: 'TreeFollow',
|
TreeFollow: 'TreeFollow',
|
||||||
|
IssueReminder: 'IssueReminder',
|
||||||
Sprint: 'SprintReadModel',
|
Sprint: 'SprintReadModel',
|
||||||
GitIntegration: 'GitIntegration',
|
GitIntegration: 'GitIntegration',
|
||||||
OutgoingWebhook: 'OutgoingWebhook',
|
OutgoingWebhook: 'OutgoingWebhook',
|
||||||
@@ -370,6 +371,20 @@ export const TreeFollowSchema =
|
|||||||
SchemaFactory.createForClass(TreeFollowDocument);
|
SchemaFactory.createForClass(TreeFollowDocument);
|
||||||
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
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' })
|
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||||
class GitIntegrationDocument {
|
class GitIntegrationDocument {
|
||||||
/** The project's id. */
|
/** The project's id. */
|
||||||
@@ -476,6 +491,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||||
|
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
PinsRepository,
|
PinsRepository,
|
||||||
AlertIntegrationsRepository,
|
AlertIntegrationsRepository,
|
||||||
TreeFollowsRepository,
|
TreeFollowsRepository,
|
||||||
|
IssueRemindersRepository,
|
||||||
GitIntegrationsRepository,
|
GitIntegrationsRepository,
|
||||||
OutgoingWebhooksRepository,
|
OutgoingWebhooksRepository,
|
||||||
AutomationsRepository,
|
AutomationsRepository,
|
||||||
@@ -84,6 +85,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
|||||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||||
|
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
||||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||||
@@ -166,6 +168,10 @@ const PORTS = [
|
|||||||
useClass: MongoAlertIntegrationsRepository,
|
useClass: MongoAlertIntegrationsRepository,
|
||||||
},
|
},
|
||||||
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
||||||
|
{
|
||||||
|
provide: IssueRemindersRepository,
|
||||||
|
useClass: MongoIssueRemindersRepository,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: GitIntegrationsRepository,
|
provide: GitIntegrationsRepository,
|
||||||
useClass: MongoGitIntegrationsRepository,
|
useClass: MongoGitIntegrationsRepository,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
IssuesService,
|
IssuesService,
|
||||||
PinsService,
|
PinsService,
|
||||||
TreeFollowsService,
|
TreeFollowsService,
|
||||||
|
IssueRemindersService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -52,6 +53,8 @@ import {
|
|||||||
IssueGql,
|
IssueGql,
|
||||||
IssuePageGql,
|
IssuePageGql,
|
||||||
IssueParentGql,
|
IssueParentGql,
|
||||||
|
IssueReminderGql,
|
||||||
|
ReminderOnIssueGql,
|
||||||
PresenceGql,
|
PresenceGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ export class IssueResolver {
|
|||||||
private readonly presence: PresenceService,
|
private readonly presence: PresenceService,
|
||||||
private readonly pins: PinsService,
|
private readonly pins: PinsService,
|
||||||
private readonly treeFollows: TreeFollowsService,
|
private readonly treeFollows: TreeFollowsService,
|
||||||
|
private readonly reminders: IssueRemindersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Mutation(() => [PresenceGql], {
|
@Mutation(() => [PresenceGql], {
|
||||||
@@ -282,6 +286,17 @@ export class IssueResolver {
|
|||||||
return this.commentsService.decisions(viewerOf(me), issue.id);
|
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, {
|
@ResolveField(() => Boolean, {
|
||||||
description:
|
description:
|
||||||
'Whether I follow it with everything under it (false without a token)',
|
'Whether I follow it with everything under it (false without a token)',
|
||||||
@@ -427,6 +442,35 @@ export class IssueResolver {
|
|||||||
return true;
|
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' })
|
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
|
||||||
unwatchIssue(
|
unwatchIssue(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
|
|||||||
@@ -1377,3 +1377,19 @@ export class AlertIntegrationGql {
|
|||||||
typeId!: string | null;
|
typeId!: string | null;
|
||||||
@Field(() => IssuePriority) priority!: IssuePriority;
|
@Field(() => IssuePriority) priority!: IssuePriority;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('IssueReminder', {
|
||||||
|
description: 'My own reminder on a ticket («remind me on Thursday»)',
|
||||||
|
})
|
||||||
|
export class IssueReminderGql {
|
||||||
|
@Field({ description: 'When it tells me (ISO, UTC)' }) at!: string;
|
||||||
|
@Field(() => String, { nullable: true }) note!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('ReminderOnIssue', {
|
||||||
|
description: 'One of my waiting reminders, with its ticket',
|
||||||
|
})
|
||||||
|
export class ReminderOnIssueGql {
|
||||||
|
@Field(() => IssueReminderGql) reminder!: IssueReminderGql;
|
||||||
|
@Field(() => IssueGql) issue!: IssueGql;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1552,6 +1552,21 @@ export class PushEndpointDto {
|
|||||||
endpoint!: string;
|
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 {
|
export class ResolveThreadDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1417,3 +1417,13 @@ export class AlertOutcomeDto {
|
|||||||
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
|
@ApiProperty({ type: String, nullable: true, example: 'WEB-42' })
|
||||||
issueKey!: string | null;
|
issueKey!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IssueReminderDto {
|
||||||
|
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) note!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReminderOnIssueDto {
|
||||||
|
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
||||||
|
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
IssuesService,
|
IssuesService,
|
||||||
PinsService,
|
PinsService,
|
||||||
TreeFollowsService,
|
TreeFollowsService,
|
||||||
|
IssueRemindersService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
AddCommentDto,
|
AddCommentDto,
|
||||||
CreateIssueDto,
|
CreateIssueDto,
|
||||||
DecisionsQueryDto,
|
DecisionsQueryDto,
|
||||||
|
SetReminderDto,
|
||||||
IssueSearchQueryDto,
|
IssueSearchQueryDto,
|
||||||
PresenceBodyDto,
|
PresenceBodyDto,
|
||||||
LinkIssueDto,
|
LinkIssueDto,
|
||||||
@@ -66,6 +68,8 @@ import {
|
|||||||
CommentDto,
|
CommentDto,
|
||||||
CommentPageDto,
|
CommentPageDto,
|
||||||
DecisionDto,
|
DecisionDto,
|
||||||
|
IssueReminderDto,
|
||||||
|
ReminderOnIssueDto,
|
||||||
EpicDto,
|
EpicDto,
|
||||||
IssueDto,
|
IssueDto,
|
||||||
IssuePageDto,
|
IssuePageDto,
|
||||||
@@ -101,6 +105,7 @@ export class IssuesController {
|
|||||||
private readonly projects: ProjectsService,
|
private readonly projects: ProjectsService,
|
||||||
private readonly pins: PinsService,
|
private readonly pins: PinsService,
|
||||||
private readonly treeFollows: TreeFollowsService,
|
private readonly treeFollows: TreeFollowsService,
|
||||||
|
private readonly reminders: IssueRemindersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('me/home')
|
@Get('me/home')
|
||||||
@@ -205,6 +210,55 @@ export class IssuesController {
|
|||||||
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
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')
|
@Put('issues/:issue/follow-tree')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@ISSUE
|
@ISSUE
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
OnApplicationShutdown,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IssueRemindersService } from '@task-stream/api/tracker/application';
|
||||||
|
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells people's own ticket reminders («remind me on Thursday») every
|
||||||
|
* minute. Each is told once (its notification id), so several instances
|
||||||
|
* running this at once only repeat lookups. REMINDERS_ENABLED=false turns it
|
||||||
|
* off, with the due-date ones.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class IssueReminderScheduler
|
||||||
|
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||||
|
{
|
||||||
|
private readonly logger = new Logger(IssueReminderScheduler.name);
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly reminders: IssueRemindersService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onApplicationBootstrap(): void {
|
||||||
|
if (String(this.config.get('REMINDERS_ENABLED') ?? 'true') === 'false')
|
||||||
|
return;
|
||||||
|
this.timer = setInterval(() => void this.run(), MINUTE);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplicationShutdown(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(): Promise<void> {
|
||||||
|
// A slow round must not pile up with the next.
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const sent = await this.reminders.sendDue();
|
||||||
|
if (sent) this.logger.log(`Ticket reminders sent (${sent})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Ticket reminders failed: ${(error as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { CalendarController } from './http/calendar.controller';
|
|||||||
import { CalendarResolver } from './graphql/calendar.resolver';
|
import { CalendarResolver } from './graphql/calendar.resolver';
|
||||||
import { ReminderScheduler } from './schedulers/reminder.scheduler';
|
import { ReminderScheduler } from './schedulers/reminder.scheduler';
|
||||||
import { TrashScheduler } from './schedulers/trash.scheduler';
|
import { TrashScheduler } from './schedulers/trash.scheduler';
|
||||||
|
import { IssueReminderScheduler } from './schedulers/issue-reminder.scheduler';
|
||||||
import { ProjectsController } from './http/projects.controller';
|
import { ProjectsController } from './http/projects.controller';
|
||||||
import { Refs } from './http/refs';
|
import { Refs } from './http/refs';
|
||||||
import { ReleasesController } from './http/releases.controller';
|
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.
|
// Timers: entry points like the HTTP and GraphQL ones.
|
||||||
ReminderScheduler,
|
ReminderScheduler,
|
||||||
TrashScheduler,
|
TrashScheduler,
|
||||||
|
IssueReminderScheduler,
|
||||||
ProjectResolver,
|
ProjectResolver,
|
||||||
IssueResolver,
|
IssueResolver,
|
||||||
CommentResolver,
|
CommentResolver,
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export const issuePageEvents = eventGroup({
|
|||||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||||
|
reminderRequested: type<{
|
||||||
|
issueId: string;
|
||||||
|
/** null: drop it. */
|
||||||
|
at: string | null;
|
||||||
|
note: string | null;
|
||||||
|
}>(),
|
||||||
trashRequested: type<{
|
trashRequested: type<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
trashed: boolean;
|
trashed: boolean;
|
||||||
|
|||||||
@@ -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. */
|
/** Follow the ticket with everything under it (an epic), or stop. */
|
||||||
setFollowingTree(on: boolean): void {
|
setFollowingTree(on: boolean): void {
|
||||||
this.withIssue((issueId) =>
|
this.withIssue((issueId) =>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
IssueSummary,
|
IssueSummary,
|
||||||
SprintsGateway,
|
SprintsGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
|
whenLabel,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
concatMap,
|
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
|
events
|
||||||
.on(issuePageEvents.followTreeRequested)
|
.on(issuePageEvents.followTreeRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './lib/models/enums';
|
export * from './lib/models/enums';
|
||||||
export * from './lib/models/models';
|
export * from './lib/models/models';
|
||||||
export * from './lib/models/bulk';
|
export * from './lib/models/bulk';
|
||||||
|
export * from './lib/models/reminders';
|
||||||
export * from './lib/models/inputs';
|
export * from './lib/models/inputs';
|
||||||
export * from './lib/models/workflow';
|
export * from './lib/models/workflow';
|
||||||
export * from './lib/ports/gateways';
|
export * from './lib/ports/gateways';
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export interface Issue extends IssueSummary {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
/** Whether I follow it with everything under it (an epic). */
|
/** Whether I follow it with everything under it (an epic). */
|
||||||
followingTree: boolean;
|
followingTree: boolean;
|
||||||
|
/** My waiting reminder on it («remind me on Thursday»). */
|
||||||
|
myReminder: IssueReminder | null;
|
||||||
/** Its comments marked as decisions, oldest first. */
|
/** Its comments marked as decisions, oldest first. */
|
||||||
decisions: Comment[];
|
decisions: Comment[];
|
||||||
checklist: ChecklistItem[];
|
checklist: ChecklistItem[];
|
||||||
@@ -206,6 +208,13 @@ export interface Issue extends IssueSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Someone else on a ticket's page right now. */
|
/** 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. */
|
/** Someone writing on a ticket right now. */
|
||||||
export interface Typing {
|
export interface Typing {
|
||||||
user: UserRef;
|
user: UserRef;
|
||||||
|
|||||||
40
libs/web/tracker/domain/src/lib/models/reminders.spec.ts
Normal file
40
libs/web/tracker/domain/src/lib/models/reminders.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
48
libs/web/tracker/domain/src/lib/models/reminders.ts
Normal file
48
libs/web/tracker/domain/src/lib/models/reminders.ts
Normal 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() }));
|
||||||
|
}
|
||||||
@@ -150,6 +150,13 @@ export abstract class IssuesGateway {
|
|||||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||||
/** Follows the ticket with everything under it (or stops). */
|
/** Follows the ticket with everything under it (or stops). */
|
||||||
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
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,
|
* Decisions (comments marked as such) of all my projects by their text,
|
||||||
* most recent first: here and not in CommentsGateway, for the palette.
|
* most recent first: here and not in CommentsGateway, for the palette.
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
WatchIssueDocument,
|
WatchIssueDocument,
|
||||||
MyPinsDocument,
|
MyPinsDocument,
|
||||||
FollowTreeDocument,
|
FollowTreeDocument,
|
||||||
|
SetReminderDocument,
|
||||||
|
ClearReminderDocument,
|
||||||
UnfollowTreeDocument,
|
UnfollowTreeDocument,
|
||||||
SearchDecisionsDocument,
|
SearchDecisionsDocument,
|
||||||
PinIssueDocument,
|
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> {
|
followTree(issueId: string, on: boolean): Observable<void> {
|
||||||
const done$: Observable<unknown> = on
|
const done$: Observable<unknown> = on
|
||||||
? this.gql.mutate(FollowTreeDocument, { issueId })
|
? this.gql.mutate(FollowTreeDocument, { issueId })
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ fragment IssueFields on Issue {
|
|||||||
...IssueSummaryFields
|
...IssueSummaryFields
|
||||||
pinned
|
pinned
|
||||||
followingTree
|
followingTree
|
||||||
|
myReminder {
|
||||||
|
at
|
||||||
|
note
|
||||||
|
}
|
||||||
decisions {
|
decisions {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -714,6 +714,7 @@ export type IssueFieldsFragment = {
|
|||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
trashedAt: string | null;
|
trashedAt: string | null;
|
||||||
|
myReminder: { at: string; note: string | null } | null;
|
||||||
decisions: Array<{
|
decisions: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
@@ -960,6 +961,7 @@ export type IssueQuery = {
|
|||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
trashedAt: string | null;
|
trashedAt: string | null;
|
||||||
|
myReminder: { at: string; note: string | null } | null;
|
||||||
decisions: Array<{
|
decisions: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
issueId: 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<{
|
export type FollowTreeMutationVariables = Exact<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -3836,6 +3852,17 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
|
{ 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',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'decisions' },
|
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: 'pinned' } },
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
|
{ 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',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'decisions' },
|
name: { kind: 'Name', value: 'decisions' },
|
||||||
@@ -7469,6 +7507,131 @@ export const MyPinsDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
} 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 = {
|
export const FollowTreeDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -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!) {
|
mutation FollowTree($issueId: ID!) {
|
||||||
followTree(issueId: $issueId)
|
followTree(issueId: $issueId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -768,3 +768,72 @@ a.ts-key {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--ts-c-green, #2e9e6a);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ import {
|
|||||||
isTyping,
|
isTyping,
|
||||||
POINT_SCALE,
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
ReminderChoice,
|
||||||
|
reminderChoices,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
|
whenLabel,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
clock,
|
clock,
|
||||||
@@ -206,6 +209,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
(execute)="facade.setWatching(!facade.watching())"
|
(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 (
|
@if (
|
||||||
project.myRole() &&
|
project.myRole() &&
|
||||||
(facade.children().length || issue.followingTree)
|
(facade.children().length || issue.followingTree)
|
||||||
@@ -265,6 +279,30 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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) {
|
@if (project.canContribute() && issue.allowedTransitions.length) {
|
||||||
<div class="ts-transitions" role="group" aria-label="Mover a">
|
<div class="ts-transitions" role="group" aria-label="Mover a">
|
||||||
<span class="ts-eyebrow">Mover a</span>
|
<span class="ts-eyebrow">Mover a</span>
|
||||||
@@ -1163,6 +1201,58 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
<pi-skeleton shape="text" [lines]="8" />
|
<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>
|
<ng-template #reasonForm>
|
||||||
<form
|
<form
|
||||||
class="ts-form"
|
class="ts-form"
|
||||||
@@ -1264,6 +1354,13 @@ export class IssuePage {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly reasonForm =
|
private readonly reasonForm =
|
||||||
viewChild.required<TemplateRef<unknown>>('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 =
|
private readonly editForm =
|
||||||
viewChild.required<TemplateRef<unknown>>('editForm');
|
viewChild.required<TemplateRef<unknown>>('editForm');
|
||||||
private readonly commentEditForm =
|
private readonly commentEditForm =
|
||||||
@@ -1638,6 +1735,28 @@ export class IssuePage {
|
|||||||
this.parentKey.set(null);
|
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 {
|
protected move(to: string): void {
|
||||||
const open = this.openThreads();
|
const open = this.openThreads();
|
||||||
if (
|
if (
|
||||||
@@ -1934,3 +2053,9 @@ export class IssuePage {
|
|||||||
this.drawer.closeAll();
|
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())}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user