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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
|
||||
import { CommentDeletedEvent } from '../events/comment-deleted.event';
|
||||
import { CommentEditedEvent } from '../events/comment-edited.event';
|
||||
import { CommentReactedEvent } from '../events/comment-reacted.event';
|
||||
@@ -44,6 +45,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public reactions: Record<string, string[]> = {};
|
||||
/** What was decided on the ticket: shown above it and searchable. */
|
||||
public decision = false;
|
||||
/** A top-level comment whose conversation was settled. */
|
||||
public resolved = false;
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -114,6 +117,28 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles the conversation this comment opens (`on`) or reopens it;
|
||||
* repeating it changes nothing. Only a top-level comment opens one.
|
||||
*/
|
||||
resolveThread(actorId: string, on: boolean): void {
|
||||
this.assertAlive();
|
||||
if (this.replyTo)
|
||||
throw new BusinessRuleError(
|
||||
'Only a conversation is resolved, not a reply',
|
||||
);
|
||||
if (on === this.resolved) return;
|
||||
this.apply(
|
||||
new CommentThreadResolvedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
on,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param canModerate whether the actor manages the project (may delete others' comments). */
|
||||
delete(actorId: string, canModerate: boolean): void {
|
||||
this.assertAlive();
|
||||
@@ -146,6 +171,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
this.decision = event.decision;
|
||||
}
|
||||
|
||||
onCommentThreadResolvedEvent(event: CommentThreadResolvedEvent): void {
|
||||
this.resolved = event.resolved;
|
||||
}
|
||||
|
||||
onCommentDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A conversation (a top-level comment and its replies) was settled, or reopened. */
|
||||
@AutowiredEvent
|
||||
export class CommentThreadResolvedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** False: reopened. */
|
||||
readonly resolved: boolean,
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export * from './comment-added.event';
|
||||
export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './comment-deleted.event';
|
||||
export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
|
||||
@@ -26,6 +26,11 @@ export class CommentReadModel {
|
||||
/** Who marked it and when (null when it is not a decision). */
|
||||
decidedBy!: UserRefReadModel | null;
|
||||
decidedAt!: string | null;
|
||||
/** A top-level comment whose conversation was settled. */
|
||||
resolved!: boolean;
|
||||
/** Who settled it and when (null while open). */
|
||||
resolvedBy!: UserRefReadModel | null;
|
||||
resolvedAt!: string | null;
|
||||
createdAt!: string;
|
||||
editedAt!: string | null;
|
||||
version!: number;
|
||||
|
||||
@@ -54,6 +54,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
decision: doc.decision ?? false,
|
||||
decidedBy: doc.decidedBy ?? null,
|
||||
decidedAt: doc.decidedAt ?? null,
|
||||
resolved: doc.resolved ?? false,
|
||||
resolvedBy: doc.resolvedBy ?? null,
|
||||
resolvedAt: doc.resolvedAt ?? null,
|
||||
});
|
||||
|
||||
const escapeRegex = (text: string) =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
SprintChange,
|
||||
Typing,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
@@ -18,6 +19,7 @@ import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||
const Channels = {
|
||||
issue: (projectId: string) => `tracker.issue.${projectId}`,
|
||||
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
||||
typing: (issueId: string) => `tracker.typing.${issueId}`,
|
||||
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
||||
release: (projectId: string) => `tracker.release.${projectId}`,
|
||||
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
|
||||
@@ -86,6 +88,14 @@ export class RedisChangeFeed extends ChangeFeed {
|
||||
return this.pubSub.asyncIterableIterator(Channels.comment(issueId));
|
||||
}
|
||||
|
||||
publishTyping(typing: Typing): Promise<void> {
|
||||
return this.pubSub.publish(Channels.typing(typing.issueId), typing);
|
||||
}
|
||||
|
||||
typing(issueId: string): AsyncIterableIterator<Typing> {
|
||||
return this.pubSub.asyncIterableIterator(Channels.typing(issueId));
|
||||
}
|
||||
|
||||
activity(projectId: string): AsyncIterableIterator<ActivityReadModel> {
|
||||
return this.pubSub.asyncIterableIterator(Channels.activity(projectId));
|
||||
}
|
||||
|
||||
@@ -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