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
|
||||||
@@ -544,17 +549,17 @@ 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` |
|
||||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
|
||||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
@@ -752,25 +757,25 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
|
|
||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
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}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
||||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
|
||||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||||
|
|||||||
@@ -300,25 +300,25 @@ 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 |
|
||||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||||
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||||
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||||
|
|
||||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||||
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||||
|
|||||||
@@ -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