feat: what is new since my last visit, and turning a comment into a ticket
Opening a ticket notes the visit and answers the one before (issue_visits), so the page marks others' comments and changes since. A comment can become a ticket of its own, as a subtask or linked; it remembers where it went. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
|
||||
export * from './lib/follows/tree-follows';
|
||||
export * from './lib/ports/tree-follows.repository';
|
||||
export * from './lib/tree-follows.service';
|
||||
export * from './lib/visits/issue-visits';
|
||||
export * from './lib/ports/issue-visits.repository';
|
||||
export * from './lib/issue-visits.service';
|
||||
export * from './lib/reminders/issue-reminders';
|
||||
export * from './lib/ports/issue-reminders.repository';
|
||||
export * from './lib/issue-reminders.service';
|
||||
@@ -51,6 +54,7 @@ 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/commands/turn-comment-into-issue';
|
||||
export * from './lib/sharing/release-notes-shares';
|
||||
export * from './lib/ports/release-note-shares.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { TURN_INTO_ISSUE_HANDLERS } from './turn-comment-into-issue';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -54,6 +55,7 @@ export const COMMAND_HANDLERS = [
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
...POLL_HANDLERS,
|
||||
...TURN_INTO_ISSUE_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
IssueLinkType,
|
||||
IssueStatus,
|
||||
Project,
|
||||
IssuePriority,
|
||||
@@ -81,6 +82,7 @@ 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 { TurnCommentIntoIssueCommand } from './turn-comment-into-issue';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
@@ -951,6 +953,65 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('makes a ticket of a comment, as a subtask or linked, and marks the comment', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Pago', {}),
|
||||
);
|
||||
const comment = await run(
|
||||
new AddCommentCommand('qa', issue.id, 'También falla en **Android**'),
|
||||
);
|
||||
// Viewers comment and react; making tickets is for the team.
|
||||
await expect(
|
||||
run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'pm',
|
||||
comment.id,
|
||||
'Android',
|
||||
IssueType.BUG,
|
||||
false,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
const linked = await run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'dev',
|
||||
comment.id,
|
||||
'Falla en Android',
|
||||
IssueType.BUG,
|
||||
false,
|
||||
),
|
||||
);
|
||||
const sub = await run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'dev',
|
||||
comment.id,
|
||||
'Probar en Android',
|
||||
IssueType.TASK,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
const fromLink = await rehydrator.rehydrate(linked.id, Issue);
|
||||
expect(fromLink.title).toBe('Falla en Android');
|
||||
expect(fromLink.description).toBe(
|
||||
'También falla en **Android**\n\n---\n_Sale de un comentario de user qa en TS-1._',
|
||||
);
|
||||
expect(fromLink.links).toEqual([
|
||||
expect.objectContaining({
|
||||
type: IssueLinkType.RELATES_TO,
|
||||
targetIssueId: issue.id,
|
||||
}),
|
||||
]);
|
||||
expect((await rehydrator.rehydrate(sub.id, Issue)).parentId).toBe(issue.id);
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).spawned).toEqual([
|
||||
{ id: linked.id, key: 'TS-2' },
|
||||
{ id: sub.id, key: 'TS-3' },
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { CommandResult } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { LinkIssuesCommand } from './link-issues.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* «Convertir en ticket»: a comment that turns out to be work of its own
|
||||
* («también falla en Android») becomes a ticket, as a subtask of the one it
|
||||
* was said on or linked to it. Its text is the new description, and the
|
||||
* comment shows where it went («→ WEB-45»).
|
||||
*/
|
||||
|
||||
export class TurnCommentIntoIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly title: string,
|
||||
/** Ticket type id of the project's settings. */
|
||||
public readonly type: string,
|
||||
/** Hang it from the ticket of the comment; otherwise, link them. */
|
||||
public readonly asSubtask: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers the new ticket (id, key, version). */
|
||||
@CommandHandler(TurnCommentIntoIssueCommand)
|
||||
export class TurnCommentIntoIssueCommandHandler
|
||||
implements ICommandHandler<TurnCommentIntoIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(command: TurnCommentIntoIssueCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const source = await this.access.issue(comment.issueId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
this.access.assertSees(project, source, command.actorId);
|
||||
// Creating and linking tickets is for the team: those commands check it.
|
||||
project.assertCanContribute(command.actorId);
|
||||
if (comment.deleted) throw new BusinessRuleError('Comment was deleted');
|
||||
|
||||
const author = (await this.users.refs([comment.authorId])).of(
|
||||
comment.authorId,
|
||||
);
|
||||
const created = await this.commandBus.execute(
|
||||
new CreateIssueCommand(
|
||||
command.actorId,
|
||||
project.id,
|
||||
command.type,
|
||||
command.title,
|
||||
{
|
||||
description: `${comment.body}\n\n---\n_Sale de un comentario de ${author.name} en ${source.key}._`,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
command.asSubtask ? source.id : null,
|
||||
null,
|
||||
// The comment is the content: no template on top of it.
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (!command.asSubtask)
|
||||
await this.commandBus.execute(
|
||||
new LinkIssuesCommand(
|
||||
command.actorId,
|
||||
created.id,
|
||||
IssueLinkType.RELATES_TO,
|
||||
source.id,
|
||||
),
|
||||
);
|
||||
comment.turnedInto(command.actorId, {
|
||||
id: created.id,
|
||||
key: created.key ?? '',
|
||||
});
|
||||
await comment.commitAndWait();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
export const TURN_INTO_ISSUE_HANDLERS = [TurnCommentIntoIssueCommandHandler];
|
||||
@@ -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 { TurnCommentIntoIssueCommand } from './commands/turn-comment-into-issue';
|
||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
@@ -109,6 +110,25 @@ export class CommentsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Makes a ticket of a comment: a subtask of its ticket, or linked to it. */
|
||||
turnIntoIssue(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
actorId,
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A ticket's decisions, oldest first. */
|
||||
decisions(
|
||||
viewerId: string | null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export class CommentAddedEventHandler
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
spawned: [],
|
||||
createdAt: comment.createdAt,
|
||||
editedAt: null,
|
||||
version: versionOf(event),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentTurnedIntoIssueEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentTurnedIntoIssueEvent)
|
||||
export class CommentTurnedIntoIssueEventHandler
|
||||
implements IEventHandler<CommentTurnedIntoIssueEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentTurnedIntoIssueEvent) {
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
spawned: event.spawned,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { CommentTurnedIntoIssueEventHandler } from './comment-turned-into-issue.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
CommentTurnedIntoIssueEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
CommentPollEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
|
||||
14
libs/api/tracker/application/src/lib/issue-visits.service.ts
Normal file
14
libs/api/tracker/application/src/lib/issue-visits.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { VisitIssueCommand } from './visits/issue-visits';
|
||||
|
||||
/** When I last opened a ticket, to tell what is new since. */
|
||||
@Injectable()
|
||||
export class IssueVisitsService {
|
||||
constructor(private readonly commands: CommandRunner) {}
|
||||
|
||||
/** Notes that I opened it now; returns my visit before (null: never). */
|
||||
visit(userId: string, issueId: string): Promise<string | null> {
|
||||
return this.commands.execute(new VisitIssueCommand(userId, issueId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** When someone last opened a ticket: personal, no history. */
|
||||
export interface IssueVisit {
|
||||
userId: string;
|
||||
issueId: string;
|
||||
seenAt: string;
|
||||
}
|
||||
|
||||
export abstract class IssueVisitsRepository {
|
||||
/** Notes this visit; returns when the one before it was (null: never). */
|
||||
abstract visit(visit: IssueVisit): Promise<string | null>;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||
import { ISSUE_VISITS } from './visits/issue-visits';
|
||||
import { IssueVisitsService } from './issue-visits.service';
|
||||
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
|
||||
import { MY_DAY } from './my-day/my-day';
|
||||
import { RELEASE_NOTES_SHARES } from './sharing/release-notes-shares';
|
||||
@@ -92,6 +94,7 @@ const SERVICES = [
|
||||
PinsService,
|
||||
AlertsService,
|
||||
TreeFollowsService,
|
||||
IssueVisitsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
SprintsService,
|
||||
@@ -127,6 +130,7 @@ const SERVICES = [
|
||||
...PINS,
|
||||
...ALERT_HANDLERS,
|
||||
...TREE_FOLLOWS,
|
||||
...ISSUE_VISITS,
|
||||
...ISSUE_REMINDERS,
|
||||
...MY_DAY,
|
||||
...RELEASE_NOTES_SHARES,
|
||||
|
||||
51
libs/api/tracker/application/src/lib/visits/issue-visits.ts
Normal file
51
libs/api/tracker/application/src/lib/visits/issue-visits.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
QueryBus,
|
||||
} from '@nestjs/cqrs';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { IssueVisitsRepository } from '../ports/issue-visits.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
|
||||
/*
|
||||
* «Lo nuevo desde tu última visita»: opening a ticket notes the moment, and
|
||||
* says when the visit before was, so the client can tell what others did
|
||||
* meanwhile. Personal, no history (like pinned tickets).
|
||||
*/
|
||||
|
||||
export class VisitIssueCommand extends Command<string | null> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(VisitIssueCommand)
|
||||
export class VisitIssueCommandHandler
|
||||
implements ICommandHandler<VisitIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly visits: IssueVisitsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(command: VisitIssueCommand): Promise<string | null> {
|
||||
const { userId, issueId } = command;
|
||||
const [issue]: IssueReadModel[] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(userId, [issueId]),
|
||||
);
|
||||
// A ticket I cannot read does not exist, as far as I know.
|
||||
if (!issue) throw new NotFoundError('Issue', issueId);
|
||||
return this.visits.visit({
|
||||
userId,
|
||||
issueId,
|
||||
seenAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ISSUE_VISITS = [VisitIssueCommandHandler];
|
||||
@@ -7,6 +7,7 @@ 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 { CommentTurnedIntoIssueEvent } from '../events/comment-turned-into-issue.event';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
@@ -82,6 +83,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public pollVotes: Record<string, number> = {};
|
||||
public pollClosed = false;
|
||||
public pollWinner: number | null = null;
|
||||
/** Tickets made from it, oldest first. */
|
||||
public spawned: { id: string; key: string }[] = [];
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -277,10 +280,32 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
this.resolved = event.resolved;
|
||||
}
|
||||
|
||||
onCommentTurnedIntoIssueEvent(event: CommentTurnedIntoIssueEvent): void {
|
||||
this.spawned = event.spawned;
|
||||
}
|
||||
|
||||
onCommentDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes a ticket made from it (its text became the ticket's description).
|
||||
* Who may create it is the caller's check.
|
||||
*/
|
||||
turnedInto(actorId: string, issue: { id: string; key: string }): void {
|
||||
this.assertAlive();
|
||||
if (this.spawned.some((s) => s.id === issue.id)) return;
|
||||
this.apply(
|
||||
new CommentTurnedIntoIssueEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
[...this.spawned, issue],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private assertAlive(): void {
|
||||
if (this.deleted) throw new BusinessRuleError('Comment was deleted');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket of its own was made from a comment («→ WEB-45» on it). */
|
||||
@AutowiredEvent
|
||||
export class CommentTurnedIntoIssueEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** Every ticket made from it so far, this one last. */
|
||||
readonly spawned: { id: string; key: string }[],
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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-turned-into-issue.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-poll.events';
|
||||
export * from './comment-deleted.event';
|
||||
|
||||
@@ -41,6 +41,8 @@ export class CommentReadModel {
|
||||
/** Who settled it and when (null while open). */
|
||||
resolvedBy!: UserRefReadModel | null;
|
||||
resolvedAt!: string | null;
|
||||
/** Tickets made from it («Convertir en ticket»), oldest first. */
|
||||
spawned!: { id: string; key: string }[];
|
||||
createdAt!: string;
|
||||
editedAt!: string | null;
|
||||
version!: number;
|
||||
|
||||
@@ -58,6 +58,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
resolved: doc.resolved ?? false,
|
||||
resolvedBy: doc.resolvedBy ?? null,
|
||||
resolvedAt: doc.resolvedAt ?? null,
|
||||
spawned: doc.spawned ?? [],
|
||||
});
|
||||
|
||||
const escapeRegex = (text: string) =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
IssueVisit,
|
||||
IssueVisitsRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoIssueVisitsRepository extends IssueVisitsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.IssueVisit)
|
||||
private readonly visits: Model<IssueVisit>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async visit(visit: IssueVisit): Promise<string | null> {
|
||||
// One round trip: the document as it was before this visit.
|
||||
const before = await this.visits
|
||||
.findOneAndUpdate(
|
||||
{ userId: visit.userId, issueId: visit.issueId },
|
||||
{ $set: { seenAt: visit.seenAt } },
|
||||
{ upsert: true, new: false },
|
||||
)
|
||||
.lean<IssueVisit>();
|
||||
return before?.seenAt ?? null;
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const ReadModels = {
|
||||
AlertIntegration: 'AlertIntegration',
|
||||
AlertTrace: 'AlertTrace',
|
||||
TreeFollow: 'TreeFollow',
|
||||
IssueVisit: 'IssueVisit',
|
||||
IssueReminder: 'IssueReminder',
|
||||
MyDay: 'MyDay',
|
||||
ReleaseNotesShare: 'ReleaseNotesShare',
|
||||
@@ -373,6 +374,16 @@ export const TreeFollowSchema =
|
||||
SchemaFactory.createForClass(TreeFollowDocument);
|
||||
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'issue_visits' })
|
||||
class IssueVisitDocument {
|
||||
@Prop({ type: String }) userId!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
@Prop({ type: String }) seenAt!: string;
|
||||
}
|
||||
export const IssueVisitSchema =
|
||||
SchemaFactory.createForClass(IssueVisitDocument);
|
||||
IssueVisitSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'issue_reminders' })
|
||||
class IssueReminderDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@@ -511,6 +522,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||
{ name: ReadModels.IssueVisit, schema: IssueVisitSchema },
|
||||
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||
{ name: ReadModels.MyDay, schema: MyDaySchema },
|
||||
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PinsRepository,
|
||||
AlertIntegrationsRepository,
|
||||
TreeFollowsRepository,
|
||||
IssueVisitsRepository,
|
||||
IssueRemindersRepository,
|
||||
MyDaysRepository,
|
||||
ReleaseNoteSharesRepository,
|
||||
@@ -87,6 +88,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||
import { MongoIssueVisitsRepository } from './mongo/issue-visits.repository';
|
||||
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
||||
import { MongoMyDaysRepository } from './mongo/my-days.repository';
|
||||
import { MongoReleaseNoteSharesRepository } from './mongo/release-note-shares.repository';
|
||||
@@ -172,6 +174,7 @@ const PORTS = [
|
||||
useClass: MongoAlertIntegrationsRepository,
|
||||
},
|
||||
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
||||
{ provide: IssueVisitsRepository, useClass: MongoIssueVisitsRepository },
|
||||
{
|
||||
provide: IssueRemindersRepository,
|
||||
useClass: MongoIssueRemindersRepository,
|
||||
|
||||
@@ -113,6 +113,27 @@ export class CommentResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets',
|
||||
})
|
||||
turnCommentIntoIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('title') title: string,
|
||||
@Args('type', { description: "One of the project's ticket type ids" })
|
||||
type: string,
|
||||
@Args('asSubtask', { defaultValue: false }) asSubtask: boolean,
|
||||
) {
|
||||
return this.comments.turnIntoIssue(
|
||||
me.id,
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
);
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [DecisionGql], {
|
||||
description:
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
IssueVisitsService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
@@ -73,6 +74,7 @@ export class IssueResolver {
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
private readonly visits: IssueVisitsService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
@@ -452,6 +454,18 @@ export class IssueResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'I opened this ticket now: answers when I had opened it before (null: never), to show what is new since',
|
||||
})
|
||||
visitIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
): Promise<string | null> {
|
||||
return this.visits.visit(me.id, issueId);
|
||||
}
|
||||
|
||||
@Query(() => MyDayGql, {
|
||||
description:
|
||||
'«Mi día»: the tickets I picked for today, and what was left on my last day',
|
||||
|
||||
@@ -572,6 +572,12 @@ export class CommentPollGql {
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('CommentSpawn', { description: 'A ticket made from a comment' })
|
||||
export class CommentSpawnGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() key!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Comment')
|
||||
export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -615,6 +621,10 @@ export class CommentGql {
|
||||
})
|
||||
resolvedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
|
||||
@Field(() => [CommentSpawnGql], {
|
||||
description: 'Tickets made from it («Convertir en ticket»), oldest first',
|
||||
})
|
||||
spawned!: CommentSpawnGql[];
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||
@Field(() => Int) version!: number;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClosePollDto,
|
||||
ReactToCommentDto,
|
||||
ResolveThreadDto,
|
||||
TurnCommentIntoIssueDto,
|
||||
VotePollDto,
|
||||
} from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
@@ -117,6 +118,28 @@ export class CommentsController {
|
||||
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
|
||||
}
|
||||
|
||||
@Post(':commentId/issue')
|
||||
@ApiOperation({
|
||||
summary: 'Make a ticket of a comment',
|
||||
description:
|
||||
'Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
turnIntoIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: TurnCommentIntoIssueDto,
|
||||
) {
|
||||
return this.comments.turnIntoIssue(
|
||||
me.id,
|
||||
commentId,
|
||||
body.title,
|
||||
body.type,
|
||||
body.asSubtask ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':commentId/resolution')
|
||||
@ApiOperation({
|
||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||
|
||||
@@ -1640,6 +1640,27 @@ export class SetReminderDto {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class TurnCommentIntoIssueDto {
|
||||
@ApiProperty({ example: 'También falla en Android', maxLength: 200 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: "One of the project's ticket type ids" })
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
'Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
asSubtask?: boolean;
|
||||
}
|
||||
|
||||
export class ResolveThreadDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -628,6 +628,10 @@ export class CommentPollDto {
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
export class CommentSpawnDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ example: 'WEB-45' }) key!: string;
|
||||
}
|
||||
export class CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@@ -679,6 +683,11 @@ export class CommentDto {
|
||||
resolvedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
resolvedAt!: string | null;
|
||||
@ApiProperty({
|
||||
type: () => [CommentSpawnDto],
|
||||
description: 'Tickets made from it («Convertir en ticket»), oldest first',
|
||||
})
|
||||
spawned!: CommentSpawnDto[];
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
editedAt!: string | null;
|
||||
@@ -1447,6 +1456,16 @@ export class IssueReminderDto {
|
||||
@ApiProperty({ type: String, nullable: true }) note!: string | null;
|
||||
}
|
||||
|
||||
export class IssueVisitDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
description: 'My visit before this one (null: first time)',
|
||||
})
|
||||
previousVisit!: string | null;
|
||||
}
|
||||
|
||||
export class ReminderOnIssueDto {
|
||||
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
||||
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
IssueVisitsService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
ProjectsService,
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
CommentPageDto,
|
||||
DecisionDto,
|
||||
IssueReminderDto,
|
||||
IssueVisitDto,
|
||||
MyDayDto,
|
||||
ReminderOnIssueDto,
|
||||
EpicDto,
|
||||
@@ -112,6 +114,7 @@ export class IssuesController {
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
private readonly visits: IssueVisitsService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@@ -382,6 +385,27 @@ export class IssuesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/visit')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'I opened this ticket now',
|
||||
description:
|
||||
'Answers when I had opened it before (null: never), to show what others did since. Private to me.',
|
||||
})
|
||||
@ApiOkResponse({ type: IssueVisitDto })
|
||||
@ApiCommandErrors()
|
||||
async visit(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
): Promise<IssueVisitDto> {
|
||||
return {
|
||||
previousVisit: await this.visits.visit(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Put('issues/:issue/follow-tree')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
|
||||
Reference in New Issue
Block a user