Compare commits

...

3 Commits

Author SHA1 Message Date
3c7c1f7f0e feat(web): mention people with @ when replying to a comment
The reply box suggests the project's members as the main comment box
does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:28 +02:00
f986f17968 feat: whoever opens or gets a ticket follows it
Like a commenter, whoever opens a ticket and whoever it is assigned to
(also on reassigning) follow it from then on, so they hear of its
priority, due date and sprint changes and find it under "Que sigo".
Existing tickets are left as they are. Docs, Word and PDF updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:28 +02:00
5ae1d05d30 fix(api): accept Pireon webhooks from the password client too
Pireon signs each webhook with the secret of the client that registered
it, and the API has two clients (the web app's and the password
grant's). Only PIREON_WEBHOOK_SECRET was accepted, so a token revoked
for someone signed in with a password went unheard. A delivery now
verifies with PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET;
the same event arriving from both clients changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:27 +02:00
18 changed files with 238 additions and 33 deletions

View File

@@ -16,8 +16,11 @@ EVENTS_BRIDGE_ENABLED=true
# Pireon identity server (https://oauth.pireon.dev/api/documentation)
PIREON_BASE_URL=https://oauth.pireon.dev
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks).
# Pireon signs with the secret of the client that registered it: one per client.
PIREON_WEBHOOK_SECRET=
# The password client's webhook, if it has its own
PIREON_PASSWORD_WEBHOOK_SECRET=
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html

View File

