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,18 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Project } from '@task-stream/web/tracker/domain';
export const explorePageEvents = eventGroup({
source: 'Explore Page',
events: {
opened: type<void>(),
},
});
export const exploreApiEvents = eventGroup({
source: 'Explore API',
events: {
loaded: type<Project[]>(),
loadFailed: type<string>(),
},
});

View File

@@ -30,6 +30,8 @@ export const projectPageEvents = eventGroup({
}>(),
analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(),
visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(),
deleteRequested: type<{ projectId: string; confirmKey: string }>(),
},
});
@@ -48,6 +50,8 @@ export const projectApiEvents = eventGroup({
analyticsFailed: type<string>(),
burnupLoaded: type<ReleaseBurnup>(),
settingsSaved: type<string>(),
/** I deleted the open project. */
deleted: type<string>(),
},
});
@@ -55,6 +59,8 @@ export const projectRealtimeEvents = eventGroup({
source: 'Project Realtime',
events: {
projectChanged: type<Project>(),
/** Someone else deleted the open project. */
projectDeleted: type<Project>(),
activityAdded: type<Activity>(),
releaseChanged: type<Release>(),
},

View File

@@ -0,0 +1,22 @@
import { inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { explorePageEvents } from '../events/explore.events';
import { ExploreStore } from '../stores/explore.store';
/** "Explorar": public projects, for anyone (no sign-in needed). */
@Injectable({ providedIn: 'root' })
export class ExploreFacade {
private readonly store = inject(ExploreStore);
private readonly dispatch = injectDispatch(explorePageEvents);
/** By name. */
readonly projects = this.store.projects;
/** False until the first load: show skeletons, not empty states. */
readonly loaded = this.store.loaded;
readonly loading = this.store.loading;
readonly error = this.store.error;
load(): void {
this.dispatch.opened();
}
}

View File

@@ -13,7 +13,8 @@ import {
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
import { NEVER, of } from 'rxjs';
import { Router } from '@angular/router';
import { NEVER, of, Subject } from 'rxjs';
import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
@@ -35,6 +36,8 @@ function gateways() {
stats: vi.fn(() => NEVER),
addMember: vi.fn(() => of(ok)),
updateSettings: vi.fn(() => of(ok)),
update: vi.fn(() => of(ok)),
delete: vi.fn(() => of(ok)),
analytics: vi.fn(() => of(analytics)),
},
issues: {
@@ -48,7 +51,7 @@ function gateways() {
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
projectChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER),
},
};
}
@@ -86,8 +89,12 @@ function setup() {
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
],
});
const navigate = vi
.spyOn(TestBed.inject(Router), 'navigateByUrl')
.mockResolvedValue(true);
const notifications = TestBed.inject(Notifications);
const host = TestBed.createComponent(Host).componentInstance;
return { ...host, fakes };
return { ...host, fakes, navigate, notifications };
}
describe('BoardFacade', () => {
@@ -152,6 +159,40 @@ describe('ProjectFacade', () => {
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
it('makes the project public and deletes it, then goes home', () => {
const { project, fakes, navigate, notifications } = setup();
project.open('WEB');
project.setPublic(true);
expect(fakes.projects.update).toHaveBeenCalledWith({
projectId: 'p1',
isPublic: true,
});
project.deleteProject('WEB');
expect(fakes.projects.delete).toHaveBeenCalledWith('p1', 'WEB');
expect(project.deleting()).toBe(false);
expect(notifications.success).toHaveBeenLastCalledWith(
'Proyecto WEB eliminado',
);
expect(navigate).toHaveBeenCalledWith('/');
});
it('leaves when someone else deletes the open project', () => {
const live = new Subject<unknown>();
const { project, fakes, navigate, notifications } = setup();
fakes.realtime.projectChanges.mockReturnValue(live);
project.open('WEB');
live.next({ project, deleted: false });
expect(navigate).not.toHaveBeenCalled();
live.next({ project: { key: 'WEB' }, deleted: true });
expect(notifications.success).toHaveBeenCalledWith(
'El proyecto WEB se ha eliminado',
);
expect(navigate).toHaveBeenCalledWith('/');
});
it('searches the directory from two characters', () => {
const { project } = setup();
const seen: string[] = [];

View File

@@ -1,4 +1,4 @@
import { inject, Injectable, Provider } from '@angular/core';
import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
@@ -41,6 +41,9 @@ export class ProjectFacade {
readonly analyticsError = this.store.analyticsError;
/** Burnup of the release asked for with `loadBurnup`. */
readonly burnup = this.store.burnup;
readonly deleting = this.store.deleting;
/** Only the owner can delete the project. */
readonly isOwner = computed(() => this.store.myRole() === ProjectRole.OWNER);
/** Loads the project (by id or key) and follows its changes. */
open(idOrKey: string): void {
@@ -91,6 +94,22 @@ export class ProjectFacade {
if (projectId) this.dispatch.settingsSaveRequested({ projectId, settings });
}
/** Public: anyone (also without signing in) can read it. Owners and admins. */
setPublic(isPublic: boolean): void {
const projectId = this.projectId();
if (projectId)
this.dispatch.visibilityChangeRequested({ projectId, isPublic });
}
/**
* Owner only, typing the key to confirm. Tickets, comments and releases go
* too; on success it navigates home.
*/
deleteProject(confirmKey: string): void {
const projectId = this.projectId();
if (projectId) this.dispatch.deleteRequested({ projectId, confirmKey });
}
loadAnalytics(days = this.store.analyticsDays()): void {
const projectId = this.projectId();
if (projectId) this.dispatch.analyticsRequested({ projectId, days });

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),
}),
),
),