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:
2026-10-15 16:53:47 +02:00
parent 869acd3f1f
commit f60d4c8346
33 changed files with 1197 additions and 22 deletions

View File

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