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

@@ -22,8 +22,44 @@ export class CommentResolver {
@Args('body') body: string,
@Args('replyTo', { type: () => ID, nullable: true })
replyTo: string | null,
@Args('poll', {
type: () => [String],
nullable: true,
description: 'A quick poll: 2 to 6 options',
})
poll: string[] | null,
) {
return this.comments.add(me.id, issueId, body, replyTo ?? null);
return this.comments.add(
me.id,
issueId,
body,
replyTo ?? null,
poll ?? null,
);
}
@Mutation(() => CommandResultGql, {
description:
"Vote in a comment's poll (option index), or take the vote back (null). Members, viewers too",
})
votePoll(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('option', { type: () => Int, nullable: true }) option: number | null,
) {
return this.comments.votePoll(me.id, commentId, option ?? null);
}
@Mutation(() => CommandResultGql, {
description:
'Close a poll (whoever started it, or an admin); with decide, the winner stays as a decision of the ticket',
})
closePoll(
@CurrentUser() me: User,
@Args('commentId', { type: () => ID }) commentId: string,
@Args('decide', { defaultValue: true }) decide: boolean,
) {
return this.comments.closePoll(me.id, commentId, decide);
}
@Mutation(() => CommandResultGql)

View File

@@ -555,6 +555,23 @@ export class CommentReactionGql {
users!: UserRefGql[];
}
@ObjectType('PollOption')
export class PollOptionGql {
@Field() text!: string;
@Field(() => [UserRefGql]) voters!: UserRefGql[];
}
@ObjectType('CommentPoll', { description: 'A quick poll in a comment' })
export class CommentPollGql {
@Field(() => [PollOptionGql]) options!: PollOptionGql[];
@Field() closed!: boolean;
@Field(() => Int, {
nullable: true,
description: 'Index of the winning option once closed (null on a tie)',
})
winner!: number | null;
}
@ObjectType('Comment')
export class CommentGql {
@Field(() => ID) id!: string;
@@ -585,6 +602,8 @@ export class CommentGql {
})
decidedBy!: UserRefGql | null;
@Field(() => String, { nullable: true }) decidedAt!: string | null;
@Field(() => CommentPollGql, { nullable: true })
poll!: CommentPollGql | null;
@Field({
description:
'A conversation (top-level comment and its replies) that was settled',

View File

@@ -19,8 +19,10 @@ import { CommentsService } from '@task-stream/api/tracker/application';
import { REACTIONS } from '@task-stream/api/tracker/domain';
import {
CommentBodyDto,
ClosePollDto,
ReactToCommentDto,
ResolveThreadDto,
VotePollDto,
} from './dto/requests.dto';
import { CommandResultDto } from './dto/responses.dto';
@@ -82,6 +84,39 @@ export class CommentsController {
return this.comments.markDecision(me.id, commentId, false);
}
@Post(':commentId/poll/vote')
@HttpCode(200)
@ApiOperation({
summary: "Vote in a comment's poll (members, viewers too)",
description: 'option: its index; null takes the vote back.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
votePoll(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: VotePollDto,
) {
return this.comments.votePoll(me.id, commentId, body.option);
}
@Post(':commentId/poll/close')
@HttpCode(200)
@ApiOperation({
summary: 'Close a poll (whoever started it, or an admin)',
description:
'With decide (default), the winner stays as a decision of the ticket.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
closePoll(
@CurrentUser() me: User,
@Param('commentId') commentId: string,
@Body() body: ClosePollDto,
) {
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
}
@Put(':commentId/resolution')
@ApiOperation({
summary: 'Settle a conversation (a top-level comment and its replies)',

View File

@@ -1316,6 +1316,40 @@ export class AddCommentDto extends CommentBodyDto {
@IsOptional()
@IsUUID()
replyTo?: string;
@ApiPropertyOptional({
type: [String],
description: 'A quick poll: 2 to 6 options',
example: ['Stripe', 'Redsys'],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@IsString({ each: true })
@MaxLength(80, { each: true })
poll?: string[];
}
export class VotePollDto {
@ApiProperty({
type: Number,
nullable: true,
description: 'Option index; null takes the vote back',
})
@ValidateIf((dto: VotePollDto) => dto.option !== null)
@IsInt()
@Min(0)
option!: number | null;
}
export class ClosePollDto {
@ApiPropertyOptional({
default: true,
description: 'Keep the winner as a decision of the ticket',
})
@IsOptional()
@IsBoolean()
decide?: boolean;
}
export class ReactToCommentDto {

View File

@@ -612,6 +612,22 @@ export class CommentReactionDto {
@ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[];
}
export class PollOptionDto {
@ApiProperty() text!: string;
@ApiProperty({ type: [UserRefDto] }) voters!: UserRefDto[];
}
export class CommentPollDto {
@ApiProperty({ type: [PollOptionDto] }) options!: PollOptionDto[];
@ApiProperty() closed!: boolean;
@ApiProperty({
type: Number,
nullable: true,
description: 'Index of the winning option once closed (null on a tie)',
})
winner!: number | null;
}
export class CommentDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) issueId!: string;
@@ -648,6 +664,8 @@ export class CommentDto {
decidedBy!: UserRefDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
decidedAt!: string | null;
@ApiProperty({ type: CommentPollDto, nullable: true })
poll!: CommentPollDto | null;
@ApiProperty({
description:
'A conversation (top-level comment and its replies) that was settled',

View File

@@ -990,6 +990,7 @@ export class IssuesController {
await this.refs.issueId(me.id, issue),
body.body,
body.replyTo ?? null,
body.poll ?? null,
);
}