Files
task-stream/libs/api/tracker/application/src/lib/changes.service.ts
Josep Piguillem 199b60e4ce 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>
2026-10-19 22:35:46 +02:00

114 lines
3.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import {
ChangeFeed,
CommentChange,
IssueChange,
ProjectChange,
ReleaseChange,
SprintChange,
Typing,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetTicketScopeQuery } from './queries/get-ticket-scope.query';
import { involves } from './queries/read-access';
/** The changes of the tickets `userId` opened, has or follows. */
async function* onlyInvolving(
changes: AsyncIterableIterator<IssueChange>,
userId: string,
): AsyncIterableIterator<IssueChange> {
for await (const change of changes)
if (involves(change.issue, userId)) yield change;
}
/** Realtime subscriptions, authorised at subscribe time. */
@Injectable()
export class ChangesService {
constructor(
private readonly queryBus: QueryBus,
private readonly changeFeed: ChangeFeed,
) {}
async watchIssues(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<IssueChange>> {
await this.assertCanView(viewerId, projectId);
const changes = this.changeFeed.issues(projectId);
const scope: string | null = await this.queryBus.execute(
new GetTicketScopeQuery(viewerId, projectId),
);
// A guest hears only about the tickets they take part in.
return scope ? onlyInvolving(changes, scope) : changes;
}
async watchComments(
viewerId: string,
issueId: string,
): Promise<AsyncIterableIterator<CommentChange>> {
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
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,
): Promise<AsyncIterableIterator<ActivityReadModel>> {
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.activity(projectId);
}
async watchReleases(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ReleaseChange>> {
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.releases(projectId);
}
async watchSprints(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId, true);
return this.changeFeed.sprints(projectId);
}
async watchProject(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ProjectChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.project(projectId);
}
/** Projects the viewer is added to or removed from. */
watchMyProjects(viewerId: string): AsyncIterableIterator<ProjectChange> {
return this.changeFeed.userProjects(viewerId);
}
private async assertCanView(
viewerId: string,
projectId: string,
wholeProject = false,
): Promise<void> {
const allowed = await this.queryBus.execute(
new CanViewProjectQuery(viewerId, projectId, wholeProject),
);
if (!allowed) throw new NotFoundError('Project', projectId);
}
}