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:
@@ -6,6 +6,7 @@ import {
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { User, UserFactory } from '@task-stream/api/identity/domain';
|
||||
import { Client, createClient } from 'graphql-ws';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
process.env['POSTGRES_URL'] ??=
|
||||
@@ -17,6 +18,7 @@ process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
|
||||
process.env['GRAPHQL_SCHEMA_FILE'] = '';
|
||||
process.env['OPENAPI_FILE'] = '';
|
||||
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
|
||||
process.env['PIREON_WEBHOOK_SECRET'] = 'e2e-webhook-secret';
|
||||
|
||||
const { AppModule } = require('../src/app/app.module');
|
||||
const { configureApp } = require('../src/app/configure');
|
||||
@@ -30,6 +32,7 @@ const people: Record<string, User> = {
|
||||
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
|
||||
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
|
||||
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
|
||||
'token-dan': person('105', 'Dan (leaving)', 'dan@x.dev'),
|
||||
};
|
||||
const byId = (id: string) =>
|
||||
Object.values(people).find((p) => p.id === id) ?? null;
|
||||
@@ -874,4 +877,288 @@ describe('task-stream (e2e)', () => {
|
||||
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
|
||||
).toBe(404);
|
||||
});
|
||||
|
||||
it('opens public projects to anyone, read-only', async () => {
|
||||
const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
const created = await rest('token-ana', 'POST', '/projects', {
|
||||
key,
|
||||
name: 'Open roadmap',
|
||||
isPublic: true,
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const projectId = created.body['id'] as string;
|
||||
await eventually(
|
||||
() => rest(null, 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const issue = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'FEATURE',
|
||||
title: 'Dark mode',
|
||||
})
|
||||
).body['key'] as string;
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${issue}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/issues/${issue}/comments`, {
|
||||
body: 'Planned for Q4',
|
||||
});
|
||||
|
||||
// REST without a token: project, tickets, comments, statistics, discovery.
|
||||
const anonymous = await eventually(
|
||||
() => rest(null, 'GET', `/issues/${issue}/comments`),
|
||||
(r) => r.body?.['total'] === 1,
|
||||
);
|
||||
expect(anonymous.status).toBe(200);
|
||||
expect((await rest(null, 'GET', `/projects/${key}`)).body).toMatchObject({
|
||||
key,
|
||||
isPublic: true,
|
||||
});
|
||||
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(200);
|
||||
expect((await rest(null, 'GET', `/projects/${key}/analytics`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
const explore = await rest(null, 'GET', '/explore/projects');
|
||||
expect(explore.body.map((p: { key: string }) => p.key)).toContain(key);
|
||||
|
||||
// GraphQL without a token, nested fields included.
|
||||
const viaGraphql = await fetch(`${url}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: `query($k: String!) {
|
||||
publicProjects { key }
|
||||
project(idOrKey: $k) { key isPublic board { status total } stats { total } releases { name } }
|
||||
issues(filter: { projectId: "${projectId}" }) { total items { key comments { total } linkedIssues { key } } }
|
||||
}`,
|
||||
variables: { k: key },
|
||||
}),
|
||||
}).then((r) => r.json());
|
||||
expect(viaGraphql.errors).toBeUndefined();
|
||||
expect(viaGraphql.data.project).toMatchObject({
|
||||
key,
|
||||
isPublic: true,
|
||||
stats: { total: 1 },
|
||||
});
|
||||
expect(viaGraphql.data.issues.items[0]).toMatchObject({
|
||||
key: issue,
|
||||
comments: { total: 1 },
|
||||
});
|
||||
|
||||
// Reading is open, writing is not: anonymous → 401, outsiders → 403.
|
||||
expect(
|
||||
(
|
||||
await rest(null, 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'x',
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await rest('token-eve', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'x',
|
||||
})
|
||||
).status,
|
||||
).toBe(403);
|
||||
expect((await rest('token-eve', 'GET', `/issues/${issue}`)).status).toBe(
|
||||
200,
|
||||
);
|
||||
// A bad token is still rejected, even where none is needed.
|
||||
expect((await rest('bad-token', 'GET', `/projects/${key}`)).status).toBe(
|
||||
401,
|
||||
);
|
||||
|
||||
// Back to private: anonymous readers get 404 again.
|
||||
await rest('token-ana', 'PATCH', `/projects/${key}`, { isPublic: false });
|
||||
await eventually(
|
||||
() => rest(null, 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 404,
|
||||
);
|
||||
expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
|
||||
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
const other = 'O' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
for (const k of [key, other])
|
||||
await rest('token-ana', 'POST', '/projects', { key: k, name: k });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${other}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '102',
|
||||
role: 'ADMIN',
|
||||
});
|
||||
const doomed = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'BUG',
|
||||
title: 'Doomed',
|
||||
})
|
||||
).body['key'] as string;
|
||||
const survivor = (
|
||||
await rest('token-ana', 'POST', `/projects/${other}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Survivor',
|
||||
})
|
||||
).body['key'] as string;
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${survivor}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${doomed}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/issues/${doomed}/comments`, {
|
||||
body: 'bye',
|
||||
});
|
||||
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||
name: 'v1.0.0',
|
||||
});
|
||||
await rest('token-ana', 'POST', `/issues/${survivor}/links`, {
|
||||
type: 'DEPENDS_ON',
|
||||
targetIssue: doomed,
|
||||
});
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${survivor}`),
|
||||
(r) => r.body?.['links']?.length === 1,
|
||||
);
|
||||
|
||||
// Owner only, confirming the key.
|
||||
expect(
|
||||
(await rest('token-bob', 'DELETE', `/projects/${key}?confirm=${key}`))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(
|
||||
(await rest('token-ana', 'DELETE', `/projects/${key}?confirm=NOPE`))
|
||||
.status,
|
||||
).toBe(422);
|
||||
const deleted = await gql(
|
||||
'token-ana',
|
||||
`mutation($p: ID!, $k: String!) { deleteProject(projectId: $p, confirmKey: $k) { version } }`,
|
||||
{
|
||||
p: (await rest('token-ana', 'GET', `/projects/${key}`)).body['id'],
|
||||
k: key.toLowerCase(),
|
||||
},
|
||||
);
|
||||
expect(deleted.deleteProject.version).toBeGreaterThan(1);
|
||||
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 404,
|
||||
);
|
||||
// The saga deletes the tickets and the link from the other project.
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${doomed}`),
|
||||
(r) => r.status === 404,
|
||||
);
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${survivor}`),
|
||||
(r) => r.body?.['links']?.length === 0,
|
||||
);
|
||||
const mine = await rest('token-bob', 'GET', '/projects');
|
||||
expect(mine.body.map((p: { key: string }) => p.key)).not.toContain(key);
|
||||
// The key can be used again.
|
||||
expect(
|
||||
(await rest('token-ana', 'POST', '/projects', { key, name: 'Reborn' }))
|
||||
.status,
|
||||
).toBe(201);
|
||||
});
|
||||
|
||||
it('archiving a release lets its unfinished tickets go (saga)', async () => {
|
||||
const key = 'A' + 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,
|
||||
);
|
||||
const release = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
|
||||
name: 'v2.0.0',
|
||||
})
|
||||
).body['id'] as string;
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/releases/${release}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const open = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Still open',
|
||||
releaseId: release,
|
||||
})
|
||||
).body['key'] as string;
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${open}`),
|
||||
(r) => r.body?.['release']?.id === release,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await rest('token-ana', 'POST', `/releases/${release}/archive`)).status,
|
||||
).toBe(200);
|
||||
const released = await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${open}`),
|
||||
(r) => r.body?.['release'] === null,
|
||||
);
|
||||
expect(released.body['release']).toBeNull();
|
||||
});
|
||||
|
||||
it('unassigns the open tickets of a user Pireon deactivates (saga)', async () => {
|
||||
const key = 'U' + 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,
|
||||
);
|
||||
// Dan signs in once (so the identity context knows him) and joins.
|
||||
expect((await rest('token-dan', 'GET', '/users/me')).status).toBe(200);
|
||||
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||
userId: '105',
|
||||
});
|
||||
const ticket = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Dan has it',
|
||||
assigneeId: '105',
|
||||
})
|
||||
).body['key'] as string;
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${ticket}`),
|
||||
(r) => r.body?.['assignee']?.id === '105',
|
||||
);
|
||||
|
||||
const body = JSON.stringify({
|
||||
event: 'user.deactivated',
|
||||
data: { user_id: 105 },
|
||||
});
|
||||
const webhook = await fetch(`${url}/api/webhooks/pireon`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-webhook-signature': createHmac('sha256', 'e2e-webhook-secret')
|
||||
.update(body)
|
||||
.digest('hex'),
|
||||
},
|
||||
body,
|
||||
});
|
||||
expect(webhook.status).toBe(204);
|
||||
|
||||
const unassigned = await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${ticket}`),
|
||||
(r) => r.status === 200 && r.body['assignee'] === null,
|
||||
);
|
||||
expect(unassigned.body['assignee']).toBeNull();
|
||||
const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
|
||||
expect(
|
||||
history.body.map((h: { actor: { name: string } }) => h.actor.name),
|
||||
).toContain('TaskStream');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user