feat: quick polls in comments that end in a decision
«📊 Añadir una encuesta» adds 2 to 6 options to a new comment. Members, viewers too, vote by clicking an option (one vote each, which they can change or take back) and see each option's votes as a bar. Whoever started it, or an admin, closes it with «Cerrar y decidir»: the most voted wins (none on a tie) and a «Decidido: …» reply marked as a decision shows above the ticket. Events of the comment (voted, closed). API: addComment(…, poll), votePoll, closePoll, Comment.poll; POST /api/comments/{id}/poll/vote and …/poll/close. MCP: add_comment poll, vote_poll, close_poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8216,6 +8216,226 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}/poll/vote": {
|
||||
"post": {
|
||||
"description": "option: its index; null takes the vote back.",
|
||||
"operationId": "CommentsController_votePoll",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "commentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VotePollDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Vote in a comment's poll (members, viewers too)",
|
||||
"tags": [
|
||||
"Comments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}/poll/close": {
|
||||
"post": {
|
||||
"description": "With decide (default), the winner stays as a decision of the ticket.",
|
||||
"operationId": "CommentsController_closePoll",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "commentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ClosePollDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Close a poll (whoever started it, or an admin)",
|
||||
"tags": [
|
||||
"Comments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}/resolution": {
|
||||
"put": {
|
||||
"description": "Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.",
|
||||
@@ -17618,6 +17838,48 @@
|
||||
"users"
|
||||
]
|
||||
},
|
||||
"PollOptionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"voters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/UserRefDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"text",
|
||||
"voters"
|
||||
]
|
||||
},
|
||||
"CommentPollDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PollOptionDto"
|
||||
}
|
||||
},
|
||||
"closed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"winner": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Index of the winning option once closed (null on a tie)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"options",
|
||||
"closed",
|
||||
"winner"
|
||||
]
|
||||
},
|
||||
"CommentDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -17672,6 +17934,15 @@
|
||||
"nullable": true,
|
||||
"format": "date-time"
|
||||
},
|
||||
"poll": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/CommentPollDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"resolved": {
|
||||
"type": "boolean",
|
||||
"description": "A conversation (top-level comment and its replies) that was settled"
|
||||
@@ -17715,6 +17986,7 @@
|
||||
"decision",
|
||||
"decidedBy",
|
||||
"decidedAt",
|
||||
"poll",
|
||||
"resolved",
|
||||
"resolvedBy",
|
||||
"resolvedAt",
|
||||
@@ -17768,6 +18040,17 @@
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Comment it answers (one level: answering a reply joins its thread)"
|
||||
},
|
||||
"poll": {
|
||||
"description": "A quick poll: 2 to 6 options",
|
||||
"example": [
|
||||
"Stripe",
|
||||
"Redsys"
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17860,6 +18143,29 @@
|
||||
"on"
|
||||
]
|
||||
},
|
||||
"VotePollDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"option": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Option index; null takes the vote back"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"option"
|
||||
]
|
||||
},
|
||||
"ClosePollDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"decide": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Keep the winner as a decision of the ticket"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResolveThreadDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -304,6 +304,7 @@ type Comment {
|
||||
editedAt: String
|
||||
id: ID!
|
||||
issueId: ID!
|
||||
poll: CommentPoll
|
||||
|
||||
"""In the order they are offered; only the ones somebody chose"""
|
||||
reactions: [CommentReaction!]!
|
||||
@@ -336,6 +337,15 @@ type CommentPage {
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""A quick poll in a comment"""
|
||||
type CommentPoll {
|
||||
closed: Boolean!
|
||||
options: [PollOption!]!
|
||||
|
||||
"""Index of the winning option once closed (null on a tie)"""
|
||||
winner: Int
|
||||
}
|
||||
|
||||
"""One emoji on a comment"""
|
||||
type CommentReaction {
|
||||
emoji: String!
|
||||
@@ -964,7 +974,14 @@ type Mutation {
|
||||
"""
|
||||
Comment on a ticket, or answer a comment (replyTo; one level of replies)
|
||||
"""
|
||||
addComment(body: String!, issueId: ID!, replyTo: ID): CommandResult!
|
||||
addComment(
|
||||
body: String!
|
||||
issueId: ID!
|
||||
|
||||
"""A quick poll: 2 to 6 options"""
|
||||
poll: [String!]
|
||||
replyTo: ID
|
||||
): CommandResult!
|
||||
|
||||
"""
|
||||
Add a repository with its own secret (the first turns the integration on)
|
||||
@@ -1010,6 +1027,11 @@ type Mutation {
|
||||
"""Drop my reminder on a ticket"""
|
||||
clearReminder(issueId: ID!): Boolean!
|
||||
|
||||
"""
|
||||
Close a poll (whoever started it, or an admin); with decide, the winner stays as a decision of the ticket
|
||||
"""
|
||||
closePoll(commentId: ID!, decide: Boolean! = true): CommandResult!
|
||||
|
||||
"""
|
||||
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
|
||||
"""
|
||||
@@ -1233,6 +1255,11 @@ type Mutation {
|
||||
"""
|
||||
voteIssue(issueId: ID!, on: Boolean!): CommandResult!
|
||||
|
||||
"""
|
||||
Vote in a comment's poll (option index), or take the vote back (null). Members, viewers too
|
||||
"""
|
||||
votePoll(commentId: ID!, option: Int): CommandResult!
|
||||
|
||||
"""
|
||||
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
||||
"""
|
||||
@@ -1373,6 +1400,11 @@ type OptionCount {
|
||||
value: String!
|
||||
}
|
||||
|
||||
type PollOption {
|
||||
text: String!
|
||||
voters: [UserRef!]!
|
||||
}
|
||||
|
||||
"""
|
||||
One of my active projects at a glance: open work, risks, sprint, releases and epics
|
||||
"""
|
||||
|
||||
@@ -1201,6 +1201,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/comments/{commentId}/poll/vote": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Vote in a comment's poll (members, viewers too)
|
||||
* @description option: its index; null takes the vote back.
|
||||
*/
|
||||
post: operations["CommentsController_votePoll"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/comments/{commentId}/poll/close": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Close a poll (whoever started it, or an admin)
|
||||
* @description With decide (default), the winner stays as a decision of the ticket.
|
||||
*/
|
||||
post: operations["CommentsController_closePoll"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/comments/{commentId}/resolution": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3288,6 +3328,16 @@ export interface components {
|
||||
emoji: string;
|
||||
users: components["schemas"]["UserRefDto"][];
|
||||
};
|
||||
PollOptionDto: {
|
||||
text: string;
|
||||
voters: components["schemas"]["UserRefDto"][];
|
||||
};
|
||||
CommentPollDto: {
|
||||
options: components["schemas"]["PollOptionDto"][];
|
||||
closed: boolean;
|
||||
/** @description Index of the winning option once closed (null on a tie) */
|
||||
winner: number | null;
|
||||
};
|
||||
CommentDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -3311,6 +3361,7 @@ export interface components {
|
||||
decidedBy: components["schemas"]["UserRefDto"] | null;
|
||||
/** Format: date-time */
|
||||
decidedAt: string | null;
|
||||
poll: components["schemas"]["CommentPollDto"] | null;
|
||||
/** @description A conversation (top-level comment and its replies) that was settled */
|
||||
resolved: boolean;
|
||||
/** @description Who settled it */
|
||||
@@ -3339,6 +3390,14 @@ export interface components {
|
||||
* @description Comment it answers (one level: answering a reply joins its thread)
|
||||
*/
|
||||
replyTo?: string;
|
||||
/**
|
||||
* @description A quick poll: 2 to 6 options
|
||||
* @example [
|
||||
* "Stripe",
|
||||
* "Redsys"
|
||||
* ]
|
||||
*/
|
||||
poll?: string[];
|
||||
};
|
||||
AttachFileDto: {
|
||||
/**
|
||||
@@ -3381,6 +3440,17 @@ export interface components {
|
||||
/** @description true adds the reaction, false takes it back */
|
||||
on: boolean;
|
||||
};
|
||||
VotePollDto: {
|
||||
/** @description Option index; null takes the vote back */
|
||||
option: number | null;
|
||||
};
|
||||
ClosePollDto: {
|
||||
/**
|
||||
* @description Keep the winner as a decision of the ticket
|
||||
* @default true
|
||||
*/
|
||||
decide: boolean;
|
||||
};
|
||||
ResolveThreadDto: {
|
||||
/** @description What was settled: added as a reply marked as a decision before it closes */
|
||||
conclusion?: string;
|
||||
@@ -9646,6 +9716,164 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_votePoll: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["VotePollDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommandResultDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_closePoll: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ClosePollDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommandResultDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_resolveThread: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -299,6 +299,17 @@ export class IssuesResolver {
|
||||
})),
|
||||
decision: c.decision || undefined,
|
||||
resolved: c.resolved || undefined,
|
||||
poll: c.poll
|
||||
? {
|
||||
options: c.poll.options.map((o, index) => ({
|
||||
index,
|
||||
text: o.text,
|
||||
votes: o.voters.length,
|
||||
})),
|
||||
closed: c.poll.closed,
|
||||
winner: c.poll.winner,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
: undefined;
|
||||
const decisions = (
|
||||
@@ -512,18 +523,77 @@ export class IssuesResolver {
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Id of the comment it answers (see get_issue)'),
|
||||
poll: z
|
||||
.array(z.string().min(1).max(80))
|
||||
.min(2)
|
||||
.max(6)
|
||||
.optional()
|
||||
.describe('A quick poll in the comment: 2 to 6 options'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
comment(
|
||||
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
|
||||
{
|
||||
issue,
|
||||
body,
|
||||
replyTo,
|
||||
poll,
|
||||
}: { issue: string; body: string; replyTo?: string; poll?: string[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||
params: { path: { issue } },
|
||||
body: { body, replyTo },
|
||||
body: { body, replyTo, poll },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'vote_poll',
|
||||
description:
|
||||
"Vote in a comment's poll (option index from get_issue), or take the vote back (option null).",
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
option: z.number().int().min(0).nullable(),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
votePoll(
|
||||
{ commentId, option }: { commentId: string; option: number | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/comments/{commentId}/poll/vote', {
|
||||
params: { path: { commentId } },
|
||||
body: { option },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'close_poll',
|
||||
description:
|
||||
'Close a poll the user started (or as admin); with decide (default), the winner stays as a decision of the ticket.',
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
decide: z.boolean().default(true),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
closePoll(
|
||||
{ commentId, decide }: { commentId: string; decide: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/comments/{commentId}/poll/close', {
|
||||
params: { path: { commentId } },
|
||||
body: { decide },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,50 +2,50 @@
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
|
||||
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||
| ☀ Mi día | Cada mañana eliges los tickets de hoy; se tachan al terminar y lo que quede se pasa al día siguiente; y «Recordármelo» en cualquier ticket |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, en el tablero las flechas recorren las tarjetas y Shift+←/→ las cambia de columna, `e` edita, `?` los lista |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras |
|
||||
| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🚨 Alertas → bugs | Sentry, Uptime Kuma, Grafana o cualquier herramienta abren bugs por una URL secreta; el mismo problema repetido va al bug abierto, y «resuelta» se dice en él |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
|
||||
|
||||
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
|
||||
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
|
||||
|
||||
@@ -239,6 +239,12 @@ stateDiagram-v2
|
||||
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
||||
`POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién
|
||||
eligió cada una. No generan actividad ni avisos.
|
||||
- **Encuestas**: un comentario puede llevar una encuesta de 2 a 6 opciones (`addComment(…, poll)`, `poll` en
|
||||
`POST …/comments`). Los miembros, también los lectores, votan una opción y pueden cambiarla o retirarla
|
||||
(`votePoll(commentId, option)`, `POST /api/comments/{id}/poll/vote`; `CommentPollVotedEvent`). Quien la abrió, o un
|
||||
admin, la cierra (`closePoll(commentId, decide)`, `POST …/poll/close`; `CommentPollClosedEvent`): gana la más votada
|
||||
(con empate, ninguna) y, con `decide`, se añade una respuesta «Decidido: …» marcada como decisión. El comentario da
|
||||
`poll { options { text voters } closed winner }`.
|
||||
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
|
||||
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
|
||||
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
|
||||
@@ -563,7 +569,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (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, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||
@@ -772,7 +778,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `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`, `POST …/poll/vote`, `POST …/poll/close`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
|
||||
@@ -300,25 +300,25 @@ visualización de la plataforma:
|
||||
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||
| `/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. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||
| Ruta | Página |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||
| `/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. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión |
|
||||
| `/p/:key/releases` | Releases |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||
|
||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||
|
||||
@@ -72,6 +72,8 @@ flowchart LR
|
||||
| | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno |
|
||||
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
|
||||
| | `answer_review` | Responder una revisión: todo bien o cambios |
|
||||
| | `vote_poll` | Votar en la encuesta de un comentario (o retirar el voto) |
|
||||
| | `close_poll` | Cerrar una encuesta; la ganadora queda como decisión |
|
||||
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
|
||||
@@ -50,6 +50,7 @@ export * from './lib/my-day/my-day';
|
||||
export * from './lib/ports/my-days.repository';
|
||||
export * from './lib/my-day.service';
|
||||
export * from './lib/commands/reviews';
|
||||
export * from './lib/commands/polls';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -38,6 +38,7 @@ export class AddCommentCommandHandler
|
||||
replyTo: command.replyTo
|
||||
? await this.threadOf(command.replyTo, issue.id)
|
||||
: null,
|
||||
poll: command.poll,
|
||||
});
|
||||
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
|
||||
@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
||||
public readonly body: string,
|
||||
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||
public readonly replyTo: string | null = null,
|
||||
/** A quick poll's options (2–6), if any. */
|
||||
public readonly poll: string[] | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler
|
||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||
import { REVIEW_HANDLERS } from './reviews';
|
||||
import { POLL_HANDLERS } from './polls';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -52,6 +53,7 @@ export const COMMAND_HANDLERS = [
|
||||
MarkDecisionCommandHandler,
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
...POLL_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -80,6 +80,7 @@ import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './polls';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
@@ -950,6 +951,57 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
|
||||
);
|
||||
await expect(
|
||||
run(new AddCommentCommand('dev', issue.id, '¿Cuál?', null, ['Solo'])),
|
||||
).rejects.toThrow(ValidationError);
|
||||
const poll = await run(
|
||||
new AddCommentCommand('dev', issue.id, '¿Cuál?', null, [
|
||||
' Stripe ',
|
||||
'Redsys',
|
||||
]),
|
||||
);
|
||||
await run(new VotePollCommand('qa', poll.id, 0));
|
||||
await run(new VotePollCommand('pm', poll.id, 1));
|
||||
await run(new VotePollCommand('pm', poll.id, 0));
|
||||
await run(new VotePollCommand('owner', poll.id, 1));
|
||||
await expect(run(new VotePollCommand('qa', poll.id, 5))).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
// Only whoever started it (or an admin) closes it.
|
||||
await expect(run(new ClosePollCommand('qa', poll.id))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await run(new ClosePollCommand('dev', poll.id));
|
||||
const closed = await rehydrator.rehydrate(poll.id, Comment);
|
||||
expect([closed.poll, closed.pollClosed, closed.pollWinner]).toEqual([
|
||||
['Stripe', 'Redsys'],
|
||||
true,
|
||||
0,
|
||||
]);
|
||||
await expect(run(new VotePollCommand('qa', poll.id, 1))).rejects.toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
const decided = store.events.find(
|
||||
(e) =>
|
||||
e.type === 'CommentAddedEvent' &&
|
||||
(e.data as { comment: { body: string } }).comment.body.startsWith(
|
||||
'Decidido',
|
||||
),
|
||||
);
|
||||
expect((decided?.data as { comment: { body: string } }).comment.body).toBe(
|
||||
'Decidido: **Stripe** (2 de 3 votos)',
|
||||
);
|
||||
expect(
|
||||
(await rehydrator.rehydrate(decided?.streamId ?? '', Comment)).decision,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('asks a teammate to review a ticket, who answers it', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { CommentFactory, ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* A quick poll in a comment («¿Stripe o Redsys?»): members vote, one vote
|
||||
* each; whoever started it closes it, and the winner can stay as a decision.
|
||||
*/
|
||||
|
||||
export class VotePollCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
/** Null takes the vote back. */
|
||||
public readonly option: number | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClosePollCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
/** Keep the winner as a decision of the ticket (a reply marked so). */
|
||||
public readonly decide = true,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Voting is for members, viewers included: like reacting. */
|
||||
@CommandHandler(VotePollCommand)
|
||||
export class VotePollCommandHandler
|
||||
implements ICommandHandler<VotePollCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: VotePollCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
this.access.assertSees(
|
||||
project,
|
||||
await this.access.issue(comment.issueId),
|
||||
command.actorId,
|
||||
);
|
||||
comment.votePoll(command.actorId, command.option);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(ClosePollCommand)
|
||||
export class ClosePollCommandHandler
|
||||
implements ICommandHandler<ClosePollCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: ClosePollCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
const voters = Object.keys(comment.pollVotes).length;
|
||||
const winner = comment.closePoll(
|
||||
command.actorId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await comment.commitAndWait();
|
||||
if (command.decide && winner !== null && comment.poll) {
|
||||
const votes = Object.values(comment.pollVotes).filter(
|
||||
(v) => v === winner,
|
||||
).length;
|
||||
// The result, as a decision above the ticket.
|
||||
const decision = this.commentFactory.create({
|
||||
issueId: comment.issueId,
|
||||
projectId: comment.projectId,
|
||||
authorId: command.actorId,
|
||||
body: `Decidido: **${comment.poll[winner]}** (${votes} de ${voters} ${voters === 1 ? 'voto' : 'votos'})`,
|
||||
replyTo: comment.replyTo ?? comment.id,
|
||||
});
|
||||
decision.markDecision(command.actorId, true);
|
||||
this.eventPublisher.mergeObjectContext(decision);
|
||||
await decision.commitAndWait();
|
||||
}
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
|
||||
export const POLL_HANDLERS = [VotePollCommandHandler, ClosePollCommandHandler];
|
||||
@@ -12,6 +12,7 @@ import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
import {
|
||||
@@ -31,9 +32,32 @@ export class CommentsService {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo),
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo, poll),
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
votePoll(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new VotePollCommand(actorId, commentId, option),
|
||||
);
|
||||
}
|
||||
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
closePoll(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
decide = true,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new ClosePollCommand(actorId, commentId, decide),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ export class CommentAddedEventHandler
|
||||
decision: false,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
poll: comment.poll?.length
|
||||
? {
|
||||
options: comment.poll.map((text) => ({ text, voters: [] })),
|
||||
closed: false,
|
||||
winner: null,
|
||||
}
|
||||
: null,
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
/** A comment's poll as the read side shows it: who voted each option. */
|
||||
@EventsHandler(CommentPollVotedEvent, CommentPollClosedEvent)
|
||||
export class CommentPollEventHandler
|
||||
implements IEventHandler<CommentPollVotedEvent | CommentPollClosedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentPollVotedEvent | CommentPollClosedEvent) {
|
||||
const comment = await this.findComments.findById(event.aggregateId);
|
||||
if (!comment?.poll) return;
|
||||
let poll = comment.poll;
|
||||
if (event instanceof CommentPollVotedEvent) {
|
||||
const refs = await this.users.refs(Object.keys(event.votes));
|
||||
poll = {
|
||||
...poll,
|
||||
options: poll.options.map((option, index) => ({
|
||||
text: option.text,
|
||||
voters: Object.entries(event.votes)
|
||||
.filter(([, vote]) => vote === index)
|
||||
.map(([userId]) => refs.of(userId)),
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
poll = { ...poll, closed: true, winner: event.winner };
|
||||
}
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
poll,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
CommentPollEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
} from '../events/comment-poll.events';
|
||||
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||
@@ -30,6 +34,32 @@ export interface CommentProps {
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
/** A quick poll's options (2–6); absent without one. */
|
||||
poll?: string[] | null;
|
||||
}
|
||||
|
||||
export const MIN_POLL_OPTIONS = 2;
|
||||
export const MAX_POLL_OPTIONS = 6;
|
||||
const MAX_POLL_OPTION = 80;
|
||||
|
||||
/** A poll's options, trimmed: 2 to 6, different, short. */
|
||||
export function validPoll(options: string[]): string[] {
|
||||
const clean = options
|
||||
.map((o) => o.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
assertThat(
|
||||
clean.length >= MIN_POLL_OPTIONS && clean.length <= MAX_POLL_OPTIONS,
|
||||
`A poll takes ${MIN_POLL_OPTIONS} to ${MAX_POLL_OPTIONS} options`,
|
||||
);
|
||||
assertThat(
|
||||
clean.every((o) => o.length <= MAX_POLL_OPTION),
|
||||
`A poll option takes ${MAX_POLL_OPTION} characters at most`,
|
||||
);
|
||||
assertThat(
|
||||
new Set(clean.map((o) => o.toLowerCase())).size === clean.length,
|
||||
'Poll options must be different',
|
||||
);
|
||||
return clean;
|
||||
}
|
||||
|
||||
const MAX_BODY = 20_000;
|
||||
@@ -47,6 +77,11 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public decision = false;
|
||||
/** A top-level comment whose conversation was settled. */
|
||||
public resolved = false;
|
||||
/** A quick poll: its options, who voted what, and whether it closed. */
|
||||
public poll: string[] | null = null;
|
||||
public pollVotes: Record<string, number> = {};
|
||||
public pollClosed = false;
|
||||
public pollWinner: number | null = null;
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -139,6 +174,63 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes `option` in its poll (null takes the vote back); repeating it changes nothing. */
|
||||
votePoll(actorId: string, option: number | null): void {
|
||||
this.assertAlive();
|
||||
if (!this.poll) throw new BusinessRuleError('This comment has no poll');
|
||||
if (this.pollClosed) throw new BusinessRuleError('The poll is closed');
|
||||
assertThat(
|
||||
option === null ||
|
||||
(Number.isInteger(option) && option >= 0 && option < this.poll.length),
|
||||
'No such option',
|
||||
);
|
||||
if ((this.pollVotes[actorId] ?? null) === option) return;
|
||||
const votes = { ...this.pollVotes };
|
||||
if (option === null) delete votes[actorId];
|
||||
else votes[actorId] = option;
|
||||
this.apply(
|
||||
new CommentPollVotedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
votes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the poll: its author, or (`canModerate`) whoever manages the
|
||||
* project. The winner is the most voted option; none on a tie.
|
||||
*/
|
||||
closePoll(actorId: string, canModerate: boolean): number | null {
|
||||
this.assertAlive();
|
||||
if (!this.poll) throw new BusinessRuleError('This comment has no poll');
|
||||
if (this.pollClosed) throw new BusinessRuleError('The poll is closed');
|
||||
if (actorId !== this.authorId && !canModerate)
|
||||
throw new ForbiddenError(
|
||||
'Only whoever started the poll or a project admin can close it',
|
||||
);
|
||||
const counts = this.poll.map(
|
||||
(_, i) => Object.values(this.pollVotes).filter((v) => v === i).length,
|
||||
);
|
||||
const most = Math.max(...counts);
|
||||
const winner =
|
||||
most > 0 && counts.filter((c) => c === most).length === 1
|
||||
? counts.indexOf(most)
|
||||
: null;
|
||||
this.apply(
|
||||
new CommentPollClosedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
winner,
|
||||
),
|
||||
);
|
||||
return winner;
|
||||
}
|
||||
|
||||
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
||||
delete(actorId: string, canModerate: boolean): void {
|
||||
this.assertAlive();
|
||||
@@ -156,6 +248,16 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
|
||||
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
|
||||
Object.assign(this, comment);
|
||||
this.poll = comment.poll ?? null;
|
||||
}
|
||||
|
||||
onCommentPollVotedEvent(event: CommentPollVotedEvent): void {
|
||||
this.pollVotes = event.votes;
|
||||
}
|
||||
|
||||
onCommentPollClosedEvent(event: CommentPollClosedEvent): void {
|
||||
this.pollClosed = true;
|
||||
this.pollWinner = event.winner;
|
||||
}
|
||||
|
||||
onCommentEditedEvent(event: CommentEditedEvent): void {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Someone voted in a comment's poll, changed or took back their vote. */
|
||||
@AutowiredEvent
|
||||
export class CommentPollVotedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** Every vote after the change: user id → option index. */
|
||||
readonly votes: Record<string, number>,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** A comment's poll was closed; `winner` is null on a tie or no votes. */
|
||||
@AutowiredEvent
|
||||
export class CommentPollClosedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
readonly winner: number | null,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-poll.events';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { newId } from '@task-stream/api/shared/domain';
|
||||
import { Comment, CommentProps, validCommentBody } from '../comment/comment';
|
||||
import {
|
||||
Comment,
|
||||
CommentProps,
|
||||
validCommentBody,
|
||||
validPoll,
|
||||
} from '../comment/comment';
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +17,8 @@ export class CommentFactory {
|
||||
body: string;
|
||||
/** Top-level comment it answers. */
|
||||
replyTo?: string | null;
|
||||
/** A quick poll's options. */
|
||||
poll?: string[] | null;
|
||||
}): Comment {
|
||||
const data: CommentProps = {
|
||||
id: newId(),
|
||||
@@ -23,6 +30,7 @@ export class CommentFactory {
|
||||
deleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
editedAt: null,
|
||||
...(props.poll?.length ? { poll: validPoll(props.poll) } : {}),
|
||||
};
|
||||
const comment = new Comment(data.id);
|
||||
Object.assign(comment, { ...data });
|
||||
|
||||
@@ -6,6 +6,14 @@ export class CommentReactionReadModel {
|
||||
users!: UserRefReadModel[];
|
||||
}
|
||||
|
||||
/** A quick poll: each option with who voted it. */
|
||||
export class CommentPollReadModel {
|
||||
options!: { text: string; voters: UserRefReadModel[] }[];
|
||||
closed!: boolean;
|
||||
/** Index of the winning option once closed (null on a tie). */
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
export class CommentReadModel {
|
||||
id!: string;
|
||||
issueId!: string;
|
||||
@@ -26,6 +34,8 @@ export class CommentReadModel {
|
||||
/** Who marked it and when (null when it is not a decision). */
|
||||
decidedBy!: UserRefReadModel | null;
|
||||
decidedAt!: string | null;
|
||||
/** A quick poll in the comment, if any. */
|
||||
poll!: CommentPollReadModel | null;
|
||||
/** A top-level comment whose conversation was settled. */
|
||||
resolved!: boolean;
|
||||
/** Who settled it and when (null while open). */
|
||||
|
||||
@@ -54,6 +54,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
decision: doc.decision ?? false,
|
||||
decidedBy: doc.decidedBy ?? null,
|
||||
decidedAt: doc.decidedAt ?? null,
|
||||
poll: doc.poll ?? null,
|
||||
resolved: doc.resolved ?? false,
|
||||
resolvedBy: doc.resolvedBy ?? null,
|
||||
resolvedAt: doc.resolvedAt ?? null,
|
||||
|
||||
@@ -22,8 +22,44 @@ export class CommentResolver {
|
||||
@Args('body') body: string,
|
||||
@Args('replyTo', { type: () => ID, nullable: true })
|
||||
replyTo: string | null,
|
||||
@Args('poll', {
|
||||
type: () => [String],
|
||||
nullable: true,
|
||||
description: 'A quick poll: 2 to 6 options',
|
||||
})
|
||||
poll: string[] | null,
|
||||
) {
|
||||
return this.comments.add(me.id, issueId, body, replyTo ?? null);
|
||||
return this.comments.add(
|
||||
me.id,
|
||||
issueId,
|
||||
body,
|
||||
replyTo ?? null,
|
||||
poll ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
"Vote in a comment's poll (option index), or take the vote back (null). Members, viewers too",
|
||||
})
|
||||
votePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('option', { type: () => Int, nullable: true }) option: number | null,
|
||||
) {
|
||||
return this.comments.votePoll(me.id, commentId, option ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Close a poll (whoever started it, or an admin); with decide, the winner stays as a decision of the ticket',
|
||||
})
|
||||
closePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('decide', { defaultValue: true }) decide: boolean,
|
||||
) {
|
||||
return this.comments.closePoll(me.id, commentId, decide);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
|
||||
@@ -555,6 +555,23 @@ export class CommentReactionGql {
|
||||
users!: UserRefGql[];
|
||||
}
|
||||
|
||||
@ObjectType('PollOption')
|
||||
export class PollOptionGql {
|
||||
@Field() text!: string;
|
||||
@Field(() => [UserRefGql]) voters!: UserRefGql[];
|
||||
}
|
||||
|
||||
@ObjectType('CommentPoll', { description: 'A quick poll in a comment' })
|
||||
export class CommentPollGql {
|
||||
@Field(() => [PollOptionGql]) options!: PollOptionGql[];
|
||||
@Field() closed!: boolean;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Index of the winning option once closed (null on a tie)',
|
||||
})
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('Comment')
|
||||
export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -585,6 +602,8 @@ export class CommentGql {
|
||||
})
|
||||
decidedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
||||
@Field(() => CommentPollGql, { nullable: true })
|
||||
poll!: CommentPollGql | null;
|
||||
@Field({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
|
||||
@@ -19,8 +19,10 @@ import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { REACTIONS } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CommentBodyDto,
|
||||
ClosePollDto,
|
||||
ReactToCommentDto,
|
||||
ResolveThreadDto,
|
||||
VotePollDto,
|
||||
} from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
|
||||
@@ -82,6 +84,39 @@ export class CommentsController {
|
||||
return this.comments.markDecision(me.id, commentId, false);
|
||||
}
|
||||
|
||||
@Post(':commentId/poll/vote')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: "Vote in a comment's poll (members, viewers too)",
|
||||
description: 'option: its index; null takes the vote back.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
votePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: VotePollDto,
|
||||
) {
|
||||
return this.comments.votePoll(me.id, commentId, body.option);
|
||||
}
|
||||
|
||||
@Post(':commentId/poll/close')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Close a poll (whoever started it, or an admin)',
|
||||
description:
|
||||
'With decide (default), the winner stays as a decision of the ticket.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
closePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: ClosePollDto,
|
||||
) {
|
||||
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
|
||||
}
|
||||
|
||||
@Put(':commentId/resolution')
|
||||
@ApiOperation({
|
||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||
|
||||
@@ -1316,6 +1316,40 @@ export class AddCommentDto extends CommentBodyDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
replyTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'A quick poll: 2 to 6 options',
|
||||
example: ['Stripe', 'Redsys'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(80, { each: true })
|
||||
poll?: string[];
|
||||
}
|
||||
|
||||
export class VotePollDto {
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Option index; null takes the vote back',
|
||||
})
|
||||
@ValidateIf((dto: VotePollDto) => dto.option !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
option!: number | null;
|
||||
}
|
||||
|
||||
export class ClosePollDto {
|
||||
@ApiPropertyOptional({
|
||||
default: true,
|
||||
description: 'Keep the winner as a decision of the ticket',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
decide?: boolean;
|
||||
}
|
||||
|
||||
export class ReactToCommentDto {
|
||||
|
||||
@@ -612,6 +612,22 @@ export class CommentReactionDto {
|
||||
@ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[];
|
||||
}
|
||||
|
||||
export class PollOptionDto {
|
||||
@ApiProperty() text!: string;
|
||||
@ApiProperty({ type: [UserRefDto] }) voters!: UserRefDto[];
|
||||
}
|
||||
|
||||
export class CommentPollDto {
|
||||
@ApiProperty({ type: [PollOptionDto] }) options!: PollOptionDto[];
|
||||
@ApiProperty() closed!: boolean;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Index of the winning option once closed (null on a tie)',
|
||||
})
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
export class CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@@ -648,6 +664,8 @@ export class CommentDto {
|
||||
decidedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
decidedAt!: string | null;
|
||||
@ApiProperty({ type: CommentPollDto, nullable: true })
|
||||
poll!: CommentPollDto | null;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
|
||||
@@ -990,6 +990,7 @@ export class IssuesController {
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.body,
|
||||
body.replyTo ?? null,
|
||||
body.poll ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ export const issuePageEvents = eventGroup({
|
||||
files?: File[];
|
||||
/** Comment it answers (one level of replies). */
|
||||
replyTo?: string | null;
|
||||
/** A quick poll's options. */
|
||||
poll?: string[] | null;
|
||||
}>(),
|
||||
pollVoteRequested: type<{ commentId: string; option: number | null }>(),
|
||||
pollCloseRequested: type<{ commentId: string; decide: boolean }>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||
|
||||
@@ -101,12 +101,29 @@ export class IssueFacade {
|
||||
body: string,
|
||||
files: File[] = [],
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.commentAddRequested({ issueId, body, files, replyTo }),
|
||||
this.dispatch.commentAddRequested({
|
||||
issueId,
|
||||
body,
|
||||
files,
|
||||
replyTo,
|
||||
poll,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
votePoll(commentId: string, option: number | null): void {
|
||||
this.dispatch.pollVoteRequested({ commentId, option });
|
||||
}
|
||||
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
closePoll(commentId: string, decide = true): void {
|
||||
this.dispatch.pollCloseRequested({ commentId, decide });
|
||||
}
|
||||
|
||||
/** To the trash (true) or back from it (false). */
|
||||
setTrashed(trashed: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
|
||||
@@ -381,6 +381,32 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.pollVoteRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.votePoll(payload.commentId, payload.option).pipe(
|
||||
// A vote is not worth a toast, like a reaction.
|
||||
mapResponse({
|
||||
next: () => issueApiEvents.checklistSaved(),
|
||||
error: (error) =>
|
||||
issueApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.pollCloseRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.closePoll(payload.commentId, payload.decide),
|
||||
payload.decide
|
||||
? 'Encuesta cerrada: el resultado queda como decisión'
|
||||
: 'Encuesta cerrada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewRequested)
|
||||
.pipe(
|
||||
@@ -698,7 +724,12 @@ export const IssueStore = signalStore(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments
|
||||
.add(payload.issueId, payload.body, payload.replyTo ?? null)
|
||||
.add(
|
||||
payload.issueId,
|
||||
payload.body,
|
||||
payload.replyTo ?? null,
|
||||
payload.poll ?? null,
|
||||
)
|
||||
.pipe(
|
||||
concatMap((comment) =>
|
||||
payload.files?.length
|
||||
|
||||
@@ -379,6 +379,8 @@ export interface Comment {
|
||||
decision: boolean;
|
||||
/** Who marked it as a decision (null when it is not one). */
|
||||
decidedBy: UserRef | null;
|
||||
/** A quick poll in it, if any. */
|
||||
poll: CommentPoll | null;
|
||||
/** A conversation (top-level comment and its replies) that was settled. */
|
||||
resolved: boolean;
|
||||
/** Who settled it (null while open). */
|
||||
@@ -387,6 +389,14 @@ export interface Comment {
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
/** A quick poll in a comment: each option with who voted it. */
|
||||
export interface CommentPoll {
|
||||
options: { text: string; voters: UserRef[] }[];
|
||||
closed: boolean;
|
||||
/** Index of the winning option once closed (null on a tie). */
|
||||
winner: number | null;
|
||||
}
|
||||
|
||||
/** A decision found by its text, with its ticket. */
|
||||
export interface Decision {
|
||||
comment: Comment;
|
||||
|
||||
@@ -336,6 +336,18 @@ export abstract class CommentsGateway {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo?: string | null,
|
||||
/** A quick poll's options (2–6). */
|
||||
poll?: string[] | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
abstract votePoll(
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
abstract closePoll(
|
||||
commentId: string,
|
||||
decide: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||
/** Adds (`on`) or takes back one's reaction. */
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
ResolveThreadDocument,
|
||||
VotePollDocument,
|
||||
ClosePollDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleaseBurnupDocument,
|
||||
ReleaseNotesDraftDocument,
|
||||
@@ -50,12 +52,33 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddCommentDocument, { issueId, body, replyTo })
|
||||
.mutate(AddCommentDocument, {
|
||||
issueId,
|
||||
body,
|
||||
replyTo,
|
||||
poll: poll?.length ? poll : null,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
||||
}
|
||||
|
||||
votePoll(
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(VotePollDocument, { commentId, option })
|
||||
.pipe(map((d) => toCommandResult(d.votePoll)));
|
||||
}
|
||||
|
||||
closePoll(commentId: string, decide: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ClosePollDocument, { commentId, decide })
|
||||
.pipe(map((d) => toCommandResult(d.closePoll)));
|
||||
}
|
||||
|
||||
react(
|
||||
commentId: string,
|
||||
emoji: string,
|
||||
|
||||
@@ -204,6 +204,16 @@ fragment CommentFields on Comment {
|
||||
decidedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
poll {
|
||||
options {
|
||||
text
|
||||
voters {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
closed
|
||||
winner
|
||||
}
|
||||
resolved
|
||||
resolvedBy {
|
||||
...UserRefFields
|
||||
|
||||
@@ -745,6 +745,14 @@ export type IssueFieldsFragment = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
@@ -800,6 +808,14 @@ export type CommentFieldsFragment = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
@@ -1002,6 +1018,14 @@ export type IssueQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
@@ -1365,6 +1389,14 @@ export type IssueCommentsQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
@@ -1376,12 +1408,31 @@ export type AddCommentMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo?: string | null | undefined;
|
||||
poll?: Array<string> | string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type AddCommentMutation = {
|
||||
addComment: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type VotePollMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
option?: number | null | undefined;
|
||||
}>;
|
||||
|
||||
export type VotePollMutation = {
|
||||
votePoll: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type ClosePollMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
decide: boolean;
|
||||
}>;
|
||||
|
||||
export type ClosePollMutation = {
|
||||
closePoll: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type EditCommentMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
body: string;
|
||||
@@ -1810,6 +1861,14 @@ export type SearchDecisionsQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
@@ -3135,6 +3194,14 @@ export type CommentChangedSubscription = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
@@ -3925,6 +3992,40 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -4336,6 +4437,40 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -6598,6 +6733,40 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -8915,6 +9084,40 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -8987,6 +9190,20 @@ export const AddCommentDocument = {
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'poll' } },
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -9019,6 +9236,14 @@ export const AddCommentDocument = {
|
||||
name: { kind: 'Name', value: 'replyTo' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -9051,6 +9276,178 @@ export const AddCommentDocument = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<AddCommentMutation, AddCommentMutationVariables>;
|
||||
export const VotePollDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'VotePoll' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'option' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'votePoll' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'option' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'option' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<VotePollMutation, VotePollMutationVariables>;
|
||||
export const ClosePollDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'ClosePoll' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'decide' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'closePoll' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'decide' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'decide' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<ClosePollMutation, ClosePollMutationVariables>;
|
||||
export const EditCommentDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -11787,6 +12184,40 @@ export const SearchDecisionsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -18728,6 +19159,40 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
|
||||
@@ -195,8 +195,25 @@ query IssueComments($idOrKey: String!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddComment($issueId: ID!, $body: String!, $replyTo: ID) {
|
||||
addComment(issueId: $issueId, body: $body, replyTo: $replyTo) {
|
||||
mutation AddComment(
|
||||
$issueId: ID!
|
||||
$body: String!
|
||||
$replyTo: ID
|
||||
$poll: [String!]
|
||||
) {
|
||||
addComment(issueId: $issueId, body: $body, replyTo: $replyTo, poll: $poll) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation VotePoll($commentId: ID!, $option: Int) {
|
||||
votePoll(commentId: $commentId, option: $option) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ClosePoll($commentId: ID!, $decide: Boolean!) {
|
||||
closePoll(commentId: $commentId, decide: $decide) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,3 +926,115 @@ a.ts-key {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A quick poll in a comment: each option a bar of its share. */
|
||||
.ts-poll {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
max-width: 28rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ts-poll__option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ts-line-strong) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
background: var(--ts-surface) !important;
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: -1;
|
||||
width: var(--share, 0%);
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&.is-mine {
|
||||
border-color: var(--ts-brand) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-winner::before {
|
||||
background: color-mix(in srgb, var(--ts-c-green) 22%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
border-color: var(--ts-brand) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll__count {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ts-poll__meta {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Small text buttons, not @pi/ui's sized ones. */
|
||||
.ts-poll__close {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ts-poll-editor {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
legend {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
.ts-input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
CommentPoll,
|
||||
DevLink,
|
||||
Issue,
|
||||
IssueLink,
|
||||
@@ -919,6 +920,68 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
}
|
||||
@if (!comment.deleted && comment.poll; as poll) {
|
||||
<div
|
||||
class="ts-poll"
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
poll.closed ? 'Encuesta cerrada' : 'Encuesta'
|
||||
"
|
||||
>
|
||||
@for (option of poll.options; track $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__option"
|
||||
[class.is-mine]="votedFor(poll) === $index"
|
||||
[class.is-winner]="
|
||||
poll.closed && poll.winner === $index
|
||||
"
|
||||
[disabled]="poll.closed || !project.myRole()"
|
||||
[attr.aria-pressed]="votedFor(poll) === $index"
|
||||
[style.--share]="share(poll, $index) + '%'"
|
||||
(click)="
|
||||
facade.votePoll(
|
||||
comment.id,
|
||||
votedFor(poll) === $index ? null : $index
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="ts-poll__text">
|
||||
@if (poll.closed && poll.winner === $index) {
|
||||
<span aria-hidden="true">🏆</span>
|
||||
}
|
||||
{{ option.text }}
|
||||
</span>
|
||||
<span
|
||||
class="ts-poll__count"
|
||||
[attr.title]="votersOf(option)"
|
||||
>{{ option.voters.length }}</span
|
||||
>
|
||||
</button>
|
||||
}
|
||||
<p class="ts-poll__meta">
|
||||
{{ totalVotes(poll) }}
|
||||
{{ totalVotes(poll) === 1 ? 'voto' : 'votos' }}
|
||||
@if (poll.closed) {
|
||||
· cerrada{{
|
||||
poll.winner === null ? ', sin ganadora' : ''
|
||||
}}
|
||||
} @else if (
|
||||
comment.author.id === facade.me() ||
|
||||
project.canManage()
|
||||
) {
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="facade.closePoll(comment.id)"
|
||||
>
|
||||
Cerrar y decidir
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted &&
|
||||
(comment.reactions.length || project.myRole())
|
||||
@@ -1144,6 +1207,65 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[loader]="loadImage"
|
||||
(submitted)="addComment()"
|
||||
/>
|
||||
@if (pollOn()) {
|
||||
<fieldset class="ts-poll-editor">
|
||||
<legend>Encuesta (2 a 6 opciones)</legend>
|
||||
@for (option of pollOptions(); track $index) {
|
||||
<div class="ts-poll-editor__row">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="80"
|
||||
[attr.aria-label]="'Opción ' + ($index + 1)"
|
||||
[placeholder]="'Opción ' + ($index + 1)"
|
||||
[value]="option"
|
||||
(input)="
|
||||
setPollOption($index, $any($event.target).value)
|
||||
"
|
||||
/>
|
||||
@if (pollOptions().length > 2) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
[attr.aria-label]="
|
||||
'Quitar la opción ' + ($index + 1)
|
||||
"
|
||||
(click)="removePollOption($index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ts-poll-editor__actions">
|
||||
@if (pollOptions().length < 6) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="pollOptions.set([...pollOptions(), ''])"
|
||||
>
|
||||
+ Añadir opción
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="pollOn.set(false)"
|
||||
>
|
||||
Quitar la encuesta
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
} @else {
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="startPoll()"
|
||||
>
|
||||
📊 Añadir una encuesta
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<pi-file-upload
|
||||
label="Adjuntar al comentario"
|
||||
[multiple]="true"
|
||||
@@ -2146,9 +2268,66 @@ export class IssuePage {
|
||||
protected addComment(): void {
|
||||
const body = this.commentBody().trim();
|
||||
if (!body) return;
|
||||
this.facade.addComment(body, this.commentFiles());
|
||||
const options = this.pollOptions()
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (this.pollOn() && options.length < 2) {
|
||||
this.toast.error('Una encuesta necesita al menos dos opciones');
|
||||
return;
|
||||
}
|
||||
this.facade.addComment(
|
||||
body,
|
||||
this.commentFiles(),
|
||||
null,
|
||||
this.pollOn() ? options : null,
|
||||
);
|
||||
this.commentBody.set('');
|
||||
this.commentFiles.set([]);
|
||||
this.pollOn.set(false);
|
||||
}
|
||||
|
||||
/** A quick poll in the new comment: its options while it is written. */
|
||||
protected readonly pollOn = signal(false);
|
||||
protected readonly pollOptions = signal<string[]>(['', '']);
|
||||
|
||||
protected startPoll(): void {
|
||||
this.pollOptions.set(['', '']);
|
||||
this.pollOn.set(true);
|
||||
}
|
||||
|
||||
protected setPollOption(index: number, value: string): void {
|
||||
this.pollOptions.update((options) =>
|
||||
options.map((o, i) => (i === index ? value : o)),
|
||||
);
|
||||
}
|
||||
|
||||
protected removePollOption(index: number): void {
|
||||
this.pollOptions.update((options) => options.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
/** The option I voted, or null. */
|
||||
protected votedFor(poll: CommentPoll): number | null {
|
||||
const me = this.facade.me();
|
||||
const index = poll.options.findIndex((o) =>
|
||||
o.voters.some((v) => v.id === me),
|
||||
);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
protected totalVotes(poll: CommentPoll): number {
|
||||
return poll.options.reduce((sum, o) => sum + o.voters.length, 0);
|
||||
}
|
||||
|
||||
/** An option's share of the votes, for its bar. */
|
||||
protected share(poll: CommentPoll, index: number): number {
|
||||
const total = this.totalVotes(poll);
|
||||
return total
|
||||
? Math.round((poll.options[index].voters.length / total) * 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
protected votersOf(option: { voters: { name: string }[] }): string {
|
||||
return option.voters.map((v) => v.name).join(', ') || 'Nadie aún';
|
||||
}
|
||||
|
||||
protected startReply(comment: Comment): void {
|
||||
|
||||
Reference in New Issue
Block a user