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:
@@ -50,6 +50,7 @@ export * from './lib/my-day/my-day';
|
||||
export * from './lib/ports/my-days.repository';
|
||||
export * from './lib/my-day.service';
|
||||
export * from './lib/commands/reviews';
|
||||
export * from './lib/commands/polls';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
@@ -38,6 +38,7 @@ export class AddCommentCommandHandler
|
||||
replyTo: command.replyTo
|
||||
? await this.threadOf(command.replyTo, issue.id)
|
||||
: null,
|
||||
poll: command.poll,
|
||||
});
|
||||
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
|
||||
@@ -8,6 +8,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
||||
public readonly body: string,
|
||||
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||
public readonly replyTo: string | null = null,
|
||||
/** A quick poll's options (2–6), if any. */
|
||||
public readonly poll: string[] | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler
|
||||
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||
import { REVIEW_HANDLERS } from './reviews';
|
||||
import { POLL_HANDLERS } from './polls';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -52,6 +53,7 @@ export const COMMAND_HANDLERS = [
|
||||
MarkDecisionCommandHandler,
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
...POLL_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -80,6 +80,7 @@ import { AddCommentCommand } from './add-comment.command';
|
||||
import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './polls';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
@@ -950,6 +951,57 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pagos', {}),
|
||||
);
|
||||
await expect(
|
||||
run(new AddCommentCommand('dev', issue.id, '¿Cuál?', null, ['Solo'])),
|
||||
).rejects.toThrow(ValidationError);
|
||||
const poll = await run(
|
||||
new AddCommentCommand('dev', issue.id, '¿Cuál?', null, [
|
||||
' Stripe ',
|
||||
'Redsys',
|
||||
]),
|
||||
);
|
||||
await run(new VotePollCommand('qa', poll.id, 0));
|
||||
await run(new VotePollCommand('pm', poll.id, 1));
|
||||
await run(new VotePollCommand('pm', poll.id, 0));
|
||||
await run(new VotePollCommand('owner', poll.id, 1));
|
||||
await expect(run(new VotePollCommand('qa', poll.id, 5))).rejects.toThrow(
|
||||
ValidationError,
|
||||
);
|
||||
// Only whoever started it (or an admin) closes it.
|
||||
await expect(run(new ClosePollCommand('qa', poll.id))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await run(new ClosePollCommand('dev', poll.id));
|
||||
const closed = await rehydrator.rehydrate(poll.id, Comment);
|
||||
expect([closed.poll, closed.pollClosed, closed.pollWinner]).toEqual([
|
||||
['Stripe', 'Redsys'],
|
||||
true,
|
||||
0,
|
||||
]);
|
||||
await expect(run(new VotePollCommand('qa', poll.id, 1))).rejects.toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
const decided = store.events.find(
|
||||
(e) =>
|
||||
e.type === 'CommentAddedEvent' &&
|
||||
(e.data as { comment: { body: string } }).comment.body.startsWith(
|
||||
'Decidido',
|
||||
),
|
||||
);
|
||||
expect((decided?.data as { comment: { body: string } }).comment.body).toBe(
|
||||
'Decidido: **Stripe** (2 de 3 votos)',
|
||||
);
|
||||
expect(
|
||||
(await rehydrator.rehydrate(decided?.streamId ?? '', Comment)).decision,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('asks a teammate to review a ticket, who answers it', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
108
libs/api/tracker/application/src/lib/commands/polls.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { CommentFactory, ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* A quick poll in a comment («¿Stripe o Redsys?»): members vote, one vote
|
||||
* each; whoever started it closes it, and the winner can stay as a decision.
|
||||
*/
|
||||
|
||||
export class VotePollCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
/** Null takes the vote back. */
|
||||
public readonly option: number | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClosePollCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
/** Keep the winner as a decision of the ticket (a reply marked so). */
|
||||
public readonly decide = true,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Voting is for members, viewers included: like reacting. */
|
||||
@CommandHandler(VotePollCommand)
|
||||
export class VotePollCommandHandler
|
||||
implements ICommandHandler<VotePollCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: VotePollCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
this.access.assertSees(
|
||||
project,
|
||||
await this.access.issue(comment.issueId),
|
||||
command.actorId,
|
||||
);
|
||||
comment.votePoll(command.actorId, command.option);
|
||||
await comment.commitAndWait();
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(ClosePollCommand)
|
||||
export class ClosePollCommandHandler
|
||||
implements ICommandHandler<ClosePollCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: ClosePollCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
if (!project.isMember(command.actorId))
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
const voters = Object.keys(comment.pollVotes).length;
|
||||
const winner = comment.closePoll(
|
||||
command.actorId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await comment.commitAndWait();
|
||||
if (command.decide && winner !== null && comment.poll) {
|
||||
const votes = Object.values(comment.pollVotes).filter(
|
||||
(v) => v === winner,
|
||||
).length;
|
||||
// The result, as a decision above the ticket.
|
||||
const decision = this.commentFactory.create({
|
||||
issueId: comment.issueId,
|
||||
projectId: comment.projectId,
|
||||
authorId: command.actorId,
|
||||
body: `Decidido: **${comment.poll[winner]}** (${votes} de ${voters} ${voters === 1 ? 'voto' : 'votos'})`,
|
||||
replyTo: comment.replyTo ?? comment.id,
|
||||
});
|
||||
decision.markDecision(command.actorId, true);
|
||||
this.eventPublisher.mergeObjectContext(decision);
|
||||
await decision.commitAndWait();
|
||||
}
|
||||
return ack(comment);
|
||||
}
|
||||
}
|
||||
|
||||
export const POLL_HANDLERS = [VotePollCommandHandler, ClosePollCommandHandler];
|
||||
@@ -12,6 +12,7 @@ import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
import { GetCommentsQuery } from './queries/get-comments.query';
|
||||
import {
|
||||
@@ -31,9 +32,32 @@ export class CommentsService {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo),
|
||||
new AddCommentCommand(actorId, issueId, body, replyTo, poll),
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
votePoll(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new VotePollCommand(actorId, commentId, option),
|
||||
);
|
||||
}
|
||||
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
closePoll(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
decide = true,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new ClosePollCommand(actorId, commentId, decide),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ export class CommentAddedEventHandler
|
||||
decision: false,
|
||||
decidedBy: null,
|
||||
decidedAt: null,
|
||||
poll: comment.poll?.length
|
||||
? {
|
||||
options: comment.poll.map((text) => ({ text, voters: [] })),
|
||||
closed: false,
|
||||
winner: null,
|
||||
}
|
||||
: null,
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
/** A comment's poll as the read side shows it: who voted each option. */
|
||||
@EventsHandler(CommentPollVotedEvent, CommentPollClosedEvent)
|
||||
export class CommentPollEventHandler
|
||||
implements IEventHandler<CommentPollVotedEvent | CommentPollClosedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentPollVotedEvent | CommentPollClosedEvent) {
|
||||
const comment = await this.findComments.findById(event.aggregateId);
|
||||
if (!comment?.poll) return;
|
||||
let poll = comment.poll;
|
||||
if (event instanceof CommentPollVotedEvent) {
|
||||
const refs = await this.users.refs(Object.keys(event.votes));
|
||||
poll = {
|
||||
...poll,
|
||||
options: poll.options.map((option, index) => ({
|
||||
text: option.text,
|
||||
voters: Object.entries(event.votes)
|
||||
.filter(([, vote]) => vote === index)
|
||||
.map(([userId]) => refs.of(userId)),
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
poll = { ...poll, closed: true, winner: event.winner };
|
||||
}
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
poll,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
|
||||
import { IssueCreatedEventHandler } from './issue-created.event-handler';
|
||||
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
CommentPollEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
IssueAssignedEventHandler,
|
||||
IssueCreatedEventHandler,
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
} from '../events/comment-poll.events';
|
||||
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||
@@ -30,6 +34,32 @@ export interface CommentProps {
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
/** A quick poll's options (2–6); absent without one. */
|
||||
poll?: string[] | null;
|
||||
}
|
||||
|
||||
export const MIN_POLL_OPTIONS = 2;
|
||||
export const MAX_POLL_OPTIONS = 6;
|
||||
const MAX_POLL_OPTION = 80;
|
||||
|
||||
/** A poll's options, trimmed: 2 to 6, different, short. */
|
||||
export function validPoll(options: string[]): string[] {
|
||||
const clean = options
|
||||
.map((o) => o.replace(/\s+/g, ' ').trim())
|
||||
.filter(Boolean);
|
||||
assertThat(
|
||||
clean.length >= MIN_POLL_OPTIONS && clean.length <= MAX_POLL_OPTIONS,
|
||||
`A poll takes ${MIN_POLL_OPTIONS} to ${MAX_POLL_OPTIONS} options`,
|
||||
);
|
||||
assertThat(
|
||||
clean.every((o) => o.length <= MAX_POLL_OPTION),
|
||||
`A poll option takes ${MAX_POLL_OPTION} characters at most`,
|
||||
);
|
||||
assertThat(
|
||||
new Set(clean.map((o) => o.toLowerCase())).size === clean.length,
|
||||
'Poll options must be different',
|
||||
);
|
||||
return clean;
|
||||
}
|
||||
|
||||
const MAX_BODY = 20_000;
|
||||
@@ -47,6 +77,11 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public decision = false;
|
||||
/** A top-level comment whose conversation was settled. */
|
||||
public resolved = false;
|
||||
/** A quick poll: its options, who voted what, and whether it closed. */
|
||||
public poll: string[] | null = null;
|
||||
public pollVotes: Record<string, number> = {};
|
||||
public pollClosed = false;
|
||||
public pollWinner: number | null = null;
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -139,6 +174,63 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes `option` in its poll (null takes the vote back); repeating it changes nothing. */
|
||||
votePoll(actorId: string, option: number | null): void {
|
||||
this.assertAlive();
|
||||
if (!this.poll) throw new BusinessRuleError('This comment has no poll');
|
||||
if (this.pollClosed) throw new BusinessRuleError('The poll is closed');
|
||||
assertThat(
|
||||
option === null ||
|
||||
(Number.isInteger(option) && option >= 0 && option < this.poll.length),
|
||||
'No such option',
|
||||
);
|
||||
if ((this.pollVotes[actorId] ?? null) === option) return;
|
||||
const votes = { ...this.pollVotes };
|
||||
if (option === null) delete votes[actorId];
|
||||
else votes[actorId] = option;
|
||||
this.apply(
|
||||
new CommentPollVotedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
votes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the poll: its author, or (`canModerate`) whoever manages the
|
||||
* project. The winner is the most voted option; none on a tie.
|
||||
*/
|
||||
closePoll(actorId: string, canModerate: boolean): number | null {
|
||||
this.assertAlive();
|
||||
if (!this.poll) throw new BusinessRuleError('This comment has no poll');
|
||||
if (this.pollClosed) throw new BusinessRuleError('The poll is closed');
|
||||
if (actorId !== this.authorId && !canModerate)
|
||||
throw new ForbiddenError(
|
||||
'Only whoever started the poll or a project admin can close it',
|
||||
);
|
||||
const counts = this.poll.map(
|
||||
(_, i) => Object.values(this.pollVotes).filter((v) => v === i).length,
|
||||
);
|
||||
const most = Math.max(...counts);
|
||||
const winner =
|
||||
most > 0 && counts.filter((c) => c === most).length === 1
|
||||
? counts.indexOf(most)
|
||||
: null;
|
||||
this.apply(
|
||||
new CommentPollClosedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
winner,
|
||||
),
|
||||
);
|
||||
return winner;
|
||||
}
|
||||
|
||||
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
||||
delete(actorId: string, canModerate: boolean): void {
|
||||
this.assertAlive();
|
||||
@@ -156,6 +248,16 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
|
||||
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
|
||||
Object.assign(this, comment);
|
||||
this.poll = comment.poll ?? null;
|
||||
}
|
||||
|
||||
onCommentPollVotedEvent(event: CommentPollVotedEvent): void {
|
||||
this.pollVotes = event.votes;
|
||||
}
|
||||
|
||||
onCommentPollClosedEvent(event: CommentPollClosedEvent): void {
|
||||
this.pollClosed = true;
|
||||
this.pollWinner = event.winner;
|
||||
}
|
||||
|
||||
onCommentEditedEvent(event: CommentEditedEvent): void {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Someone voted in a comment's poll, changed or took back their vote. */
|
||||
@AutowiredEvent
|
||||
export class CommentPollVotedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** Every vote after the change: user id → option index. */
|
||||
readonly votes: Record<string, number>,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** A comment's poll was closed; `winner` is null on a tie or no votes. */
|
||||
@AutowiredEvent
|
||||
export class CommentPollClosedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
readonly winner: number | null,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-poll.events';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { newId } from '@task-stream/api/shared/domain';
|
||||
import { Comment, CommentProps, validCommentBody } from '../comment/comment';
|
||||
import {
|
||||
Comment,
|
||||
CommentProps,
|
||||
validCommentBody,
|
||||
validPoll,
|
||||
} from '../comment/comment';
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +17,8 @@ export class CommentFactory {
|
||||
body: string;
|
||||
/** Top-level comment it answers. */
|
||||
replyTo?: string | null;
|
||||
/** A quick poll's options. */
|
||||
poll?: string[] | null;
|
||||
}): Comment {
|
||||
const data: CommentProps = {
|
||||
id: newId(),
|
||||
@@ -23,6 +30,7 @@ export class CommentFactory {
|
||||
deleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
editedAt: null,
|
||||
...(props.poll?.length ? { poll: validPoll(props.poll) } : {}),
|
||||
};
|
||||
const comment = new Comment(data.id);
|
||||
Object.assign(comment, { ...data });
|
||||
|
||||
@@ -6,6 +6,14 @@ export class CommentReactionReadModel {
|
||||
users!: UserRefReadModel[];
|
||||
}
|
||||
|
||||
/** A quick poll: each option with who voted it. */
|
||||
export class CommentPollReadModel {
|
||||
options!: { text: string; voters: UserRefReadModel[] }[];
|
||||
closed!: boolean;
|
||||
/** Index of the winning option once closed (null on a tie). */
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
export class CommentReadModel {
|
||||
id!: string;
|
||||
issueId!: string;
|
||||
@@ -26,6 +34,8 @@ export class CommentReadModel {
|
||||
/** Who marked it and when (null when it is not a decision). */
|
||||
decidedBy!: UserRefReadModel | null;
|
||||
decidedAt!: string | null;
|
||||
/** A quick poll in the comment, if any. */
|
||||
poll!: CommentPollReadModel | null;
|
||||
/** A top-level comment whose conversation was settled. */
|
||||
resolved!: boolean;
|
||||
/** Who settled it and when (null while open). */
|
||||
|
||||
@@ -54,6 +54,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
decision: doc.decision ?? false,
|
||||
decidedBy: doc.decidedBy ?? null,
|
||||
decidedAt: doc.decidedAt ?? null,
|
||||
poll: doc.poll ?? null,
|
||||
resolved: doc.resolved ?? false,
|
||||
resolvedBy: doc.resolvedBy ?? null,
|
||||
resolvedAt: doc.resolvedAt ?? null,
|
||||
|
||||
@@ -22,8 +22,44 @@ export class CommentResolver {
|
||||
@Args('body') body: string,
|
||||
@Args('replyTo', { type: () => ID, nullable: true })
|
||||
replyTo: string | null,
|
||||
@Args('poll', {
|
||||
type: () => [String],
|
||||
nullable: true,
|
||||
description: 'A quick poll: 2 to 6 options',
|
||||
})
|
||||
poll: string[] | null,
|
||||
) {
|
||||
return this.comments.add(me.id, issueId, body, replyTo ?? null);
|
||||
return this.comments.add(
|
||||
me.id,
|
||||
issueId,
|
||||
body,
|
||||
replyTo ?? null,
|
||||
poll ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
"Vote in a comment's poll (option index), or take the vote back (null). Members, viewers too",
|
||||
})
|
||||
votePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('option', { type: () => Int, nullable: true }) option: number | null,
|
||||
) {
|
||||
return this.comments.votePoll(me.id, commentId, option ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Close a poll (whoever started it, or an admin); with decide, the winner stays as a decision of the ticket',
|
||||
})
|
||||
closePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('decide', { defaultValue: true }) decide: boolean,
|
||||
) {
|
||||
return this.comments.closePoll(me.id, commentId, decide);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
|
||||
@@ -555,6 +555,23 @@ export class CommentReactionGql {
|
||||
users!: UserRefGql[];
|
||||
}
|
||||
|
||||
@ObjectType('PollOption')
|
||||
export class PollOptionGql {
|
||||
@Field() text!: string;
|
||||
@Field(() => [UserRefGql]) voters!: UserRefGql[];
|
||||
}
|
||||
|
||||
@ObjectType('CommentPoll', { description: 'A quick poll in a comment' })
|
||||
export class CommentPollGql {
|
||||
@Field(() => [PollOptionGql]) options!: PollOptionGql[];
|
||||
@Field() closed!: boolean;
|
||||
@Field(() => Int, {
|
||||
nullable: true,
|
||||
description: 'Index of the winning option once closed (null on a tie)',
|
||||
})
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('Comment')
|
||||
export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -585,6 +602,8 @@ export class CommentGql {
|
||||
})
|
||||
decidedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
||||
@Field(() => CommentPollGql, { nullable: true })
|
||||
poll!: CommentPollGql | null;
|
||||
@Field({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
|
||||
@@ -19,8 +19,10 @@ import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { REACTIONS } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
CommentBodyDto,
|
||||
ClosePollDto,
|
||||
ReactToCommentDto,
|
||||
ResolveThreadDto,
|
||||
VotePollDto,
|
||||
} from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
|
||||
@@ -82,6 +84,39 @@ export class CommentsController {
|
||||
return this.comments.markDecision(me.id, commentId, false);
|
||||
}
|
||||
|
||||
@Post(':commentId/poll/vote')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: "Vote in a comment's poll (members, viewers too)",
|
||||
description: 'option: its index; null takes the vote back.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
votePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: VotePollDto,
|
||||
) {
|
||||
return this.comments.votePoll(me.id, commentId, body.option);
|
||||
}
|
||||
|
||||
@Post(':commentId/poll/close')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary: 'Close a poll (whoever started it, or an admin)',
|
||||
description:
|
||||
'With decide (default), the winner stays as a decision of the ticket.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
closePoll(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: ClosePollDto,
|
||||
) {
|
||||
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
|
||||
}
|
||||
|
||||
@Put(':commentId/resolution')
|
||||
@ApiOperation({
|
||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||
|
||||
@@ -1316,6 +1316,40 @@ export class AddCommentDto extends CommentBodyDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
replyTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'A quick poll: 2 to 6 options',
|
||||
example: ['Stripe', 'Redsys'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(6)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(80, { each: true })
|
||||
poll?: string[];
|
||||
}
|
||||
|
||||
export class VotePollDto {
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Option index; null takes the vote back',
|
||||
})
|
||||
@ValidateIf((dto: VotePollDto) => dto.option !== null)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
option!: number | null;
|
||||
}
|
||||
|
||||
export class ClosePollDto {
|
||||
@ApiPropertyOptional({
|
||||
default: true,
|
||||
description: 'Keep the winner as a decision of the ticket',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
decide?: boolean;
|
||||
}
|
||||
|
||||
export class ReactToCommentDto {
|
||||
|
||||
@@ -612,6 +612,22 @@ export class CommentReactionDto {
|
||||
@ApiProperty({ type: [UserRefDto] }) users!: UserRefDto[];
|
||||
}
|
||||
|
||||
export class PollOptionDto {
|
||||
@ApiProperty() text!: string;
|
||||
@ApiProperty({ type: [UserRefDto] }) voters!: UserRefDto[];
|
||||
}
|
||||
|
||||
export class CommentPollDto {
|
||||
@ApiProperty({ type: [PollOptionDto] }) options!: PollOptionDto[];
|
||||
@ApiProperty() closed!: boolean;
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
nullable: true,
|
||||
description: 'Index of the winning option once closed (null on a tie)',
|
||||
})
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
export class CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@@ -648,6 +664,8 @@ export class CommentDto {
|
||||
decidedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
decidedAt!: string | null;
|
||||
@ApiProperty({ type: CommentPollDto, nullable: true })
|
||||
poll!: CommentPollDto | null;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
|
||||
@@ -990,6 +990,7 @@ export class IssuesController {
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.body,
|
||||
body.replyTo ?? null,
|
||||
body.poll ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user