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:
2026-10-19 22:35:46 +02:00
parent c0b0417b6d
commit 199b60e4ce
51 changed files with 1965 additions and 126 deletions

View File

@@ -9,6 +9,7 @@ import {
ProjectChange,
ReleaseChange,
SprintChange,
Typing,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
@@ -53,6 +54,15 @@ export class ChangesService {
return this.changeFeed.comments(issueId);
}
/** Who is writing on the ticket (the client leaves itself out). */
async watchTyping(
viewerId: string,
issueId: string,
): Promise<AsyncIterableIterator<Typing>> {
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
return this.changeFeed.typing(issueId);
}
async watchActivity(
viewerId: string,
projectId: string,

View File

@@ -1,5 +1,6 @@
import { ReactToCommentCommandHandler } from './react-to-comment.command-handler';
import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
@@ -48,6 +49,7 @@ export const COMMAND_HANDLERS = [
VoteIssueCommandHandler,
ReactToCommentCommandHandler,
MarkDecisionCommandHandler,
ResolveThreadCommandHandler,
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
UpdateProjectSettingsCommandHandler,

View File

@@ -78,6 +78,7 @@ import { AttachmentStorage } from '../ports/attachment-storage';
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 { UpdateProjectCommand } from './update-project.command';
import { VoteIssueCommand } from './vote-issue.command';
import { AddProjectMemberCommand } from './add-project-member.command';
@@ -943,6 +944,51 @@ describe('tracker commands (event sourced)', () => {
);
});
it('settles a conversation, keeping its conclusion 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', {}),
);
const thread = await run(
new AddCommentCommand('qa', issue.id, '¿Stripe o Redsys?'),
);
const reply = await run(
new AddCommentCommand('dev', issue.id, 'Stripe', thread.id),
);
// A reply is not a conversation; a viewer does not settle one.
await expect(
run(new ResolveThreadCommand('dev', reply.id, true)),
).rejects.toThrow(BusinessRuleError);
await expect(
run(new ResolveThreadCommand('pm', thread.id, true)),
).rejects.toThrow(ForbiddenError);
await run(
new ResolveThreadCommand('dev', thread.id, true, ' Vamos con Stripe '),
);
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
true,
);
const conclusion = store.events.find(
(e) =>
e.type === 'CommentAddedEvent' &&
(e.data as { comment: { body: string } }).comment.body ===
'Vamos con Stripe',
);
expect(conclusion).toBeDefined();
const added = await rehydrator.rehydrate(
conclusion?.streamId ?? '',
Comment,
);
expect(added).toMatchObject({ replyTo: thread.id, decision: true });
await run(new ResolveThreadCommand('owner', thread.id, false));
expect((await rehydrator.rehydrate(thread.id, Comment)).resolved).toBe(
false,
);
});
it('takes votes from members, and from anyone once it is public', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -0,0 +1,44 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { CommentFactory } from '@task-stream/api/tracker/domain';
import { CommandResult, ack } from './command-result';
import { ResolveThreadCommand } from './resolve-thread.command';
import { WriteAccess } from './write-access';
/** The team settles its conversations: whoever can change the ticket. */
@CommandHandler(ResolveThreadCommand)
export class ResolveThreadCommandHandler
implements ICommandHandler<ResolveThreadCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commentFactory: CommentFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: ResolveThreadCommand): Promise<CommandResult> {
const thread = await this.access.comment(command.commentId);
if (thread.replyTo)
throw new BusinessRuleError(
'Only a conversation is resolved, not a reply',
);
await this.access.issueForContributor(thread.issueId, command.actorId);
const conclusion = command.conclusion?.trim();
if (command.resolved && conclusion && !thread.resolved) {
// What was settled stays as a decision, shown above the ticket.
const reply = this.commentFactory.create({
issueId: thread.issueId,
projectId: thread.projectId,
authorId: command.actorId,
body: conclusion,
replyTo: thread.id,
});
reply.markDecision(command.actorId, true);
this.eventPublisher.mergeObjectContext(reply);
await reply.commitAndWait();
}
thread.resolveThread(command.actorId, command.resolved);
await thread.commitAndWait();
return ack(thread);
}
}

View File

@@ -0,0 +1,17 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/**
* Settles a conversation (`resolved`) or reopens it. A conclusion, if
* given, is added as a reply marked as a decision before it closes.
*/
export class ResolveThreadCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly resolved: boolean,
public readonly conclusion: string | null = null,
) {
super();
}
}

View File

