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

@@ -397,6 +397,27 @@ export interface paths {
patch: operations["ProjectsController_changeMemberRole"];
trace?: never;
};
"/api/issues/{issue}/presence": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* 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.
*/
put: operations["IssuesController_touchPresence"];
post?: never;
/** I left this ticket's page */
delete: operations["IssuesController_leavePresence"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/watch": {
parameters: {
query?: never;
@@ -2419,6 +2440,23 @@ export interface components {
ChangeMemberRoleDto: {
role: components["schemas"]["ProjectRole"];
};
PresenceBodyDto: {
/**
* @description Editing the ticket or writing a comment
* @default false
*/
editing: Record<string, never>;
};
PresenceDto: {
user: components["schemas"]["UserRefDto"];
/** @description Editing the ticket or writing a comment */
editing: boolean;
/**
* Format: date-time
* @description Last heartbeat
*/
at: string;
};
IssuePageDto: {
items: components["schemas"]["IssueDto"][];
/** @description Total matches (for pagination) */
@@ -4740,6 +4778,124 @@ export interface operations {
};
};
};
IssuesController_touchPresence: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PresenceBodyDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PresenceDto"][];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_leavePresence: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_watch: {
parameters: {
query?: never;