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

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