@@ -11,6 +11,7 @@ import { DeleteCommentCommand } from './commands/delete-comment.command';
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 { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query';
import {
@@ -69,6 +70,21 @@ export class CommentsService {
);
}
/**
* Settles a conversation or reopens it; a conclusion stays as a reply
* marked as a decision.
*/
resolveThread(
actorId: string,
commentId: string,
resolved: boolean,
conclusion: string | null = null,
): Promise<CommandResult> {
return this.commands.execute(
new ResolveThreadCommand(actorId, commentId, resolved, conclusion),
);
}
/** A ticket's decisions, oldest first. */
decisions(
viewerId: string | null,

View File

@@ -33,6 +33,9 @@ export class CommentAddedEventHandler
decision: false,
decidedBy: null,
decidedAt: null,
resolved: false,
resolvedBy: null,
resolvedAt: null,
createdAt: comment.createdAt,
editedAt: null,
version: versionOf(event),

View File

@@ -0,0 +1,28 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentThreadResolvedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentThreadResolvedEvent)
export class CommentThreadResolvedEventHandler
implements IEventHandler<CommentThreadResolvedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentThreadResolvedEvent) {
const refs = await this.users.refs([event.actorId]);
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
resolved: event.resolved,
resolvedBy: event.resolved ? refs.of(event.actorId) : null,
resolvedAt: event.resolved ? event.occurredAt : null,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
}
}

View File

@@ -4,6 +4,7 @@ import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
import { IssueVotedEventHandler } from './issue-voted.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler';
@@ -52,6 +53,7 @@ export const EVENT_HANDLERS = [
CommentEditedEventHandler,
CommentReactedEventHandler,
CommentDecisionMarkedEventHandler,
CommentThreadResolvedEventHandler,
IssueVotedEventHandler,
IssueAssignedEventHandler,
IssueCreatedEventHandler,

View File

@@ -46,6 +46,15 @@ export interface ProjectChange extends Change {
* Live stream of read-model changes: event handlers publish once the read
* model is up to date, GraphQL subscriptions watch.
*/
/** Someone is writing on a ticket: never stored, gone in a few seconds. */
export interface Typing {
issueId: string;
user: { id: string; name: string };
/** The conversation being answered; null: a new comment. */
threadId: string | null;
at: string;
}
export abstract class ChangeFeed {
abstract publishIssue(change: IssueChange): Promise<void>;
abstract publishComment(change: CommentChange): Promise<void>;
@@ -64,6 +73,8 @@ export abstract class ChangeFeed {
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
abstract publishTyping(typing: Typing): Promise<void>;
abstract typing(issueId: string): AsyncIterableIterator<Typing>;
abstract activity(
projectId: string,
): AsyncIterableIterator<ActivityReadModel>;

View File

@@ -1,5 +1,6 @@
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { ChangeFeed, Typing } from './ports/change-feed';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
@@ -40,10 +41,23 @@ const access = {
},
} as unknown as ReadAccess;
/** What would go out to the ticket's open pages. */
const feed = () => {
const published: Typing[] = [];
return {
published,
feed: {
publishTyping: async (typing: Typing) => {
published.push(typing);
},
} as unknown as ChangeFeed,
};
};
describe('PresenceService', () => {
it('tells who else is on a ticket, the ones editing first', async () => {
const store = new MemoryPresence();
const presence = new PresenceService(store, issues, access);
const presence = new PresenceService(store, issues, access, feed().feed);
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
@@ -62,8 +76,33 @@ describe('PresenceService', () => {
).toEqual([]);
});
it('tells at once who is writing, and where, to whoever can read it', async () => {
const { published, feed: changeFeed } = feed();
const presence = new PresenceService(
new MemoryPresence(),
issues,
access,
changeFeed,
);
await presence.typing({ id: 'ana', name: 'Ana' }, 'i1', 'c1');
await presence.typing({ id: 'bob', name: 'Bob' }, 'i1', null);
expect(published.map((t) => [t.issueId, t.user.name, t.threadId])).toEqual([
['i1', 'Ana', 'c1'],
['i1', 'Bob', null],
]);
await expect(
presence.typing({ id: 'eve', name: 'Eve' }, 'i1', null),
).rejects.toBeInstanceOf(ForbiddenError);
expect(published).toHaveLength(2);
});
it('only for whoever can read the ticket', async () => {
const presence = new PresenceService(new MemoryPresence(), issues, access);
const presence = new PresenceService(
new MemoryPresence(),
issues,
access,
feed().feed,
);
await expect(
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
).rejects.toBeInstanceOf(ForbiddenError);

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ChangeFeed } from './ports/change-feed';
import { FindIssuesRepository } from './ports/find-issues.repository';
import { PresenceEntry, PresenceStore } from './ports/presence.store';
import { ReadAccess } from './queries/read-access';
@@ -17,6 +18,7 @@ export class PresenceService {
private readonly store: PresenceStore,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
private readonly changeFeed: ChangeFeed,
) {}
/**
@@ -54,6 +56,24 @@ export class PresenceService {
);
}
/**
* I am writing on the ticket (an answer to `threadId`, or a new comment):
* told at once to whoever has it open, and forgotten.
*/
async typing(
viewer: { id: string; name: string },
issueId: string,
threadId: string | null,
): Promise<void> {
await this.assertCanView(viewer.id, issueId);
await this.changeFeed.publishTyping({
issueId,
user: { id: viewer.id, name: viewer.name },
threadId,
at: new Date().toISOString(),
});
}
/** I left the ticket's page. */
leave(viewerId: string, issueId: string): Promise<void> {
return this.store.leave(issueId, viewerId);