feat: presence on tickets
A ticket's page beats every 10 s (touchPresence / PUT
/issues/{issue}/presence, with whether I am editing) and gets back who
else is there, editing ones first. Entries live in Redis, one hash per
ticket, and fade after 30 s without a beat; leaving the page takes me out
at once. Only for people who can read the ticket; never in the event
store. The ticket shows «Bob está editando · Ana también lo está viendo»
above its title; editing means a ticket or comment form is open or a
comment is being written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
BlockersService,
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
IssueGql,
|
||||
IssuePageGql,
|
||||
IssueParentGql,
|
||||
PresenceGql,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => IssueGql)
|
||||
@@ -57,8 +59,30 @@ export class IssueResolver {
|
||||
private readonly commentsService: CommentsService,
|
||||
private readonly workflows: WorkflowsService,
|
||||
private readonly blockers: BlockersService,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
description:
|
||||
'I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first',
|
||||
})
|
||||
touchPresence(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('editing', { defaultValue: false }) editing: boolean,
|
||||
) {
|
||||
return this.presence.touch({ id: me.id, name: me.name }, issueId, editing);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: "I left this ticket's page" })
|
||||
async leavePresence(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
) {
|
||||
await this.presence.leave(me.id, issueId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => IssueGql)
|
||||
issue(
|
||||
|
||||
@@ -1063,3 +1063,13 @@ export class ProjectInsightsGql {
|
||||
})
|
||||
fields!: FieldBreakdownGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Presence', {
|
||||
description: 'Someone on a ticket right now (heartbeat in the last 30 s)',
|
||||
})
|
||||
export class PresenceGql {
|
||||
@Field(() => UserRefGql) user!: UserRefGql;
|
||||
@Field({ description: 'Editing the ticket or writing a comment' })
|
||||
editing!: boolean;
|
||||
@Field({ description: 'Last heartbeat' }) at!: string;
|
||||
}
|
||||
|
||||
@@ -1188,6 +1188,16 @@ export class SetIssueParentDto {
|
||||
parent!: string | null;
|
||||
}
|
||||
|
||||
export class PresenceBodyDto {
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Editing the ticket or writing a comment',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
editing = false;
|
||||
}
|
||||
|
||||
export class ChecklistItemBodyDto {
|
||||
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
|
||||
@IsString()
|
||||
|
||||
@@ -764,6 +764,14 @@ export class ProjectStatsDto {
|
||||
byAssignee!: CountByAssigneeDto[];
|
||||
}
|
||||
|
||||
export class PresenceDto {
|
||||
@ApiProperty({ type: UserRefDto }) user!: UserRefDto;
|
||||
@ApiProperty({ description: 'Editing the ticket or writing a comment' })
|
||||
editing!: boolean;
|
||||
@ApiProperty({ format: 'date-time', description: 'Last heartbeat' })
|
||||
at!: string;
|
||||
}
|
||||
|
||||
export class ActivityDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ example: 'IssueStatusChanged' }) type!: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PresenceService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { withoutNulls } from '../common/issue-input';
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
AddCommentDto,
|
||||
CreateIssueDto,
|
||||
IssueSearchQueryDto,
|
||||
PresenceBodyDto,
|
||||
LinkIssueDto,
|
||||
LinkTypeParamDto,
|
||||
MyIssuesQueryDto,
|
||||
@@ -60,6 +63,7 @@ import {
|
||||
EpicDto,
|
||||
IssueDto,
|
||||
IssuePageDto,
|
||||
PresenceDto,
|
||||
} from './dto/responses.dto';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { toBulkChange } from '../common/bulk';
|
||||
@@ -85,8 +89,40 @@ export class IssuesController {
|
||||
private readonly issues: IssuesService,
|
||||
private readonly comments: CommentsService,
|
||||
private readonly refs: Refs,
|
||||
private readonly presence: PresenceService,
|
||||
) {}
|
||||
|
||||
@Put('issues/:issue/presence')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'I am on this ticket (heartbeat)',
|
||||
description:
|
||||
'Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.',
|
||||
})
|
||||
@ApiOkResponse({ type: [PresenceDto] })
|
||||
@ApiReadErrors()
|
||||
async touchPresence(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: PresenceBodyDto,
|
||||
) {
|
||||
return this.presence.touch(
|
||||
{ id: me.id, name: me.name },
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.editing,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/presence')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: "I left this ticket's page" })
|
||||
@ApiNoContentResponse()
|
||||
@ApiReadErrors()
|
||||
async leavePresence(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
|
||||
}
|
||||
|
||||
@Put('issues/:issue/watch')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
|
||||
Reference in New Issue
Block a user