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

@@ -0,0 +1,58 @@
import { inject } from '@angular/core';
import { mapResponse } from '@ngrx/operators';
import { signalStore, withState } from '@ngrx/signals';
import {
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import { Project, ProjectsGateway } from '@task-stream/web/tracker/domain';
import { switchMap } from 'rxjs';
import { exploreApiEvents, explorePageEvents } from '../events/explore.events';
import { describeError } from '../notifications';
interface ExploreState {
projects: Project[];
loaded: boolean;
loading: boolean;
error: string | null;
}
/** Public projects, readable by anyone (also without signing in). */
export const ExploreStore = signalStore(
{ providedIn: 'root' },
withState<ExploreState>({
projects: [],
loaded: false,
loading: false,
error: null,
}),
withReducer(
on(explorePageEvents.opened, () => ({ loading: true, error: null })),
on(exploreApiEvents.loaded, ({ payload }) => ({
projects: payload,
loaded: true,
loading: false,
})),
on(exploreApiEvents.loadFailed, ({ payload }) => ({
loading: false,
error: payload,
})),
),
withEventHandlers(
(_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
load$: events.on(explorePageEvents.opened).pipe(
switchMap(() =>
projects.publicProjects().pipe(
mapResponse({
next: (list) => exploreApiEvents.loaded(list),
error: (error) =>
exploreApiEvents.loadFailed(describeError(error)),
}),
),
),
),
}),
),
);

View File

@@ -24,8 +24,11 @@ import {
UsersGateway,
Workflow,
} from '@task-stream/web/tracker/domain';
import { Router } from '@angular/router';
import {
debounceTime,
exhaustMap,
filter,
map,
merge,
mergeMap,
@@ -56,6 +59,8 @@ interface ProjectState {
burnup: ReleaseBurnup | null;
/** True while settings are being saved (until the project comes back changed). */
savingSettings: boolean;
/** True from asking to delete the project until the answer. */
deleting: boolean;
loading: boolean;
error: string | null;
}
@@ -74,6 +79,7 @@ export const ProjectStore = signalStore(
analyticsError: null,
burnup: null,
savingSettings: false,
deleting: false,
loading: false,
error: null,
}),
@@ -120,10 +126,12 @@ export const ProjectStore = signalStore(
on(projectPageEvents.settingsSaveRequested, () => ({
savingSettings: true,
})),
on(projectPageEvents.deleteRequested, () => ({ deleting: true })),
on(projectApiEvents.deleted, () => ({ deleting: false })),
on(
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
() => ({ savingSettings: false }),
() => ({ savingSettings: false, deleting: false }),
),
on(projectPageEvents.analyticsRequested, ({ payload }) => ({
analyticsDays: payload.days,
@@ -171,6 +179,7 @@ export const ProjectStore = signalStore(
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
users = inject(UsersGateway),
router = inject(Router),
) => {
const loaded$ = events.on(projectApiEvents.loaded);
const reloadReleases = (projectId: string) =>
@@ -218,7 +227,13 @@ export const ProjectStore = signalStore(
merge(
realtime
.projectChanges(payload.id)
.pipe(map((p) => projectRealtimeEvents.projectChanged(p))),
.pipe(
map(({ project, deleted }) =>
deleted
? projectRealtimeEvents.projectDeleted(project)
: projectRealtimeEvents.projectChanged(project),
),
),
realtime
.activity(payload.id)
.pipe(map((a) => projectRealtimeEvents.activityAdded(a))),
@@ -335,6 +350,20 @@ export const ProjectStore = signalStore(
),
),
),
events
.on(projectPageEvents.visibilityChangeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.update(payload),
payload.isPublic
? 'Proyecto público: cualquiera puede verlo'
: 'Proyecto privado: solo para sus miembros',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.releaseCreateRequested)
.pipe(
@@ -372,6 +401,36 @@ export const ProjectStore = signalStore(
),
),
),
delete$: events.on(projectPageEvents.deleteRequested).pipe(
exhaustMap(({ payload }) =>
projects.delete(payload.projectId, payload.confirmKey).pipe(
mapResponse({
next: () =>
projectApiEvents.deleted(
`Proyecto ${store.project()?.key ?? ''} eliminado`,
),
error: (error) =>
projectApiEvents.commandFailed(describeError(error)),
}),
),
),
),
/** Gone (by me or by someone else): back to the home page. */
leave$: merge(
events
.on(projectApiEvents.deleted)
.pipe(map(({ payload }) => payload)),
events.on(projectRealtimeEvents.projectDeleted).pipe(
// My own deletion already said so.
filter(() => !store.deleting()),
map(({ payload }) => `El proyecto ${payload.key} se ha eliminado`),
),
).pipe(
tap((message) => {
notifications.success(message);
void router.navigateByUrl('/');
}),
),
feedback$: merge(
events
.on(projectApiEvents.commandSucceeded)

View File

@@ -88,12 +88,12 @@ export const ProjectsStore = signalStore(
/** One live subscription for the whole session. */
live$: events.on(projectsPageEvents.opened).pipe(
exhaustMap(() => realtime.myProjectsChanges()),
map((project) =>
map(({ project, deleted }) =>
projectsRealtimeEvents.projectChanged({
project,
stillMember: project.members.some(
(m) => m.user.id === auth.user()?.id,
),
stillMember:
!deleted &&
project.members.some((m) => m.user.id === auth.user()?.id),
}),
),
),