@@ -344,7 +344,7 @@
},
"/api/webhooks/pireon": {
"post": {
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.",
"description": "`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.",
"operationId": "PireonWebhookController_receive",
"parameters": [
{
@@ -358,7 +358,7 @@
{
"name": "X-Webhook-Signature",
"in": "header",
"description": "HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)",
"description": "HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)",
"required": true,
"schema": {
"type": "string"

View File

@@ -23,6 +23,8 @@ export const configSchema = Joi.object({
// Pireon identity server
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// The password client registers its own webhook, signed with its own secret.
PIREON_PASSWORD_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
// authorization_code for "Sign in with Pireon" / Swagger UI…
PIREON_CLIENT_ID: Joi.string().allow('').optional(),

View File

@@ -123,7 +123,7 @@ export interface paths {
put?: never;
/**
* Pireon events (called by Pireon, not by apps)
* @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET is set.
* @description `user.deactivated` closes the user's sessions; `token.revoked` forgets the token. Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.
*/
post: operations["PireonWebhookController_receive"];
delete?: never;
@@ -2825,7 +2825,7 @@ export interface operations {
query?: never;
header: {
"x-webhook-signature": string;
/** @description HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex) */
/** @description HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex) */
"X-Webhook-Signature": string;
};
path?: never;

View File

@@ -45,6 +45,7 @@ services:
EVENTS_BRIDGE_ENABLED: 'true'
PIREON_BASE_URL: https://oauth.pireon.dev
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_PASSWORD_WEBHOOK_SECRET: ${PIREON_PASSWORD_WEBHOOK_SECRET:-}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
# Pireon clients allow one grant type: the one above is authorization_code,

View File

@@ -27,7 +27,7 @@
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien comenta, los sigue solo |
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo |
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |

View File

@@ -144,6 +144,7 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
| `PeopleFollowSaga` | `IssueCreatedEvent`, `IssueAssignedEvent` | `WatchIssueCommand`: quien abre el ticket y quien lo tiene asignado pasan a seguirlo |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
Reglas que cumplen:

View File

@@ -251,7 +251,8 @@ Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por event
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). Quien
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo tiene asignado y quien comenta pasan a seguirlo (`PeopleFollowSaga`,
`CommentersFollowSaga`). Quien
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,

View File

@@ -122,11 +122,16 @@ caduque la caché):
1. Registra `https://<tu-api>/api/webhooks/pireon` con los eventos `user.deactivated` y `token.revoked`
(`POST /api/clients/{client}/webhooks`).
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
3. Si usas un cliente aparte para el acceso con contraseña (`PIREON_PASSWORD_CLIENT_ID`), registra el webhook también
en ese cliente y pon su secret en `PIREON_PASSWORD_WEBHOOK_SECRET`. Pireon firma cada aviso con el secret del
cliente que lo registró, y la revocación de un token solo la avisa el cliente que lo emitió: sin este paso, los
tokens de quien entra con contraseña no se olvidan al revocarlos.
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo, válida con cualquiera de los dos secrets),
marca al usuario como inactivo y borra sus
sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el
endpoint rechaza todas las llamadas.
tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Repetir un aviso (llega uno por
cliente) no tiene efecto. Sin ningún secret, el endpoint rechaza todas las llamadas.
## 5.4 Usuarios en TaskStream

View File

@@ -87,6 +87,7 @@ donde los lee Compose.
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,48 @@
import { UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IdentityService } from '@task-stream/api/identity/application';
import { createHmac } from 'node:crypto';
import { PireonWebhookController } from './pireon-webhook.controller';
describe('PireonWebhookController', () => {
const handled: string[] = [];
const controller = (secrets: Record<string, string>) =>
new PireonWebhookController(
{
handleWebhook: async (event: string) => {
handled.push(event);
},
} as unknown as IdentityService,
{ get: (key: string) => secrets[key] } as unknown as ConfigService,
);
const body = { event: 'token.revoked', data: { token_id: 't-1' } };
const raw = Buffer.from(JSON.stringify(body));
const signed = (secret: string) =>
createHmac('sha256', secret).update(raw).digest('hex');
const request = { rawBody: raw, body } as never;
beforeEach(() => (handled.length = 0));
it("accepts a delivery signed with either Pireon client's secret", async () => {
const both = controller({
PIREON_WEBHOOK_SECRET: 'web-secret',
PIREON_PASSWORD_WEBHOOK_SECRET: 'password-secret',
});
await both.receive(request, signed('web-secret'));
await both.receive(request, `sha256=${signed('password-secret')}`);
expect(handled).toEqual(['token.revoked', 'token.revoked']);
});
it('refuses any other signature, and everything when no secret is set', async () => {
await expect(
controller({ PIREON_WEBHOOK_SECRET: 'web-secret' }).receive(
request,
signed('password-secret'),
),
).rejects.toThrow(UnauthorizedException);
await expect(
controller({}).receive(request, signed('web-secret')),
).rejects.toThrow(/disabled/);
expect(handled).toEqual([]);
});
});

View File

@@ -21,7 +21,13 @@ import { Request } from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Public } from '../auth/auth.decorators';
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
/**
* Receives `user.deactivated` / `token.revoked` from Pireon.
*
* Pireon signs each webhook with the secret of the client that registered
* it, and this API has two (the web app's and the password grant's): a
* delivery is good if it verifies with either.
*/
@ApiTags('Webhooks')
@Controller('webhooks/pireon')
export class PireonWebhookController {
@@ -37,11 +43,12 @@ export class PireonWebhookController {
summary: 'Pireon events (called by Pireon, not by apps)',
description:
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
'Disabled unless PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET is set.',
})
@ApiHeader({
name: 'X-Webhook-Signature',
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
description:
'HMAC-SHA256 of the raw body with the secret of the Pireon client that registered the webhook (hex)',
required: true,
})
@ApiNoContentResponse({ description: 'Processed' })
@@ -63,13 +70,16 @@ export class PireonWebhookController {
await this.identity.handleWebhook(event, data);
}
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
/** X-Webhook-Signature = HMAC-SHA256(raw body, the client's webhook secret). */
private verifySignature(
rawBody: Buffer | undefined,
signature: string | undefined,
): void {
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
if (!secret) {
const secrets = [
this.config.get<string>('PIREON_WEBHOOK_SECRET'),
this.config.get<string>('PIREON_PASSWORD_WEBHOOK_SECRET'),
].filter((secret): secret is string => !!secret);
if (!secrets.length) {
throw new UnauthorizedException(
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
);
@@ -77,11 +87,16 @@ export class PireonWebhookController {
if (!rawBody || !signature)
throw new UnauthorizedException('Missing signature');
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = signature.replace(/^sha256=/, '');
const valid =
received.length === expected.length &&
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
const received = Buffer.from(signature.replace(/^sha256=/, ''));
const valid = secrets.some((secret) => {
const expected = Buffer.from(
createHmac('sha256', secret).update(rawBody).digest('hex'),
);
return (
received.length === expected.length &&
timingSafeEqual(received, expected)
);
});
if (!valid) throw new UnauthorizedException('Invalid signature');
}
}

View File

@@ -0,0 +1,47 @@
import {
IssueAssignedEvent,
IssueCreatedEvent,
IssuePriority,
} from '@task-stream/api/tracker/domain';
import { lastValueFrom, of, toArray } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
import { PeopleFollowSaga } from './people-follow.saga';
describe('PeopleFollowSaga', () => {
const saga = new PeopleFollowSaga();
const created = (assigneeId: string | null) =>
new IssueCreatedEvent('ana', {
id: 'i-1',
projectId: 'p-1',
reporterId: 'ana',
assigneeId,
priority: IssuePriority.MEDIUM,
} as never);
it('makes whoever opens a ticket, and whoever gets it, follow it', async () => {
expect(
await lastValueFrom(saga.created(of(created('bob'))).pipe(toArray())),
).toEqual([
new WatchIssueCommand('ana', 'i-1', 'ana'),
new WatchIssueCommand('bob', 'i-1', 'bob'),
]);
// Opening a ticket for oneself: once.
expect(
await lastValueFrom(saga.created(of(created('ana'))).pipe(toArray())),
).toEqual([new WatchIssueCommand('ana', 'i-1', 'ana')]);
});
it('makes whoever a ticket is assigned to follow it, not when unassigned', async () => {
const commands = await lastValueFrom(
saga
.assigned(
of(
new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null),
new IssueAssignedEvent('ana', 'i-1', 'p-1', null, 'bob'),
),
)
.pipe(toArray()),
);
expect(commands).toEqual([new WatchIssueCommand('bob', 'i-1', 'bob')]);
});
});

View File

@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
import {
IssueAssignedEvent,
IssueCreatedEvent,
} from '@task-stream/api/tracker/domain';
import { filter, mergeMap, Observable } from 'rxjs';
import { WatchIssueCommand } from '../commands/watch-issue.command';
/** Follows a ticket as `userId` would by pressing "Seguir". */
const follow = (userId: string, issueId: string) =>
new WatchIssueCommand(userId, issueId, userId);
/**
* Whoever opens a ticket and whoever it is assigned to follow it from then
* on, as a commenter does: its changes concern them. Unfollowing stays
* theirs to do. (Idempotent: following twice changes nothing; someone who
* is not a member, like the system, is refused and skipped.)
*/
@Injectable()
export class PeopleFollowSaga {
@Saga()
created = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(IssueCreatedEvent),
mergeMap(({ issue }) =>
[...new Set([issue.reporterId, issue.assigneeId])]
.filter((userId): userId is string => !!userId)
.map((userId) => follow(userId, issue.id)),
),
);
@Saga()
assigned = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(IssueAssignedEvent),
filter((event) => !!event.assigneeId),
mergeMap((event) => [
follow(event.assigneeId as string, event.aggregateId),
]),
);
}

View File

@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.saga';
import { CommentersFollowSaga } from './commenters-follow.saga';
import { PeopleFollowSaga } from './people-follow.saga';
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
/** Reactions to events that change other aggregates (run by the events bridge). */
@@ -10,5 +11,6 @@ export const SAGAS = [
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
PeopleFollowSaga,
SprintCarryOverSaga,
];

View File

@@ -608,9 +608,33 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
novalidate
>
<pi-textarea
[placeholder]="'Responde a ' + thread.comment.author.name + '…'"
[placeholder]="
'Responde a ' +
thread.comment.author.name +
'… Escribe @ para mencionar a alguien.'
"
[(value)]="replyBody"
/>
@if (replyMentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (member of replyMentionOptions(); track member.id) {
<button
type="button"
class="ts-mentions__option"
(click)="mentionInReply(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
{{ member.name }}
</button>
}
</div>
}
<div class="ts-reply__actions">
<pi-action-button
color="primary"
@@ -1024,17 +1048,13 @@ export class IssuePage {
protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments;
/** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() => {
const query = mentionQuery(this.commentBody());
if (query === null) return [];
return this.project
.members()
.map((m) => m.user)
.filter(
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
});
protected readonly mentionOptions = computed(() =>
this.membersMatching(this.commentBody()),
);
/** The same, for the answer being written. */
protected readonly replyMentionOptions = computed(() =>
this.membersMatching(this.replyBody()),
);
/** Pull requests first, then the newest. */
protected readonly devLinks = computed(() =>
[...(this.facade.issue()?.devLinks ?? [])].sort(
@@ -1226,6 +1246,23 @@ export class IssuePage {
this.replyingTo.set(null);
}
/** Members whose name fits the `@…` being typed at the end of `text`. */
private membersMatching(text: string) {
const query = mentionQuery(text);
if (query === null) return [];
return this.project
.members()
.map((m) => m.user)
.filter(
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
}
protected mentionInReply(user: { id: string; name: string }): void {
this.replyBody.update((text) => insertMention(text, user));
}
protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user));
}