feat: conversations on a ticket are settled, and you see who is typing
A top-level comment and its replies can be resolved by whoever can
change the ticket (CommentThreadResolvedEvent), with an optional
conclusion added as a reply marked as a decision, so it shows above the
ticket. A settled conversation folds to one line (topic, who settled it
and the conclusion) and can be unfolded or reopened; the header counts
the open ones, and finishing the ticket with one open asks first.
«Ana está escribiendo…» shows in the conversation being answered, at
once: a `typing` mutation publishes to a Redis channel per ticket
(`typing` subscription), never stored. In replies, Enter sends and
Shift+Enter breaks the line, except inside lists and code.
API: resolveThread / PUT-DELETE /api/comments/{id}/resolution, Comment
.resolved/resolvedBy/resolvedAt, typing mutation and subscription. MCP:
resolve_thread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,24 @@ export class CommentResolver {
|
||||
return this.comments.markDecision(me.id, commentId, on);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket',
|
||||
})
|
||||
resolveThread(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('resolved', { defaultValue: true }) resolved: boolean,
|
||||
@Args('conclusion', { nullable: true }) conclusion: string | null,
|
||||
) {
|
||||
return this.comments.resolveThread(
|
||||
me.id,
|
||||
commentId,
|
||||
resolved,
|
||||
conclusion ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [DecisionGql], {
|
||||
description:
|
||||
|
||||
@@ -77,6 +77,28 @@ export class IssueResolver {
|
||||
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description:
|
||||
'I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`',
|
||||
})
|
||||
async typing(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('threadId', {
|
||||
type: () => ID,
|
||||
nullable: true,
|
||||
description: 'The conversation being answered; null: a new comment',
|
||||
})
|
||||
threadId: string | null,
|
||||
) {
|
||||
await this.presence.typing(
|
||||
{ id: me.id, name: me.name },
|
||||
issueId,
|
||||
threadId ?? null,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: "I left this ticket's page" })
|
||||
async leavePresence(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ProjectChangeGql,
|
||||
ReleaseChangeGql,
|
||||
SprintChangeGql,
|
||||
TypingGql,
|
||||
} from './types';
|
||||
|
||||
const asIs = { resolve: <T>(payload: T) => payload };
|
||||
@@ -48,6 +49,18 @@ export class SubscriptionResolver {
|
||||
return this.changes.watchComments(me.id, issueId);
|
||||
}
|
||||
|
||||
@Subscription(() => TypingGql, {
|
||||
...asIs,
|
||||
description:
|
||||
"Who is writing on a ticket, as they type (one's own come too: leave them out)",
|
||||
})
|
||||
typing(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
) {
|
||||
return this.changes.watchTyping(me.id, issueId);
|
||||
}
|
||||
|
||||
@Subscription(() => ActivityGql, {
|
||||
...asIs,
|
||||
description: 'Live activity feed',
|
||||
|
||||
@@ -562,6 +562,17 @@ export class CommentGql {
|
||||
})
|
||||
decidedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) decidedAt!: string | null;
|
||||
@Field({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
})
|
||||
resolved!: boolean;
|
||||
@Field(() => UserRefGql, {
|
||||
nullable: true,
|
||||
description: 'Who settled it',
|
||||
})
|
||||
resolvedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||
@Field(() => Int) version!: number;
|
||||
@@ -1157,6 +1168,20 @@ export class ProjectInsightsGql {
|
||||
proposals!: ProposalsFunnelGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('Typing', {
|
||||
description: 'Someone writing on a ticket right now (not stored)',
|
||||
})
|
||||
export class TypingGql {
|
||||
@Field(() => ID) issueId!: string;
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'The conversation being answered; null: a new comment',
|
||||
})
|
||||
threadId!: string | null;
|
||||
@Field() at!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Presence', {
|
||||
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
|
||||
})
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
import { ApiCommandErrors } from '@task-stream/api/shared/presenters';
|
||||
import { CommentsService } from '@task-stream/api/tracker/application';
|
||||
import { REACTIONS } from '@task-stream/api/tracker/domain';
|
||||
import { CommentBodyDto, ReactToCommentDto } from './dto/requests.dto';
|
||||
import {
|
||||
CommentBodyDto,
|
||||
ReactToCommentDto,
|
||||
ResolveThreadDto,
|
||||
} from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
|
||||
/** REST adapter of the comment use cases (listing and adding live under /issues). */
|
||||
@@ -78,6 +82,35 @@ export class CommentsController {
|
||||
return this.comments.markDecision(me.id, commentId, false);
|
||||
}
|
||||
|
||||
@Put(':commentId/resolution')
|
||||
@ApiOperation({
|
||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||
description:
|
||||
'Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
resolveThread(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: ResolveThreadDto,
|
||||
) {
|
||||
return this.comments.resolveThread(
|
||||
me.id,
|
||||
commentId,
|
||||
true,
|
||||
body.conclusion ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':commentId/resolution')
|
||||
@ApiOperation({ summary: 'Reopen a conversation' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
reopenThread(@CurrentUser() me: User, @Param('commentId') commentId: string) {
|
||||
return this.comments.resolveThread(me.id, commentId, false);
|
||||
}
|
||||
|
||||
@Delete(':commentId')
|
||||
@ApiOperation({ summary: 'Delete a comment (author or project admin)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
|
||||
@@ -1552,6 +1552,18 @@ export class PushEndpointDto {
|
||||
endpoint!: string;
|
||||
}
|
||||
|
||||
export class ResolveThreadDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'What was settled: added as a reply marked as a decision before it closes',
|
||||
maxLength: 20000,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20000)
|
||||
conclusion?: string;
|
||||
}
|
||||
|
||||
// ---- Alerts → bugs ----------------------------------------------------------
|
||||
|
||||
export class AlertSettingsDto {
|
||||
|
||||
@@ -642,6 +642,19 @@ export class CommentDto {
|
||||
decidedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
decidedAt!: string | null;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'A conversation (top-level comment and its replies) that was settled',
|
||||
})
|
||||
resolved!: boolean;
|
||||
@ApiProperty({
|
||||
type: UserRefDto,
|
||||
nullable: true,
|
||||
description: 'Who settled it',
|
||||
})
|
||||
resolvedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
resolvedAt!: string | null;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
editedAt!: string | null;
|
||||
|
||||
Reference in New Issue
Block a user