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

@@ -50,6 +50,7 @@ export * from './lib/my-day/my-day';
export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service';
export * from './lib/commands/reviews';
export * from './lib/commands/polls';
export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -38,6 +38,7 @@ export class AddCommentCommandHandler
replyTo: command.replyTo
? await this.threadOf(command.replyTo, issue.id)
: null,
poll: command.poll,
});
this.eventPublisher.mergeObjectContext(comment);

View File

@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
public readonly body: string,
/** Comment it answers; a reply to a reply joins the same thread. */
public readonly replyTo: string | null = null,
/** A quick poll's options (2–6), if any. */
public readonly poll: string[] | null = null,
) {
super();
}

View File

@@ -2,6 +2,7 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
import { REVIEW_HANDLERS } from './reviews';
import { POLL_HANDLERS } from './polls';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -52,6 +53,7 @@ export const COMMAND_HANDLERS = [
MarkDecisionCommandHandler,
ResolveThreadCommandHandler,
...REVIEW_HANDLERS,
...POLL_HANDLERS,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,

View File

@@ -80,6 +80,7 @@ import { AddCommentCommand } from './add-comment.command';
import { ReactToCommentCommand } from './react-to-comment.command';
import { MarkDecisionCommand } from './mark-decision.command';
import { ResolveThreadCommand } from './resolve-thread.command';
import { ClosePollCommand, VotePollCommand } from './polls';
import {
AnswerReviewCommand,
CancelReviewCommand,
@@ -950,6 +951,57 @@ describe('tracker commands (event sourced)', () => {
);
});
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
const { run, rehydrator, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
);
await expect(
run(new AddCommentCommand('dev', issue.id, '¿Cuál?', null, ['Solo'])),
).rejects.toThrow(ValidationError);
const poll = await run(
new AddCommentCommand('dev', issue.id, '¿Cuál?', null, [
' Stripe ',
'Redsys',
]),
);
await run(new VotePollCommand('qa', poll.id, 0));
await run(new VotePollCommand('pm', poll.id, 1));
await run(new VotePollCommand('pm', poll.id, 0));
await run(new VotePollCommand('owner', poll.id, 1));
await expect(run(new VotePollCommand('qa', poll.id, 5))).rejects.toThrow(
ValidationError,
);
// Only whoever started it (or an admin) closes it.
await expect(run(new ClosePollCommand('qa', poll.id))).rejects.toThrow(
ForbiddenError,
);
await run(new ClosePollCommand('dev', poll.id));
const closed = await rehydrator.rehydrate(poll.id, Comment);
expect([closed.poll, closed.pollClosed, closed.pollWinner]).toEqual([
['Stripe', 'Redsys'],
true,
0,
]);
await expect(run(new VotePollCommand('qa', poll.id, 1))).rejects.toThrow(
BusinessRuleError,
);
const decided = store.events.find(
(e) =>
e.type === 'CommentAddedEvent' &&
(e.data as { comment: { body: string } }).comment.body.startsWith(
'Decidido',
),
);
expect((decided?.data as { comment: { body: string } }).comment.body).toBe(
'Decidido: **Stripe** (2 de 3 votos)',
);
expect(
(await rehydrator.rehydrate(decided?.streamId ?? '', Comment)).decision,
).toBe(true);
});
it('asks a teammate to review a ticket, who answers it', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);

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 { MarkDecisionCommand } from './commands/mark-decision.command';
import { ResolveThreadCommand } from './commands/resolve-thread.command';
import { ClosePollCommand, VotePollCommand } from './commands/polls';
import { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query';
import {
@@ -31,9 +32,32 @@ export class CommentsService {
issueId: string,
body: string,
replyTo: string | null = null,
poll: string[] | null = null,
): Promise<CommandResult> {
return this.commands.execute(
new AddCommentCommand(actorId, issueId, body, replyTo),
new AddCommentCommand(actorId, issueId, body, replyTo, poll),
);
}
/** Votes in a comment's poll (null takes the vote back). */
votePoll(
actorId: string,
commentId: string,
option: number | null,
): Promise<CommandResult> {
return this.commands.execute(
new VotePollCommand(actorId, commentId, option),
);
}
/** Closes a poll; with `decide`, its winner stays as a decision. */
closePoll(
actorId: string,
commentId: string,
decide = true,
): Promise<CommandResult> {
return this.commands.execute(
new ClosePollCommand(actorId, commentId, decide),
);
}

View File

@@ -33,6 +33,13 @@ export class CommentAddedEventHandler
decision: false,
decidedBy: null,
decidedAt: null,
poll: comment.poll?.length
? {
options: comment.poll.map((text) => ({ text, voters: [] })),
closed: false,
winner: null,
}
: null,
resolved: false,
resolvedBy: null,
resolvedAt: null,

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 { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
import { CommentPollEventHandler } from './comment-poll.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler';
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
CommentDecisionMarkedEventHandler,
CommentThreadResolvedEventHandler,
IssueReviewsEventHandler,
CommentPollEventHandler,
IssueVotedEventHandler,
IssueAssignedEventHandler,
IssueCreatedEventHandler,