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:
2026-10-21 21:05:44 +02:00
parent 429b52e6f5
commit 285bd5c454
50 changed files with 1953 additions and 79 deletions

View File

@@ -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:

View File

@@ -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',

View File

@@ -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;

View File

@@ -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)',

View File

@@ -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:

View File

@@ -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;

View File

@@ -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