feat: public projects, project deletion and sagas

Public projects:
- Project.isPublic (create/update, owners and admins). Anyone can read a
  public project without a token: project, board, tickets, comments,
  history, releases, activity, tracking and statistics, over GraphQL and
  REST. Writes stay for members (401 anonymous, 403 outsiders).
- @OptionalAuth() / @ApiOptionalPireonAuth(): anonymous callers allowed,
  invalid tokens still rejected. ReadAccess lets anonymous viewers read
  public projects only; linked tickets are filtered by visibility.
- publicProjects (GraphQL) and GET /api/explore/projects (REST).
- Web: «Explorar» page, project pages open without a session (read-only
  notice, sign-in hints), public badge, visibility switch in settings,
  no realtime subscriptions without a session.

Project deletion:
- Owner only, confirming the key (deleteProject / DELETE
  /api/projects/{project}?confirm=KEY). The key is freed; the read model
  drops the project and members are notified (web goes home).

Sagas (run by the EventsBridge after projections, durable and ordered):
- ProjectDeletionSaga: project deleted → delete its tickets and releases;
  ticket deleted → purge its comments and drop links from other tickets.
- ReleaseArchivedSaga: unfinished tickets leave an archived release.
- UserDeactivatedSaga: the Pireon webhook records UserDeactivatedEvent in
  the event store (EventRecorder); the tracker unassigns that user's open
  tickets as the system actor.
- Internal idempotent commands; domain refusals are logged and skipped,
  concurrency conflicts retried. Affected streams come from the event store
  (TrackerStreamsRepository).

Tests: domain lifecycle specs, saga specs run like the bridge, facade
tests, e2e for public access, cascade deletion, archived releases and
deactivation webhook. Docs, READMEs, schema.gql/openapi.json and Word/PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 07:04:20 +02:00
parent 0e301ad545
commit e656222b54
164 changed files with 3862 additions and 414 deletions

View File

@@ -10,7 +10,8 @@ Event store en PostgreSQL y el camino de los eventos hasta la lectura.
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final).
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final);
después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno.
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).

View File

@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/infrastructure/src",
"projectType": "library",
"tags": [
"scope:api-shared",
"type:infrastructure"
],
"tags": ["scope:api-shared", "type:infrastructure"],
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
"targets": {}
}

View File

@@ -7,9 +7,20 @@ import {
Type,
} from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core';
import { IEventHandler } from '@nestjs/cqrs';
import { SerializableEvent } from '@task-stream/api/shared/domain';
import {
CommandBus,
ICommand,
IEvent,
IEventHandler,
ISaga,
} from '@nestjs/cqrs';
import {
ConcurrencyError,
DomainError,
SerializableEvent,
} from '@task-stream/api/shared/domain';
import { hostname } from 'node:os';
import { lastValueFrom, of, toArray } from 'rxjs';
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
import {
EVENT_STREAM_OPTIONS,
@@ -20,8 +31,12 @@ import { EventDeserializer } from './deserializers/event.deserializer';
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
/** Metadata written by @EventsHandler(...events) (@nestjs/cqrs). */
/** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
const SAGA_METADATA = '__saga__';
/** Attempts of a saga command that keeps hitting concurrent writes. */
const CONCURRENCY_ATTEMPTS = 5;
/**
* Redis stream → @EventsHandler. Plays the role of the event-store change
@@ -32,6 +47,13 @@ const EVENTS_HANDLER_METADATA = '__eventsHandler__';
* time, so events of an aggregate are projected in order, and the entry is
* acknowledged in Redis only once every handler succeeded (otherwise it
* stays pending and is retried).
*
* It also runs the `@Saga()`s (the in-memory EventBus never sees stored
* events, so it would not): after the handlers, each saga gets the event
* alone (`of(event)`), and the commands it emits are executed one by one,
* awaited. Commands must be idempotent (an event may be handled twice).
* A command refused by the domain (not found, business rule...) is logged
* and skipped, so it cannot block the stream; concurrent writes are retried.
*/
@Injectable()
export class EventsBridge
@@ -40,6 +62,7 @@ export class EventsBridge
private readonly logger = new Logger(EventsBridge.name);
private consumer?: EventStreamConsumer;
private readonly handlers = new Map<Type, IEventHandler[]>();
private readonly sagas: { name: string; saga: ISaga }[] = [];
constructor(
private readonly connections: RedisConnectionFactory,
@@ -47,6 +70,7 @@ export class EventsBridge
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
private readonly eventDeserializer: EventDeserializer,
private readonly discovery: DiscoveryService,
private readonly commandBus: CommandBus,
) {}
async onApplicationBootstrap(): Promise<void> {
@@ -55,6 +79,7 @@ export class EventsBridge
return;
}
this.registerEventHandlers();
this.registerSagas();
this.consumer = new EventStreamConsumer(
this.connections.create('events-bridge'),
{
@@ -66,7 +91,7 @@ export class EventsBridge
);
await this.consumer.start();
this.logger.log(
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s)`,
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s) and ${this.sagas.length} saga(s)`,
);
}
@@ -90,11 +115,66 @@ export class EventsBridge
}
}
private registerSagas(): void {
for (const wrapper of this.discovery.getProviders()) {
if (!wrapper.metatype || !wrapper.instance) continue;
const keys: string[] =
Reflect.getMetadata(SAGA_METADATA, wrapper.metatype) ?? [];
const instance = wrapper.instance as Record<string, ISaga>;
for (const key of keys) {
this.sagas.push({
name: `${wrapper.metatype.name}.${key}`,
saga: instance[key].bind(instance),
});
}
}
}
private async handleEvent(stored: SerializableEvent): Promise<void> {
const { data: event } = this.eventDeserializer.deserialize(stored);
const eventCls = Object.getPrototypeOf(event).constructor as Type;
// Projections first: sagas may read the read model they just updated.
for (const handler of this.handlers.get(eventCls) ?? []) {
await handler.handle(event);
}
for (const { name, saga } of this.sagas) {
const commands = await lastValueFrom(
saga(of(event as IEvent)).pipe(toArray()),
);
for (const command of commands) {
await this.execute(name, event as IEvent, command);
}
}
}
private async execute(
saga: string,
event: IEvent,
command: ICommand,
): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await this.commandBus.execute(command);
return;
} catch (error) {
if (
error instanceof ConcurrencyError &&
attempt < CONCURRENCY_ATTEMPTS
) {
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
continue;
}
if (
error instanceof DomainError &&
!(error instanceof ConcurrencyError)
) {
this.logger.warn(
`${saga}: ${command.constructor.name} after ${event.constructor.name} skipped: ${error.message}`,
);
return;
}
throw error;
}
}
}
}