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:
@@ -1700,4 +1700,59 @@ describe('task-stream (e2e)', () => {
|
|||||||
.summary,
|
.summary,
|
||||||
).toMatch(/tu equipo Frontend/);
|
).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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": {
|
"/api/issues/{issue}/watch": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",
|
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",
|
||||||
@@ -13248,6 +13415,38 @@
|
|||||||
"role"
|
"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": {
|
"IssuePageDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -871,6 +871,9 @@ type Mutation {
|
|||||||
duplicateIssue(issueId: ID!): CommandResult!
|
duplicateIssue(issueId: ID!): CommandResult!
|
||||||
editComment(body: String!, commentId: 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"""
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
@@ -933,6 +936,11 @@ type Mutation {
|
|||||||
"""Send a ping now and tell how it went"""
|
"""Send a ping now and tell how it went"""
|
||||||
testWebhook(webhookId: ID!): WebhookDelivery!
|
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
|
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!
|
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 {
|
type PriorityCount {
|
||||||
count: Int!
|
count: Int!
|
||||||
priority: IssuePriority!
|
priority: IssuePriority!
|
||||||
|
|||||||
@@ -397,6 +397,27 @@ export interface paths {
|
|||||||
patch: operations["ProjectsController_changeMemberRole"];
|
patch: operations["ProjectsController_changeMemberRole"];
|
||||||
trace?: never;
|
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": {
|
"/api/issues/{issue}/watch": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2419,6 +2440,23 @@ export interface components {
|
|||||||
ChangeMemberRoleDto: {
|
ChangeMemberRoleDto: {
|
||||||
role: components["schemas"]["ProjectRole"];
|
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: {
|
IssuePageDto: {
|
||||||
items: components["schemas"]["IssueDto"][];
|
items: components["schemas"]["IssueDto"][];
|
||||||
/** @description Total matches (for pagination) */
|
/** @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: {
|
IssuesController_watch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -135,6 +135,14 @@ estaban. Un ticket va a un equipo con `team` (en `createIssue` / `updateIssue` y
|
|||||||
`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu
|
`teams` (`?team=FRONT`). En un comentario, `@[Frontend](team-FRONT)` avisa a cada miembro del equipo («mencionó a tu
|
||||||
equipo…»).
|
equipo…»).
|
||||||
|
|
||||||
|
### Presencia
|
||||||
|
|
||||||
|
Quién más está en un ticket ahora. La página de un ticket late cada 10 s con `touchPresence(issueId, editing)` (o
|
||||||
|
`PUT /api/issues/{issue}/presence` con `{ editing }`), y la respuesta es quién más está, primero los que editan. Se
|
||||||
|
guarda en Redis (un hash por ticket, `task-stream:presence:<id>`) y una entrada sin latido en 30 s desaparece sola;
|
||||||
|
`leavePresence` (`DELETE …/presence`) la quita al salir. No es historia: no pasa por el event store. Solo para quien
|
||||||
|
puede leer el ticket.
|
||||||
|
|
||||||
### Definición de «Hecho»
|
### Definición de «Hecho»
|
||||||
|
|
||||||
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
||||||
@@ -658,10 +666,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` |
|
||||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ visualización de la plataforma:
|
|||||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos |
|
| `/` | Inicio: «Ir a…» (`WEB-12`), mis vistas guardadas (`ts-my-views`: una barra para elegir, sus tickets y «+ Nueva vista»), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket |
|
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5).
|
|||||||
vista.
|
vista.
|
||||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||||
integración continua.
|
integración continua.
|
||||||
- **Presencia**: ver quién está mirando o editando el mismo ticket.
|
|
||||||
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
|
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos).
|
||||||
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
- **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS.
|
||||||
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
|
- **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página.
|
||||||
@@ -121,3 +120,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
|
- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva.
|
||||||
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
|
- Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por
|
||||||
equipo y «@equipo» avisa a todos sus miembros.
|
equipo y «@equipo» avisa a todos sus miembros.
|
||||||
|
- Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -32,3 +32,5 @@ export * from './lib/saved-filters/saved-filters';
|
|||||||
export * from './lib/saved-filters/my-views';
|
export * from './lib/saved-filters/my-views';
|
||||||
export * from './lib/ports/my-views.repository';
|
export * from './lib/ports/my-views.repository';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
|
export * from './lib/ports/presence.store';
|
||||||
|
export * from './lib/presence.service';
|
||||||
|
|||||||
24
libs/api/tracker/application/src/lib/ports/presence.store.ts
Normal file
24
libs/api/tracker/application/src/lib/ports/presence.store.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** Someone on a ticket's page just now. */
|
||||||
|
export interface PresenceEntry {
|
||||||
|
user: { id: string; name: string };
|
||||||
|
/** Editing the ticket or writing a comment, not only looking. */
|
||||||
|
editing: boolean;
|
||||||
|
/** Last heartbeat (ISO). */
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who is on each ticket's page, shared by every instance of the API.
|
||||||
|
* Entries fade on their own: a page that stops beating is gone.
|
||||||
|
*/
|
||||||
|
export abstract class PresenceStore {
|
||||||
|
/** Records (or refreshes) one person on a ticket; lasts `ttlMs`. */
|
||||||
|
abstract touch(
|
||||||
|
issueId: string,
|
||||||
|
entry: PresenceEntry,
|
||||||
|
ttlMs: number,
|
||||||
|
): Promise<void>;
|
||||||
|
/** Everyone whose heartbeat is newer than `since` (ms epoch). */
|
||||||
|
abstract list(issueId: string, since: number): Promise<PresenceEntry[]>;
|
||||||
|
abstract leave(issueId: string, userId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||||
|
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||||
|
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
||||||
|
import { PRESENCE_TTL_MS, PresenceService } from './presence.service';
|
||||||
|
import { ReadAccess } from './queries/read-access';
|
||||||
|
|
||||||
|
class MemoryPresence extends PresenceStore {
|
||||||
|
readonly entries = new Map<string, Map<string, PresenceEntry>>();
|
||||||
|
async touch(issueId: string, entry: PresenceEntry) {
|
||||||
|
const here = this.entries.get(issueId) ?? new Map();
|
||||||
|
here.set(entry.user.id, entry);
|
||||||
|
this.entries.set(issueId, here);
|
||||||
|
}
|
||||||
|
async list(issueId: string, since: number) {
|
||||||
|
return [...(this.entries.get(issueId)?.values() ?? [])].filter(
|
||||||
|
(e) => Date.parse(e.at) >= since,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async leave(issueId: string, userId: string) {
|
||||||
|
this.entries.get(issueId)?.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = {
|
||||||
|
findById: async (id: string) =>
|
||||||
|
id === 'i1'
|
||||||
|
? ({ id: 'i1', projectId: 'p1', trashedAt: null } as IssueReadModel)
|
||||||
|
: null,
|
||||||
|
} as unknown as FindIssuesRepository;
|
||||||
|
const access = {
|
||||||
|
assertCanView: async (_projectId: string, viewerId: string) => {
|
||||||
|
if (viewerId === 'eve') throw new ForbiddenError('No');
|
||||||
|
return 'MEMBER';
|
||||||
|
},
|
||||||
|
} as unknown as ReadAccess;
|
||||||
|
|
||||||
|
describe('PresenceService', () => {
|
||||||
|
it('tells who else is on a ticket, the ones editing first', async () => {
|
||||||
|
const store = new MemoryPresence();
|
||||||
|
const presence = new PresenceService(store, issues, access);
|
||||||
|
await presence.touch({ id: 'ana', name: 'Ana' }, 'i1', false);
|
||||||
|
await presence.touch({ id: 'cris', name: 'Cris' }, 'i1', true);
|
||||||
|
const seen = await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false);
|
||||||
|
expect(seen.map((p) => [p.user.name, p.editing])).toEqual([
|
||||||
|
['Cris', true],
|
||||||
|
['Ana', false],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A page that stops beating fades out; leaving takes one out at once.
|
||||||
|
store.entries.get('i1')!.get('ana')!.at = new Date(
|
||||||
|
Date.now() - PRESENCE_TTL_MS - 1000,
|
||||||
|
).toISOString();
|
||||||
|
await presence.leave('cris', 'i1');
|
||||||
|
expect(
|
||||||
|
await presence.touch({ id: 'bob', name: 'Bob' }, 'i1', false),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only for whoever can read the ticket', async () => {
|
||||||
|
const presence = new PresenceService(new MemoryPresence(), issues, access);
|
||||||
|
await expect(
|
||||||
|
presence.touch({ id: 'eve', name: 'Eve' }, 'i1', false),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenError);
|
||||||
|
await expect(
|
||||||
|
presence.touch({ id: 'ana', name: 'Ana' }, 'nope', false),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
libs/api/tracker/application/src/lib/presence.service.ts
Normal file
62
libs/api/tracker/application/src/lib/presence.service.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||||
|
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||||
|
import { PresenceEntry, PresenceStore } from './ports/presence.store';
|
||||||
|
import { ReadAccess } from './queries/read-access';
|
||||||
|
|
||||||
|
/** A page beats every 10 s; three missed beats and it is gone. */
|
||||||
|
export const PRESENCE_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who else is on a ticket right now, and whether they are editing it. Not
|
||||||
|
* history: kept in Redis for a few seconds, never in the event store.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PresenceService {
|
||||||
|
constructor(
|
||||||
|
private readonly store: PresenceStore,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I am on the ticket (editing or not): answers who else is there, the
|
||||||
|
* ones editing first. Only for people who can read it.
|
||||||
|
*/
|
||||||
|
async touch(
|
||||||
|
viewer: { id: string; name: string },
|
||||||
|
issueId: string,
|
||||||
|
editing: boolean,
|
||||||
|
): Promise<PresenceEntry[]> {
|
||||||
|
await this.assertCanView(viewer.id, issueId);
|
||||||
|
const now = Date.now();
|
||||||
|
await this.store.touch(
|
||||||
|
issueId,
|
||||||
|
{
|
||||||
|
user: { id: viewer.id, name: viewer.name },
|
||||||
|
editing,
|
||||||
|
at: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
|
PRESENCE_TTL_MS,
|
||||||
|
);
|
||||||
|
const here = await this.store.list(issueId, now - PRESENCE_TTL_MS);
|
||||||
|
return here
|
||||||
|
.filter((entry) => entry.user.id !== viewer.id)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.editing) - Number(a.editing) ||
|
||||||
|
a.user.name.localeCompare(b.user.name, 'es'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** I left the ticket's page. */
|
||||||
|
leave(viewerId: string, issueId: string): Promise<void> {
|
||||||
|
return this.store.leave(issueId, viewerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanView(viewerId: string, issueId: string) {
|
||||||
|
const issue = await this.findIssues.findById(issueId);
|
||||||
|
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||||
|
await this.access.assertCanView(issue.projectId, viewerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
|
|||||||
import { TransferService } from './transfer.service';
|
import { TransferService } from './transfer.service';
|
||||||
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
import { TRANSFER_HANDLERS } from './transfer/import-export';
|
||||||
import { AutomationsService } from './automations.service';
|
import { AutomationsService } from './automations.service';
|
||||||
|
import { PresenceService } from './presence.service';
|
||||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||||
import { CALENDAR_HANDLERS } from './calendar/calendar';
|
import { CALENDAR_HANDLERS } from './calendar/calendar';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
@@ -79,6 +80,7 @@ const SERVICES = [
|
|||||||
WebhooksService,
|
WebhooksService,
|
||||||
TransferService,
|
TransferService,
|
||||||
AutomationsService,
|
AutomationsService,
|
||||||
|
PresenceService,
|
||||||
ProposalQuota,
|
ProposalQuota,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { REDIS_CLIENT } from '@task-stream/api/shared/infrastructure';
|
||||||
|
import {
|
||||||
|
PresenceEntry,
|
||||||
|
PresenceStore,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hash per ticket (field: user id, value: the entry). The hash expires
|
||||||
|
* when nobody beats on it; stale fields are skipped and dropped on reading.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RedisPresenceStore extends PresenceStore {
|
||||||
|
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(issueId: string): string {
|
||||||
|
return `task-stream:presence:${issueId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async touch(
|
||||||
|
issueId: string,
|
||||||
|
entry: PresenceEntry,
|
||||||
|
ttlMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = this.key(issueId);
|
||||||
|
await this.redis
|
||||||
|
.multi()
|
||||||
|
.hset(key, entry.user.id, JSON.stringify(entry))
|
||||||
|
.pexpire(key, ttlMs)
|
||||||
|
.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(issueId: string, since: number): Promise<PresenceEntry[]> {
|
||||||
|
const key = this.key(issueId);
|
||||||
|
const all = await this.redis.hgetall(key);
|
||||||
|
const fresh: PresenceEntry[] = [];
|
||||||
|
const stale: string[] = [];
|
||||||
|
for (const [userId, raw] of Object.entries(all)) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(raw) as PresenceEntry;
|
||||||
|
if (Date.parse(entry.at) >= since) fresh.push(entry);
|
||||||
|
else stale.push(userId);
|
||||||
|
} catch {
|
||||||
|
stale.push(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stale.length) await this.redis.hdel(key, ...stale);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
async leave(issueId: string, userId: string): Promise<void> {
|
||||||
|
await this.redis.hdel(this.key(issueId), userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
UpsertMaterializedTimelineRepository,
|
UpsertMaterializedTimelineRepository,
|
||||||
UserDirectory,
|
UserDirectory,
|
||||||
RateLimitStore,
|
RateLimitStore,
|
||||||
|
PresenceStore,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import { IdentityUserDirectory } from './identity/identity-user-directory';
|
import { IdentityUserDirectory } from './identity/identity-user-directory';
|
||||||
import {
|
import {
|
||||||
@@ -68,6 +69,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
|
|||||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||||
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
||||||
|
import { RedisPresenceStore } from './redis/redis-presence.store';
|
||||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||||
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
||||||
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
||||||
@@ -160,6 +162,7 @@ const PORTS = [
|
|||||||
// Realtime → Redis pub/sub
|
// Realtime → Redis pub/sub
|
||||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||||
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
|
{ provide: RateLimitStore, useClass: RedisRateLimitStore },
|
||||||
|
{ provide: PresenceStore, useClass: RedisPresenceStore },
|
||||||
// Identity context (anti-corruption layer)
|
// Identity context (anti-corruption layer)
|
||||||
{ provide: UserDirectory, useClass: IdentityUserDirectory },
|
{ provide: UserDirectory, useClass: IdentityUserDirectory },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
BlockersService,
|
BlockersService,
|
||||||
CommentsService,
|
CommentsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
|
PresenceService,
|
||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
IssueGql,
|
IssueGql,
|
||||||
IssuePageGql,
|
IssuePageGql,
|
||||||
IssueParentGql,
|
IssueParentGql,
|
||||||
|
PresenceGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@Resolver(() => IssueGql)
|
@Resolver(() => IssueGql)
|
||||||
@@ -57,8 +59,30 @@ export class IssueResolver {
|
|||||||
private readonly commentsService: CommentsService,
|
private readonly commentsService: CommentsService,
|
||||||
private readonly workflows: WorkflowsService,
|
private readonly workflows: WorkflowsService,
|
||||||
private readonly blockers: BlockersService,
|
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()
|
@OptionalAuth()
|
||||||
@Query(() => IssueGql)
|
@Query(() => IssueGql)
|
||||||
issue(
|
issue(
|
||||||
|
|||||||
@@ -1063,3 +1063,13 @@ export class ProjectInsightsGql {
|
|||||||
})
|
})
|
||||||
fields!: FieldBreakdownGql[];
|
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;
|
parent!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PresenceBodyDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Editing the ticket or writing a comment',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
export class ChecklistItemBodyDto {
|
export class ChecklistItemBodyDto {
|
||||||
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
|
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -764,6 +764,14 @@ export class ProjectStatsDto {
|
|||||||
byAssignee!: CountByAssigneeDto[];
|
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 {
|
export class ActivityDto {
|
||||||
@ApiProperty() id!: string;
|
@ApiProperty() id!: string;
|
||||||
@ApiProperty({ example: 'IssueStatusChanged' }) type!: string;
|
@ApiProperty({ example: 'IssueStatusChanged' }) type!: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CommentsService,
|
CommentsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
|
PresenceService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||||
import { withoutNulls } from '../common/issue-input';
|
import { withoutNulls } from '../common/issue-input';
|
||||||
@@ -42,6 +44,7 @@ import {
|
|||||||
AddCommentDto,
|
AddCommentDto,
|
||||||
CreateIssueDto,
|
CreateIssueDto,
|
||||||
IssueSearchQueryDto,
|
IssueSearchQueryDto,
|
||||||
|
PresenceBodyDto,
|
||||||
LinkIssueDto,
|
LinkIssueDto,
|
||||||
LinkTypeParamDto,
|
LinkTypeParamDto,
|
||||||
MyIssuesQueryDto,
|
MyIssuesQueryDto,
|
||||||
@@ -60,6 +63,7 @@ import {
|
|||||||
EpicDto,
|
EpicDto,
|
||||||
IssueDto,
|
IssueDto,
|
||||||
IssuePageDto,
|
IssuePageDto,
|
||||||
|
PresenceDto,
|
||||||
} from './dto/responses.dto';
|
} from './dto/responses.dto';
|
||||||
import { viewerOf } from '../common/viewer';
|
import { viewerOf } from '../common/viewer';
|
||||||
import { toBulkChange } from '../common/bulk';
|
import { toBulkChange } from '../common/bulk';
|
||||||
@@ -85,8 +89,40 @@ export class IssuesController {
|
|||||||
private readonly issues: IssuesService,
|
private readonly issues: IssuesService,
|
||||||
private readonly comments: CommentsService,
|
private readonly comments: CommentsService,
|
||||||
private readonly refs: Refs,
|
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')
|
@Put('issues/:issue/watch')
|
||||||
@ISSUE
|
@ISSUE
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export * from './lib/facades/inbox.facade';
|
|||||||
export * from './lib/facades/epics.facade';
|
export * from './lib/facades/epics.facade';
|
||||||
export * from './lib/facades/insights.facade';
|
export * from './lib/facades/insights.facade';
|
||||||
export * from './lib/facades/my-views.facade';
|
export * from './lib/facades/my-views.facade';
|
||||||
|
export * from './lib/facades/presence.facade';
|
||||||
export * from './lib/facades/backlog.facade';
|
export * from './lib/facades/backlog.facade';
|
||||||
export * from './lib/facades/list.facade';
|
export * from './lib/facades/list.facade';
|
||||||
export * from './lib/facades/propose.facade';
|
export * from './lib/facades/propose.facade';
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { type } from '@ngrx/signals';
|
||||||
|
import { eventGroup } from '@ngrx/signals/events';
|
||||||
|
import { Presence } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
export const presencePageEvents = eventGroup({
|
||||||
|
source: 'Issue Presence',
|
||||||
|
events: {
|
||||||
|
opened: type<string>(),
|
||||||
|
editingChanged: type<boolean>(),
|
||||||
|
left: type<void>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const presenceApiEvents = eventGroup({
|
||||||
|
source: 'Presence API',
|
||||||
|
events: {
|
||||||
|
seen: type<Presence[]>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { inject, Injectable, Provider } from '@angular/core';
|
||||||
|
import { injectDispatch } from '@ngrx/signals/events';
|
||||||
|
import { presencePageEvents } from '../events/presence.events';
|
||||||
|
import { PresenceStore } from '../stores/presence.store';
|
||||||
|
|
||||||
|
/** Who else is on the ticket's page, and whether they are editing it. */
|
||||||
|
@Injectable()
|
||||||
|
export class PresenceFacade {
|
||||||
|
private readonly store = inject(PresenceStore);
|
||||||
|
private readonly dispatch = injectDispatch(presencePageEvents);
|
||||||
|
|
||||||
|
/** The others, the ones editing first. */
|
||||||
|
readonly others = this.store.others;
|
||||||
|
|
||||||
|
/** I am on this ticket: beats until the page closes. */
|
||||||
|
open(issueId: string): void {
|
||||||
|
if (this.store.issueId() !== issueId) this.dispatch.opened(issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(editing: boolean): void {
|
||||||
|
this.dispatch.editingChanged(editing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store + facade of a ticket's presence, for the ticket's page. */
|
||||||
|
export function providePresenceFacade(): Provider[] {
|
||||||
|
return [PresenceStore, PresenceFacade];
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { signalStore, withHooks, withState } from '@ngrx/signals';
|
||||||
|
import {
|
||||||
|
Events,
|
||||||
|
on,
|
||||||
|
withEventHandlers,
|
||||||
|
withReducer,
|
||||||
|
} from '@ngrx/signals/events';
|
||||||
|
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
EMPTY,
|
||||||
|
map,
|
||||||
|
mergeMap,
|
||||||
|
pairwise,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
timer,
|
||||||
|
} from 'rxjs';
|
||||||
|
import {
|
||||||
|
presenceApiEvents,
|
||||||
|
presencePageEvents,
|
||||||
|
} from '../events/presence.events';
|
||||||
|
|
||||||
|
/** A heartbeat every so often; the API forgets a page after 30 s without one. */
|
||||||
|
export const PRESENCE_BEAT_MS = 10_000;
|
||||||
|
|
||||||
|
interface PresenceState {
|
||||||
|
issueId: string | null;
|
||||||
|
others: Presence[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who else is on the ticket's page. Beats while the page is open (and at
|
||||||
|
* once when I start or stop editing); says goodbye when it closes. Scoped
|
||||||
|
* to the ticket's page.
|
||||||
|
*/
|
||||||
|
export const PresenceStore = signalStore(
|
||||||
|
withState<PresenceState>({ issueId: null, others: [] }),
|
||||||
|
withReducer(
|
||||||
|
on(presencePageEvents.opened, ({ payload }) => ({
|
||||||
|
issueId: payload,
|
||||||
|
others: [],
|
||||||
|
})),
|
||||||
|
on(presenceApiEvents.seen, ({ payload }) => ({ others: payload })),
|
||||||
|
on(presencePageEvents.left, () => ({ issueId: null, others: [] })),
|
||||||
|
),
|
||||||
|
withEventHandlers(
|
||||||
|
(store, events = inject(Events), presence = inject(PresenceGateway)) => ({
|
||||||
|
beat$: events.on(presencePageEvents.opened).pipe(
|
||||||
|
switchMap(({ payload: issueId }) =>
|
||||||
|
combineLatest([
|
||||||
|
events.on(presencePageEvents.editingChanged).pipe(
|
||||||
|
map(({ payload }) => payload),
|
||||||
|
startWith(false),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
|
timer(0, PRESENCE_BEAT_MS),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(([editing]) =>
|
||||||
|
// A missed beat is no error worth showing: the next one retries.
|
||||||
|
presence.touch(issueId, editing).pipe(catchError(() => EMPTY)),
|
||||||
|
),
|
||||||
|
map((others) => presenceApiEvents.seen(others)),
|
||||||
|
takeUntil(events.on(presencePageEvents.left)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Moving to another ticket in the same page: goodbye to the previous one.
|
||||||
|
moved$: events.on(presencePageEvents.opened).pipe(
|
||||||
|
map(({ payload }) => payload),
|
||||||
|
pairwise(),
|
||||||
|
mergeMap(([previous]) =>
|
||||||
|
presence.leave(previous).pipe(catchError(() => EMPTY)),
|
||||||
|
),
|
||||||
|
switchMap(() => EMPTY),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
withHooks((store, presence = inject(PresenceGateway)) => ({
|
||||||
|
onDestroy() {
|
||||||
|
const issueId = store.issueId();
|
||||||
|
if (issueId)
|
||||||
|
presence
|
||||||
|
.leave(issueId)
|
||||||
|
.pipe(catchError(() => EMPTY))
|
||||||
|
.subscribe();
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
@@ -197,6 +197,15 @@ export interface Issue extends IssueSummary {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Someone else on a ticket's page right now. */
|
||||||
|
export interface Presence {
|
||||||
|
user: UserRef;
|
||||||
|
/** Editing the ticket or writing a comment. */
|
||||||
|
editing: boolean;
|
||||||
|
/** Last heartbeat. */
|
||||||
|
at: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A commit, branch or pull request that mentions a ticket. */
|
/** A commit, branch or pull request that mentions a ticket. */
|
||||||
export interface DevLink {
|
export interface DevLink {
|
||||||
kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST';
|
kind: 'COMMIT' | 'BRANCH' | 'PULL_REQUEST';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Release,
|
Release,
|
||||||
ReleaseBurnup,
|
ReleaseBurnup,
|
||||||
SavedFilter,
|
SavedFilter,
|
||||||
|
Presence,
|
||||||
MyView,
|
MyView,
|
||||||
MyViewCriteria,
|
MyViewCriteria,
|
||||||
SavedFilterCriteria,
|
SavedFilterCriteria,
|
||||||
@@ -430,3 +431,9 @@ export abstract class TrackerRealtimeGateway {
|
|||||||
*/
|
*/
|
||||||
abstract reconnects(): Observable<void>;
|
abstract reconnects(): Observable<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who is on a ticket's page: a heartbeat answers who else is. */
|
||||||
|
export abstract class PresenceGateway {
|
||||||
|
abstract touch(issueId: string, editing: boolean): Observable<Presence[]>;
|
||||||
|
abstract leave(issueId: string): Observable<void>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { Presence, PresenceGateway } from '@task-stream/web/tracker/domain';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
LeavePresenceDocument,
|
||||||
|
TouchPresenceDocument,
|
||||||
|
} from '../graphql/generated';
|
||||||
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GraphqlPresenceGateway extends PresenceGateway {
|
||||||
|
private readonly gql = inject(GraphqlOperations);
|
||||||
|
|
||||||
|
touch(issueId: string, editing: boolean): Observable<Presence[]> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(TouchPresenceDocument, { issueId, editing })
|
||||||
|
.pipe(map((d) => d.touchPresence));
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(issueId: string): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(LeavePresenceDocument, { issueId })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1546,6 +1546,25 @@ export type NotificationAddedSubscription = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TouchPresenceMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
editing: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type TouchPresenceMutation = {
|
||||||
|
touchPresence: Array<{
|
||||||
|
editing: boolean;
|
||||||
|
at: string;
|
||||||
|
user: { id: string; name: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeavePresenceMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type LeavePresenceMutation = { leavePresence: boolean };
|
||||||
|
|
||||||
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
export type MyProjectsQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type MyProjectsQuery = {
|
export type MyProjectsQuery = {
|
||||||
@@ -9536,6 +9555,153 @@ export const NotificationAddedDocument = {
|
|||||||
NotificationAddedSubscription,
|
NotificationAddedSubscription,
|
||||||
NotificationAddedSubscriptionVariables
|
NotificationAddedSubscriptionVariables
|
||||||
>;
|
>;
|
||||||
|
export const TouchPresenceDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'TouchPresence' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'editing' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Boolean' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'touchPresence' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'editing' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'editing' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'user' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'editing' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'at' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
TouchPresenceMutation,
|
||||||
|
TouchPresenceMutationVariables
|
||||||
|
>;
|
||||||
|
export const LeavePresenceDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'LeavePresence' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'leavePresence' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
LeavePresenceMutation,
|
||||||
|
LeavePresenceMutationVariables
|
||||||
|
>;
|
||||||
export const MyProjectsDocument = {
|
export const MyProjectsDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
mutation TouchPresence($issueId: ID!, $editing: Boolean!) {
|
||||||
|
touchPresence(issueId: $issueId, editing: $editing) {
|
||||||
|
user {
|
||||||
|
...UserRefFields
|
||||||
|
}
|
||||||
|
editing
|
||||||
|
at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation LeavePresence($issueId: ID!) {
|
||||||
|
leavePresence(issueId: $issueId)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
NotificationsGateway,
|
NotificationsGateway,
|
||||||
CalendarGateway,
|
CalendarGateway,
|
||||||
|
PresenceGateway,
|
||||||
PushDevice,
|
PushDevice,
|
||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||||
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.gateway';
|
||||||
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
|
import { GraphqlCalendarGateway } from './adapters/graphql-calendar.gateway';
|
||||||
|
import { GraphqlPresenceGateway } from './adapters/graphql-presence.gateway';
|
||||||
import { BrowserPushDevice } from './adapters/browser-push.device';
|
import { BrowserPushDevice } from './adapters/browser-push.device';
|
||||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||||
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
|
import { HttpProposalsGateway } from './adapters/http-proposals.gateway';
|
||||||
@@ -56,6 +58,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||||
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
|
{ provide: CalendarGateway, useClass: GraphqlCalendarGateway },
|
||||||
|
{ provide: PresenceGateway, useClass: GraphqlPresenceGateway },
|
||||||
{ provide: PushDevice, useClass: BrowserPushDevice },
|
{ provide: PushDevice, useClass: BrowserPushDevice },
|
||||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||||
|
|||||||
@@ -551,3 +551,19 @@ a.ts-key {
|
|||||||
color: var(--ts-muted);
|
color: var(--ts-muted);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-presence {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0.375rem 0 0.25rem;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-presence__dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ts-c-green, #2e9e6a);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
input,
|
input,
|
||||||
signal,
|
signal,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
|
untracked,
|
||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||||
@@ -38,6 +39,8 @@ import {
|
|||||||
IssueFacade,
|
IssueFacade,
|
||||||
ProjectFacade,
|
ProjectFacade,
|
||||||
provideIssueFacade,
|
provideIssueFacade,
|
||||||
|
PresenceFacade,
|
||||||
|
providePresenceFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -105,7 +108,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
],
|
],
|
||||||
providers: [provideIssueFacade()],
|
providers: [provideIssueFacade(), providePresenceFacade()],
|
||||||
styleUrl: './issue.page.scss',
|
styleUrl: './issue.page.scss',
|
||||||
template: `
|
template: `
|
||||||
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
||||||
@@ -152,6 +155,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
<ts-blocked [blockers]="issue.blockedBy" />
|
<ts-blocked [blockers]="issue.blockedBy" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (presence.others().length) {
|
||||||
|
<p class="ts-presence" aria-live="polite">
|
||||||
|
<span class="ts-presence__dot" aria-hidden="true"></span>
|
||||||
|
{{ presenceText() }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<div class="ts-issue__title-row">
|
<div class="ts-issue__title-row">
|
||||||
<h1>{{ issue.title }}</h1>
|
<h1>{{ issue.title }}</h1>
|
||||||
<div class="ts-issue__actions">
|
<div class="ts-issue__actions">
|
||||||
@@ -1273,8 +1282,47 @@ export class IssuePage {
|
|||||||
required(p.body),
|
required(p.body),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* Presence: who else is here; I count as editing while a form is open. */
|
||||||
|
protected readonly presence = inject(PresenceFacade);
|
||||||
|
private readonly editingInDrawer = signal(false);
|
||||||
|
protected readonly presenceText = computed(() => {
|
||||||
|
const others = this.presence.others();
|
||||||
|
const editing = others.filter((p) => p.editing).map((p) => p.user.name);
|
||||||
|
const looking = others.filter((p) => !p.editing).map((p) => p.user.name);
|
||||||
|
const list = (names: string[]) =>
|
||||||
|
names.length > 1
|
||||||
|
? `${names.slice(0, -1).join(', ')} y ${names[names.length - 1]}`
|
||||||
|
: names.join('');
|
||||||
|
return [
|
||||||
|
editing.length
|
||||||
|
? `${list(editing)} ${editing.length > 1 ? 'están editando' : 'está editando'}`
|
||||||
|
: '',
|
||||||
|
looking.length
|
||||||
|
? `${list(looking)} ${looking.length > 1 ? 'también lo están viendo' : 'también lo está viendo'}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => this.facade.open(this.issueKey()));
|
effect(() => this.facade.open(this.issueKey()));
|
||||||
|
effect(() => {
|
||||||
|
const id = this.facade.issue()?.id;
|
||||||
|
if (id) untracked(() => this.presence.open(id));
|
||||||
|
});
|
||||||
|
effect(() => {
|
||||||
|
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||||
|
untracked(() => this.presence.setEditing(editing));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** While a form is open in the drawer, I am editing. */
|
||||||
|
private trackEditing(ref: {
|
||||||
|
afterClosed(): { subscribe(f: () => void): unknown };
|
||||||
|
}): void {
|
||||||
|
this.editingInDrawer.set(true);
|
||||||
|
ref.afterClosed().subscribe(() => this.editingInDrawer.set(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected statusLabel = (status: string) =>
|
protected statusLabel = (status: string) =>
|
||||||
@@ -1492,7 +1540,9 @@ export class IssuePage {
|
|||||||
protected openEditComment(comment: Comment): void {
|
protected openEditComment(comment: Comment): void {
|
||||||
this.editingComment = comment.id;
|
this.editingComment = comment.id;
|
||||||
this.commentEditModel.set({ body: comment.body });
|
this.commentEditModel.set({ body: comment.body });
|
||||||
this.drawer.open('Editar comentario', this.commentEditForm());
|
this.trackEditing(
|
||||||
|
this.drawer.open('Editar comentario', this.commentEditForm()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveComment(): void {
|
protected saveComment(): void {
|
||||||
@@ -1515,7 +1565,9 @@ export class IssuePage {
|
|||||||
labels: issue.labels.join(', '),
|
labels: issue.labels.join(', '),
|
||||||
figmaUrl: issue.figmaUrl ?? '',
|
figmaUrl: issue.figmaUrl ?? '',
|
||||||
});
|
});
|
||||||
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem');
|
this.trackEditing(
|
||||||
|
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected saveEdit(): void {
|
protected saveEdit(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user