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": {
|
"/api/comments/{commentId}/resolution": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.",
|
"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"
|
"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": {
|
"CommentDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -17672,6 +17934,15 @@
|
|||||||
"nullable": true,
|
"nullable": true,
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
},
|
},
|
||||||
|
"poll": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/CommentPollDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"resolved": {
|
"resolved": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "A conversation (top-level comment and its replies) that was settled"
|
"description": "A conversation (top-level comment and its replies) that was settled"
|
||||||
@@ -17715,6 +17986,7 @@
|
|||||||
"decision",
|
"decision",
|
||||||
"decidedBy",
|
"decidedBy",
|
||||||
"decidedAt",
|
"decidedAt",
|
||||||
|
"poll",
|
||||||
"resolved",
|
"resolved",
|
||||||
"resolvedBy",
|
"resolvedBy",
|
||||||
"resolvedAt",
|
"resolvedAt",
|
||||||
@@ -17768,6 +18040,17 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"description": "Comment it answers (one level: answering a reply joins its thread)"
|
"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": [
|
"required": [
|
||||||
@@ -17860,6 +18143,29 @@
|
|||||||
"on"
|
"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": {
|
"ResolveThreadDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ type Comment {
|
|||||||
editedAt: String
|
editedAt: String
|
||||||
id: ID!
|
id: ID!
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
|
poll: CommentPoll
|
||||||
|
|
||||||
"""In the order they are offered; only the ones somebody chose"""
|
"""In the order they are offered; only the ones somebody chose"""
|
||||||
reactions: [CommentReaction!]!
|
reactions: [CommentReaction!]!
|
||||||
@@ -336,6 +337,15 @@ type CommentPage {
|
|||||||
total: Int!
|
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"""
|
"""One emoji on a comment"""
|
||||||
type CommentReaction {
|
type CommentReaction {
|
||||||
emoji: String!
|
emoji: String!
|
||||||
@@ -964,7 +974,14 @@ type Mutation {
|
|||||||
"""
|
"""
|
||||||
Comment on a ticket, or answer a comment (replyTo; one level of replies)
|
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)
|
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"""
|
"""Drop my reminder on a ticket"""
|
||||||
clearReminder(issueId: ID!): Boolean!
|
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.
|
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!
|
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
|
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
||||||
"""
|
"""
|
||||||
@@ -1373,6 +1400,11 @@ type OptionCount {
|
|||||||
value: String!
|
value: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PollOption {
|
||||||
|
text: String!
|
||||||
|
voters: [UserRef!]!
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
One of my active projects at a glance: open work, risks, sprint, releases and epics
|
One of my active projects at a glance: open work, risks, sprint, releases and epics
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1201,6 +1201,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/comments/{commentId}/resolution": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3288,6 +3328,16 @@ export interface components {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
users: components["schemas"]["UserRefDto"][];
|
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: {
|
CommentDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -3311,6 +3361,7 @@ export interface components {
|
|||||||
decidedBy: components["schemas"]["UserRefDto"] | null;
|
decidedBy: components["schemas"]["UserRefDto"] | null;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
decidedAt: string | null;
|
decidedAt: string | null;
|
||||||
|
poll: components["schemas"]["CommentPollDto"] | null;
|
||||||
/** @description A conversation (top-level comment and its replies) that was settled */
|
/** @description A conversation (top-level comment and its replies) that was settled */
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
/** @description Who settled it */
|
/** @description Who settled it */
|
||||||
@@ -3339,6 +3390,14 @@ export interface components {
|
|||||||
* @description Comment it answers (one level: answering a reply joins its thread)
|
* @description Comment it answers (one level: answering a reply joins its thread)
|
||||||
*/
|
*/
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
|
/**
|
||||||
|
* @description A quick poll: 2 to 6 options
|
||||||
|
* @example [
|
||||||
|
* "Stripe",
|
||||||
|
* "Redsys"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
poll?: string[];
|
||||||
};
|
};
|
||||||
AttachFileDto: {
|
AttachFileDto: {
|
||||||
/**
|
/**
|
||||||
@@ -3381,6 +3440,17 @@ export interface components {
|
|||||||
/** @description true adds the reaction, false takes it back */
|
/** @description true adds the reaction, false takes it back */
|
||||||
on: boolean;
|
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: {
|
ResolveThreadDto: {
|
||||||
/** @description What was settled: added as a reply marked as a decision before it closes */
|
/** @description What was settled: added as a reply marked as a decision before it closes */
|
||||||
conclusion?: string;
|
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: {
|
CommentsController_resolveThread: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -299,6 +299,17 @@ export class IssuesResolver {
|
|||||||
})),
|
})),
|
||||||
decision: c.decision || undefined,
|
decision: c.decision || undefined,
|
||||||
resolved: c.resolved || 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;
|
: undefined;
|
||||||
const decisions = (
|
const decisions = (
|
||||||
@@ -512,18 +523,77 @@ export class IssuesResolver {
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Id of the comment it answers (see get_issue)'),
|
.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,
|
annotations: WRITE,
|
||||||
})
|
})
|
||||||
comment(
|
comment(
|
||||||
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
|
{
|
||||||
|
issue,
|
||||||
|
body,
|
||||||
|
replyTo,
|
||||||
|
poll,
|
||||||
|
}: { issue: string; body: string; replyTo?: string; poll?: string[] },
|
||||||
ctx: McpContext,
|
ctx: McpContext,
|
||||||
) {
|
) {
|
||||||
return toolResult(() =>
|
return toolResult(() =>
|
||||||
this.api.data(
|
this.api.data(
|
||||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||||
params: { path: { issue } },
|
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 },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 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 |
|
| 🌍 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 |
|
| 🗑️ 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 |
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 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 |
|
| 📝 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 |
|
| 👥 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 |
|
| 💬 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 |
|
| 🔎 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 |
|
| 📋 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 |
|
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||||
|
|||||||
@@ -239,6 +239,12 @@ stateDiagram-v2
|
|||||||
- **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`,
|
- **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
|
`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.
|
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)`,
|
- **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;
|
`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
|
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` |
|
| 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` |
|
| 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` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
@@ -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}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `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` |
|
| 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` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
|
|||||||
@@ -301,14 +301,14 @@ visualización de la plataforma:
|
|||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «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» |
|
| `/` | 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 |
|
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||||
| `/explore` | Proyectos públicos (sin sesión) |
|
| `/explore` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. 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/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/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/releases` | Releases |
|
||||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
|
|||||||
@@ -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 |
|
| | `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 |
|
| | `request_review` | Pedir a alguien del proyecto que revise un ticket |
|
||||||
| | `answer_review` | Responder una revisión: todo bien o cambios |
|
| | `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 |
|
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export * from './lib/my-day/my-day';
|
|||||||
export * from './lib/ports/my-days.repository';
|
export * from './lib/ports/my-days.repository';
|
||||||
export * from './lib/my-day.service';
|
export * from './lib/my-day.service';
|
||||||
export * from './lib/commands/reviews';
|
export * from './lib/commands/reviews';
|
||||||
|
export * from './lib/commands/polls';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
export * from './lib/ports/presence.store';
|
export * from './lib/ports/presence.store';
|
||||||
export * from './lib/presence.service';
|
export * from './lib/presence.service';
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export class AddCommentCommandHandler
|
|||||||
replyTo: command.replyTo
|
replyTo: command.replyTo
|
||||||
? await this.threadOf(command.replyTo, issue.id)
|
? await this.threadOf(command.replyTo, issue.id)
|
||||||
: null,
|
: null,
|
||||||
|
poll: command.poll,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventPublisher.mergeObjectContext(comment);
|
this.eventPublisher.mergeObjectContext(comment);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
|||||||
public readonly body: string,
|
public readonly body: string,
|
||||||
/** Comment it answers; a reply to a reply joins the same thread. */
|
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||||
public readonly replyTo: string | null = null,
|
public readonly replyTo: string | null = null,
|
||||||
|
/** A quick poll's options (2–6), if any. */
|
||||||
|
public readonly poll: string[] | null = null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler
|
|||||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||||
import { REVIEW_HANDLERS } from './reviews';
|
import { REVIEW_HANDLERS } from './reviews';
|
||||||
|
import { POLL_HANDLERS } from './polls';
|
||||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||||
@@ -52,6 +53,7 @@ export const COMMAND_HANDLERS = [
|
|||||||
MarkDecisionCommandHandler,
|
MarkDecisionCommandHandler,
|
||||||
ResolveThreadCommandHandler,
|
ResolveThreadCommandHandler,
|
||||||
...REVIEW_HANDLERS,
|
...REVIEW_HANDLERS,
|
||||||
|
...POLL_HANDLERS,
|
||||||
CreateProjectCommandHandler,
|
CreateProjectCommandHandler,
|
||||||
UpdateProjectCommandHandler,
|
UpdateProjectCommandHandler,
|
||||||
UpdateProjectSettingsCommandHandler,
|
UpdateProjectSettingsCommandHandler,
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import { AddCommentCommand } from './add-comment.command';
|
|||||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||||
import { MarkDecisionCommand } from './mark-decision.command';
|
import { MarkDecisionCommand } from './mark-decision.command';
|
||||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||||
|
import { ClosePollCommand, VotePollCommand } from './polls';
|
||||||
import {
|
import {
|
||||||
AnswerReviewCommand,
|
AnswerReviewCommand,
|
||||||
CancelReviewCommand,
|
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 () => {
|
it('asks a teammate to review a ticket, who answers it', async () => {
|
||||||
const { run, rehydrator } = await setup();
|
const { run, rehydrator } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
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 { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||||
|
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||||
import { Page } from './ports/find-issues.repository';
|
import { Page } from './ports/find-issues.repository';
|
||||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||||
import {
|
import {
|
||||||
@@ -31,9 +32,32 @@ export class CommentsService {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
body: string,
|
body: string,
|
||||||
replyTo: string | null = null,
|
replyTo: string | null = null,
|
||||||
|
poll: string[] | null = null,
|
||||||
): Promise<CommandResult> {
|
): Promise<CommandResult> {
|
||||||
return this.commands.execute(
|
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,
|
decision: false,
|
||||||
decidedBy: null,
|
decidedBy: null,
|
||||||
decidedAt: null,
|
decidedAt: null,
|
||||||
|
poll: comment.poll?.length
|
||||||
|
? {
|
||||||
|
options: comment.poll.map((text) => ({ text, voters: [] })),
|
||||||
|
closed: false,
|
||||||
|
winner: null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
resolved: false,
|
resolved: false,
|
||||||
resolvedBy: null,
|
resolvedBy: null,
|
||||||
resolvedAt: 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 { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||||
|
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||||
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
|
|||||||
CommentDecisionMarkedEventHandler,
|
CommentDecisionMarkedEventHandler,
|
||||||
CommentThreadResolvedEventHandler,
|
CommentThreadResolvedEventHandler,
|
||||||
IssueReviewsEventHandler,
|
IssueReviewsEventHandler,
|
||||||
|
CommentPollEventHandler,
|
||||||
IssueVotedEventHandler,
|
IssueVotedEventHandler,
|
||||||
IssueAssignedEventHandler,
|
IssueAssignedEventHandler,
|
||||||
IssueCreatedEventHandler,
|
IssueCreatedEventHandler,
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||||
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.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 { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||||
@@ -30,6 +34,32 @@ export interface CommentProps {
|
|||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
editedAt: string | null;
|
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;
|
const MAX_BODY = 20_000;
|
||||||
@@ -47,6 +77,11 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
|||||||
public decision = false;
|
public decision = false;
|
||||||
/** A top-level comment whose conversation was settled. */
|
/** A top-level comment whose conversation was settled. */
|
||||||
public resolved = false;
|
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 deleted = false;
|
||||||
public createdAt!: string;
|
public createdAt!: string;
|
||||||
public editedAt: string | null = null;
|
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). */
|
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
||||||
delete(actorId: string, canModerate: boolean): void {
|
delete(actorId: string, canModerate: boolean): void {
|
||||||
this.assertAlive();
|
this.assertAlive();
|
||||||
@@ -156,6 +248,16 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
|||||||
|
|
||||||
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
|
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
|
||||||
Object.assign(this, comment);
|
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 {
|
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-decision-marked.event';
|
||||||
export * from './comment-thread-resolved.event';
|
export * from './comment-thread-resolved.event';
|
||||||
export * from './issue-review.events';
|
export * from './issue-review.events';
|
||||||
|
export * from './comment-poll.events';
|
||||||
export * from './comment-deleted.event';
|
export * from './comment-deleted.event';
|
||||||
export * from './release-created.event';
|
export * from './release-created.event';
|
||||||
export * from './release-updated.event';
|
export * from './release-updated.event';
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { newId } from '@task-stream/api/shared/domain';
|
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';
|
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -12,6 +17,8 @@ export class CommentFactory {
|
|||||||
body: string;
|
body: string;
|
||||||
/** Top-level comment it answers. */
|
/** Top-level comment it answers. */
|
||||||
replyTo?: string | null;
|
replyTo?: string | null;
|
||||||
|
/** A quick poll's options. */
|
||||||
|
poll?: string[] | null;
|
||||||
}): Comment {
|
}): Comment {
|
||||||
const data: CommentProps = {
|
const data: CommentProps = {
|
||||||
id: newId(),
|
id: newId(),
|
||||||
@@ -23,6 +30,7 @@ export class CommentFactory {
|
|||||||
deleted: false,
|
deleted: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
editedAt: null,
|
editedAt: null,
|
||||||
|
...(props.poll?.length ? { poll: validPoll(props.poll) } : {}),
|
||||||
};
|
};
|
||||||
const comment = new Comment(data.id);
|
const comment = new Comment(data.id);
|
||||||
Object.assign(comment, { ...data });
|
Object.assign(comment, { ...data });
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export class CommentReactionReadModel {
|
|||||||
users!: UserRefReadModel[];
|
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 {
|
export class CommentReadModel {
|
||||||
id!: string;
|
id!: string;
|
||||||
issueId!: string;
|
issueId!: string;
|
||||||
@@ -26,6 +34,8 @@ export class CommentReadModel {
|
|||||||
/** Who marked it and when (null when it is not a decision). */
|
/** Who marked it and when (null when it is not a decision). */
|
||||||
decidedBy!: UserRefReadModel | null;
|
decidedBy!: UserRefReadModel | null;
|
||||||
decidedAt!: string | null;
|
decidedAt!: string | null;
|
||||||
|
/** A quick poll in the comment, if any. */
|
||||||
|
poll!: CommentPollReadModel | null;
|
||||||
/** A top-level comment whose conversation was settled. */
|
/** A top-level comment whose conversation was settled. */
|
||||||
resolved!: boolean;
|
resolved!: boolean;
|
||||||
/** Who settled it and when (null while open). */
|
/** Who settled it and when (null while open). */
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
|||||||
decision: doc.decision ?? false,
|
decision: doc.decision ?? false,
|
||||||
decidedBy: doc.decidedBy ?? null,
|
decidedBy: doc.decidedBy ?? null,
|
||||||
decidedAt: doc.decidedAt ?? null,
|
decidedAt: doc.decidedAt ?? null,
|
||||||
|
poll: doc.poll ?? null,
|
||||||
resolved: doc.resolved ?? false,
|
resolved: doc.resolved ?? false,
|
||||||
resolvedBy: doc.resolvedBy ?? null,
|
resolvedBy: doc.resolvedBy ?? null,
|
||||||
resolvedAt: doc.resolvedAt ?? null,
|
resolvedAt: doc.resolvedAt ?? null,
|
||||||
|
|||||||
@@ -22,8 +22,44 @@ export class CommentResolver {
|
|||||||
@Args('body') body: string,
|
@Args('body') body: string,
|
||||||
@Args('replyTo', { type: () => ID, nullable: true })
|
@Args('replyTo', { type: () => ID, nullable: true })
|
||||||
replyTo: string | null,
|
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)
|
@Mutation(() => CommandResultGql)
|
||||||
|
|||||||
@@ -555,6 +555,23 @@ export class CommentReactionGql {
|
|||||||
users!: UserRefGql[];
|
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')
|
@ObjectType('Comment')
|
||||||
export class CommentGql {
|
export class CommentGql {
|
||||||
@Field(() => ID) id!: string;
|
@Field(() => ID) id!: string;
|
||||||
@@ -585,6 +602,8 @@ export class CommentGql {
|
|||||||
})
|
})
|
||||||
decidedBy!: UserRefGql | null;
|
decidedBy!: UserRefGql | null;
|
||||||
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
||||||
|
@Field(() => CommentPollGql, { nullable: true })
|
||||||
|
poll!: CommentPollGql | null;
|
||||||
@Field({
|
@Field({
|
||||||
description:
|
description:
|
||||||
'A conversation (top-level comment and its replies) that was settled',
|
'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 { REACTIONS } from '@task-stream/api/tracker/domain';
|
||||||
import {
|
import {
|
||||||
CommentBodyDto,
|
CommentBodyDto,
|
||||||
|
ClosePollDto,
|
||||||
ReactToCommentDto,
|
ReactToCommentDto,
|
||||||
ResolveThreadDto,
|
ResolveThreadDto,
|
||||||
|
VotePollDto,
|
||||||
} from './dto/requests.dto';
|
} from './dto/requests.dto';
|
||||||
import { CommandResultDto } from './dto/responses.dto';
|
import { CommandResultDto } from './dto/responses.dto';
|
||||||
|
|
||||||
@@ -82,6 +84,39 @@ export class CommentsController {
|
|||||||
return this.comments.markDecision(me.id, commentId, false);
|
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')
|
@Put(':commentId/resolution')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||||
|
|||||||
@@ -1316,6 +1316,40 @@ export class AddCommentDto extends CommentBodyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
replyTo?: string;
|
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 {
|
export class ReactToCommentDto {
|
||||||
|
|||||||
@@ -612,6 +612,22 @@ export class CommentReactionDto {
|
|||||||
@ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[];
|
@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 {
|
export class CommentDto {
|
||||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||||
@@ -648,6 +664,8 @@ export class CommentDto {
|
|||||||
decidedBy!: UserRefDto | null;
|
decidedBy!: UserRefDto | null;
|
||||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
decidedAt!: string | null;
|
decidedAt!: string | null;
|
||||||
|
@ApiProperty({ type: CommentPollDto, nullable: true })
|
||||||
|
poll!: CommentPollDto | null;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description:
|
description:
|
||||||
'A conversation (top-level comment and its replies) that was settled',
|
'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),
|
await this.refs.issueId(me.id, issue),
|
||||||
body.body,
|
body.body,
|
||||||
body.replyTo ?? null,
|
body.replyTo ?? null,
|
||||||
|
body.poll ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ export const issuePageEvents = eventGroup({
|
|||||||
files?: File[];
|
files?: File[];
|
||||||
/** Comment it answers (one level of replies). */
|
/** Comment it answers (one level of replies). */
|
||||||
replyTo?: string | null;
|
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 }>(),
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||||
|
|||||||
@@ -101,12 +101,29 @@ export class IssueFacade {
|
|||||||
body: string,
|
body: string,
|
||||||
files: File[] = [],
|
files: File[] = [],
|
||||||
replyTo: string | null = null,
|
replyTo: string | null = null,
|
||||||
|
poll: string[] | null = null,
|
||||||
): void {
|
): void {
|
||||||
this.withIssue((issueId) =>
|
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). */
|
/** To the trash (true) or back from it (false). */
|
||||||
setTrashed(trashed: boolean): void {
|
setTrashed(trashed: boolean): void {
|
||||||
this.withIssue((issueId) =>
|
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
|
events
|
||||||
.on(issuePageEvents.reviewRequested)
|
.on(issuePageEvents.reviewRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -698,7 +724,12 @@ export const IssueStore = signalStore(
|
|||||||
mergeMap(({ payload }) =>
|
mergeMap(({ payload }) =>
|
||||||
runCommand(
|
runCommand(
|
||||||
comments
|
comments
|
||||||
.add(payload.issueId, payload.body, payload.replyTo ?? null)
|
.add(
|
||||||
|
payload.issueId,
|
||||||
|
payload.body,
|
||||||
|
payload.replyTo ?? null,
|
||||||
|
payload.poll ?? null,
|
||||||
|
)
|
||||||
.pipe(
|
.pipe(
|
||||||
concatMap((comment) =>
|
concatMap((comment) =>
|
||||||
payload.files?.length
|
payload.files?.length
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ export interface Comment {
|
|||||||
decision: boolean;
|
decision: boolean;
|
||||||
/** Who marked it as a decision (null when it is not one). */
|
/** Who marked it as a decision (null when it is not one). */
|
||||||
decidedBy: UserRef | null;
|
decidedBy: UserRef | null;
|
||||||
|
/** A quick poll in it, if any. */
|
||||||
|
poll: CommentPoll | null;
|
||||||
/** A conversation (top-level comment and its replies) that was settled. */
|
/** A conversation (top-level comment and its replies) that was settled. */
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
/** Who settled it (null while open). */
|
/** Who settled it (null while open). */
|
||||||
@@ -387,6 +389,14 @@ export interface Comment {
|
|||||||
editedAt: string | null;
|
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. */
|
/** A decision found by its text, with its ticket. */
|
||||||
export interface Decision {
|
export interface Decision {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
|
|||||||
@@ -336,6 +336,18 @@ export abstract class CommentsGateway {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
body: string,
|
body: string,
|
||||||
replyTo?: string | null,
|
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>;
|
): Observable<CommandResult>;
|
||||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||||
/** Adds (`on`) or takes back one's reaction. */
|
/** Adds (`on`) or takes back one's reaction. */
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
ReactToCommentDocument,
|
ReactToCommentDocument,
|
||||||
MarkDecisionDocument,
|
MarkDecisionDocument,
|
||||||
ResolveThreadDocument,
|
ResolveThreadDocument,
|
||||||
|
VotePollDocument,
|
||||||
|
ClosePollDocument,
|
||||||
PublishReleaseDocument,
|
PublishReleaseDocument,
|
||||||
ReleaseBurnupDocument,
|
ReleaseBurnupDocument,
|
||||||
ReleaseNotesDraftDocument,
|
ReleaseNotesDraftDocument,
|
||||||
@@ -50,12 +52,33 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
|||||||
issueId: string,
|
issueId: string,
|
||||||
body: string,
|
body: string,
|
||||||
replyTo: string | null = null,
|
replyTo: string | null = null,
|
||||||
|
poll: string[] | null = null,
|
||||||
): Observable<CommandResult> {
|
): Observable<CommandResult> {
|
||||||
return this.gql
|
return this.gql
|
||||||
.mutate(AddCommentDocument, { issueId, body, replyTo })
|
.mutate(AddCommentDocument, {
|
||||||
|
issueId,
|
||||||
|
body,
|
||||||
|
replyTo,
|
||||||
|
poll: poll?.length ? poll : null,
|
||||||
|
})
|
||||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
.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(
|
react(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
emoji: string,
|
emoji: string,
|
||||||
|
|||||||
@@ -204,6 +204,16 @@ fragment CommentFields on Comment {
|
|||||||
decidedBy {
|
decidedBy {
|
||||||
...UserRefFields
|
...UserRefFields
|
||||||
}
|
}
|
||||||
|
poll {
|
||||||
|
options {
|
||||||
|
text
|
||||||
|
voters {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closed
|
||||||
|
winner
|
||||||
|
}
|
||||||
resolved
|
resolved
|
||||||
resolvedBy {
|
resolvedBy {
|
||||||
...UserRefFields
|
...UserRefFields
|
||||||
|
|||||||
@@ -745,6 +745,14 @@ export type IssueFieldsFragment = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
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;
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
@@ -800,6 +808,14 @@ export type CommentFieldsFragment = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
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;
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
};
|
};
|
||||||
@@ -1002,6 +1018,14 @@ export type IssueQuery = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
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;
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
@@ -1365,6 +1389,14 @@ export type IssueCommentsQuery = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
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;
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
}>;
|
}>;
|
||||||
@@ -1376,12 +1408,31 @@ export type AddCommentMutationVariables = Exact<{
|
|||||||
issueId: string;
|
issueId: string;
|
||||||
body: string;
|
body: string;
|
||||||
replyTo?: string | null | undefined;
|
replyTo?: string | null | undefined;
|
||||||
|
poll?: Array<string> | string | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type AddCommentMutation = {
|
export type AddCommentMutation = {
|
||||||
addComment: { id: string; key: string | null; version: number };
|
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<{
|
export type EditCommentMutationVariables = Exact<{
|
||||||
commentId: string;
|
commentId: string;
|
||||||
body: string;
|
body: string;
|
||||||
@@ -1810,6 +1861,14 @@ export type SearchDecisionsQuery = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
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;
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
};
|
};
|
||||||
@@ -3135,6 +3194,14 @@ export type CommentChangedSubscription = {
|
|||||||
users: Array<{ id: string; name: string }>;
|
users: Array<{ id: string; name: string }>;
|
||||||
}>;
|
}>;
|
||||||
decidedBy: { id: string; name: string } | null;
|
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;
|
resolvedBy: { id: string; name: string } | null;
|
||||||
author: { id: string; name: string };
|
author: { id: string; name: string };
|
||||||
} | null;
|
} | 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', name: { kind: 'Name', value: 'resolved' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
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', name: { kind: 'Name', value: 'resolved' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
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', name: { kind: 'Name', value: 'resolved' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
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', name: { kind: 'Name', value: 'resolved' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
@@ -8987,6 +9190,20 @@ export const AddCommentDocument = {
|
|||||||
},
|
},
|
||||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
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: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -9019,6 +9236,14 @@ export const AddCommentDocument = {
|
|||||||
name: { kind: 'Name', value: 'replyTo' },
|
name: { kind: 'Name', value: 'replyTo' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'poll' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'poll' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -9051,6 +9276,178 @@ export const AddCommentDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<AddCommentMutation, AddCommentMutationVariables>;
|
} 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 = {
|
export const EditCommentDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
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', name: { kind: 'Name', value: 'resolved' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
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', name: { kind: 'Name', value: 'resolved' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
|
|||||||
@@ -195,8 +195,25 @@ query IssueComments($idOrKey: String!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutation AddComment($issueId: ID!, $body: String!, $replyTo: ID) {
|
mutation AddComment(
|
||||||
addComment(issueId: $issueId, body: $body, replyTo: $replyTo) {
|
$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
|
...CommandResultFields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -926,3 +926,115 @@ a.ts-key {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
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 {
|
import {
|
||||||
Attachment,
|
Attachment,
|
||||||
Comment,
|
Comment,
|
||||||
|
CommentPoll,
|
||||||
DevLink,
|
DevLink,
|
||||||
Issue,
|
Issue,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
@@ -919,6 +920,68 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
[loader]="loadImage"
|
[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 (
|
@if (
|
||||||
!comment.deleted &&
|
!comment.deleted &&
|
||||||
(comment.reactions.length || project.myRole())
|
(comment.reactions.length || project.myRole())
|
||||||
@@ -1144,6 +1207,65 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
[loader]="loadImage"
|
[loader]="loadImage"
|
||||||
(submitted)="addComment()"
|
(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
|
<pi-file-upload
|
||||||
label="Adjuntar al comentario"
|
label="Adjuntar al comentario"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
@@ -2146,9 +2268,66 @@ export class IssuePage {
|
|||||||
protected addComment(): void {
|
protected addComment(): void {
|
||||||
const body = this.commentBody().trim();
|
const body = this.commentBody().trim();
|
||||||
if (!body) return;
|
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.commentBody.set('');
|
||||||
this.commentFiles.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 {
|
protected startReply(comment: Comment): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user