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

@@ -1700,4 +1700,59 @@ describe('task-stream (e2e)', () => {
.summary,
).toMatch(/tu equipo Frontend/);
});
it('tells who else is on a ticket, and who is editing it', async () => {
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/members`, {
userId: '102',
});
const created = await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Aquí estamos',
});
const issue = await eventually(
() => rest('token-bob', 'GET', `/issues/${created.body['key']}`),
(r) => r.status === 200,
);
const id = issue.body['id'];
expect(
(
await rest('token-ana', 'PUT', `/issues/${id}/presence`, {
editing: false,
})
).body,
).toEqual([]);
const { touchPresence } = await gql(
'token-bob',
`mutation($i: ID!) { touchPresence(issueId: $i, editing: true) { user { id name } editing } }`,
{ i: id },
);
expect(touchPresence).toEqual([
{ user: { id: '101', name: 'Ana (lead)' }, editing: false },
]);
const seen = await rest('token-ana', 'PUT', `/issues/${id}/presence`, {});
expect(seen.body).toEqual([
expect.objectContaining({
user: { id: '102', name: 'Bob (frontend)' },
editing: true,
}),
]);
// Outsiders cannot even ask (a private ticket does not exist for them).
expect(
(await rest('token-eve', 'PUT', `/issues/${id}/presence`, {})).status,
).toBe(404);
expect(
(await rest('token-bob', 'DELETE', `/issues/${id}/presence`)).status,
).toBe(204);
expect(
(await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body,
).toEqual([]);
});
});

View File

@@ -2185,6 +2185,173 @@
]
}
},
"/api/issues/{issue}/presence": {
"put": {
"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.",
"operationId": "IssuesController_touchPresence",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresenceBodyDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresenceDto"
}
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "I am on this ticket (heartbeat)",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_leavePresence",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "I left this ticket's page",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/watch": {
"put": {
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",
@@ -13248,6 +13415,38 @@
"role"
]
},
"PresenceBodyDto": {
"type": "object",
"properties": {
"editing": {
"type": "object",
"default": false,
"description": "Editing the ticket or writing a comment"
}
}
},
"PresenceDto": {
"type": "object",
"properties": {
"user": {
"$ref": "#/components/schemas/UserRefDto"
},
"editing": {
"type": "boolean",
"description": "Editing the ticket or writing a comment"
},
"at": {
"type": "string",
"format": "date-time",
"description": "Last heartbeat"
}
},
"required": [
"user",
"editing",
"at"
]
},
"IssuePageDto": {
"type": "object",
"properties": {

View File

@@ -871,6 +871,9 @@ type Mutation {
duplicateIssue(issueId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""I left this ticket's page"""
leavePresence(issueId: ID!): Boolean!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
linkIssues(input: LinkIssuesInput!): CommandResult!
@@ -933,6 +936,11 @@ type Mutation {
"""Send a ping now and tell how it went"""
testWebhook(webhookId: ID!): WebhookDelivery!
"""
I am on this ticket (send every ~10 s while the page is open): answers who else is, the ones editing first
"""
touchPresence(editing: Boolean! = false, issueId: ID!): [Presence!]!
"""
To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
"""
@@ -1074,6 +1082,16 @@ type OptionCount {
value: String!
}
"""Someone on a ticket right now (heartbeat in the last 30 s)"""
type Presence {
"""Last heartbeat"""
at: String!
"""Editing the ticket or writing a comment"""
editing: Boolean!
user: UserRef!
}
type PriorityCount {
count: Int!
priority: IssuePriority!