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:
2026-10-20 20:48:12 +02:00
parent 93d028e870
commit 3a46a236e4
41 changed files with 2144 additions and 77 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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). */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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