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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -632,7 +632,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "A project with its members and settings",
|
||||
"tags": [
|
||||
@@ -743,7 +744,117 @@
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Rename or describe a project (owner/admin)",
|
||||
"summary": "Rename, describe or make public/private a project (owner/admin)",
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"description": "Deletes the project and, right after (asynchronously, through a saga), its tickets, comments and releases; links from tickets of other projects are removed and the key becomes free. Cannot be undone from the API. `confirm` must be the project key.",
|
||||
"operationId": "ProjectsController_remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirm",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "The project key, to confirm the deletion",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Someone changed it at the same time: retry",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete a project (owner only)",
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
@@ -981,7 +1092,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Kanban board: one column per status",
|
||||
"tags": [
|
||||
@@ -1062,7 +1174,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Tracking dashboard: counts by status, type, person…",
|
||||
"tags": [
|
||||
@@ -1159,7 +1272,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Statistics of the last `days` days",
|
||||
"tags": [
|
||||
@@ -1278,7 +1392,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Activity feed (newest first)",
|
||||
"tags": [
|
||||
@@ -1970,7 +2085,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Search the tickets of a project",
|
||||
"tags": [
|
||||
@@ -2161,7 +2277,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "A ticket",
|
||||
"tags": [
|
||||
@@ -2944,7 +3061,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Comments of the ticket (oldest first)",
|
||||
"tags": [
|
||||
@@ -3166,7 +3284,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "History of the ticket (newest first)",
|
||||
"tags": [
|
||||
@@ -3545,7 +3664,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Releases of a project",
|
||||
"tags": [
|
||||
@@ -3733,7 +3853,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "A release",
|
||||
"tags": [
|
||||
@@ -3919,7 +4040,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Finished tickets out of the total",
|
||||
"tags": [
|
||||
@@ -3998,7 +4120,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Burnup: tickets in the release and finished ones, day by day",
|
||||
"tags": [
|
||||
@@ -4106,7 +4229,8 @@
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
}
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "Tickets included in the release",
|
||||
"tags": [
|
||||
@@ -4322,6 +4446,33 @@
|
||||
"Releases"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/explore/projects": {
|
||||
"get": {
|
||||
"description": "Their board, tickets, comments, releases, activity and statistics can be read without a token too.",
|
||||
"operationId": "ExploreController_publicProjects",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ProjectDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "Public projects (no token needed)",
|
||||
"tags": [
|
||||
"Explore"
|
||||
],
|
||||
"security": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
@@ -4875,6 +5026,10 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"isPublic": {
|
||||
"type": "boolean",
|
||||
"description": "Readable by anyone, also without a token"
|
||||
},
|
||||
"owner": {
|
||||
"$ref": "#/components/schemas/UserRefDto"
|
||||
},
|
||||
@@ -4905,6 +5060,7 @@
|
||||
"key",
|
||||
"name",
|
||||
"description",
|
||||
"isPublic",
|
||||
"owner",
|
||||
"members",
|
||||
"settings",
|
||||
@@ -4930,6 +5086,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 5000
|
||||
},
|
||||
"isPublic": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4971,6 +5132,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"maxLength": 5000
|
||||
},
|
||||
"isPublic": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -118,6 +118,9 @@ input CreateIssueInput {
|
||||
input CreateProjectInput {
|
||||
description: String
|
||||
|
||||
"""Readable by anyone, also without signing in"""
|
||||
isPublic: Boolean
|
||||
|
||||
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
|
||||
key: String!
|
||||
name: String!
|
||||
@@ -312,6 +315,11 @@ type Mutation {
|
||||
|
||||
"""Author or project admin"""
|
||||
deleteComment(commentId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||
"""
|
||||
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
|
||||
editComment(body: String!, commentId: ID!): CommandResult!
|
||||
|
||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||
@@ -347,6 +355,9 @@ type Project {
|
||||
createdAt: String!
|
||||
description: String
|
||||
id: ID!
|
||||
|
||||
"""Anyone can read it, also without signing in"""
|
||||
isPublic: Boolean!
|
||||
key: String!
|
||||
members: [ProjectMember!]!
|
||||
name: String!
|
||||
@@ -467,10 +478,15 @@ type Query {
|
||||
|
||||
"""Projects where I am a member"""
|
||||
myProjects: [Project!]!
|
||||
|
||||
"""A project of mine, or any public one (no sign-in needed)"""
|
||||
project(
|
||||
"""Project id or key (e.g. "TS")"""
|
||||
idOrKey: String!
|
||||
): Project!
|
||||
|
||||
"""Projects anyone can read, also without signing in"""
|
||||
publicProjects: [Project!]!
|
||||
release(id: ID!): Release!
|
||||
|
||||
"""Planned releases of all my projects, soonest target date first"""
|
||||
@@ -593,6 +609,7 @@ input UpdateIssueInput {
|
||||
|
||||
input UpdateProjectInput {
|
||||
description: String
|
||||
isPublic: Boolean
|
||||
name: String
|
||||
projectId: ID!
|
||||
}
|
||||
|
||||
@@ -2,17 +2,28 @@ import { authGuard } from '@task-stream/web/identity/presenters';
|
||||
import { appRoutes } from './app.routes';
|
||||
|
||||
describe('appRoutes', () => {
|
||||
it('keeps sign-in public and everything else behind the auth guard', () => {
|
||||
it('keeps home behind the auth guard, and public projects and «Explorar» open', () => {
|
||||
const paths = appRoutes.map((route) => route.path);
|
||||
expect(paths).toEqual(expect.arrayContaining(['login', 'auth/callback', '', '**']));
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining(['login', 'auth/callback', '', '**']),
|
||||
);
|
||||
|
||||
const app = appRoutes.find((route) => route.path === '' && route.children);
|
||||
expect(app?.canActivate).toContain(authGuard);
|
||||
expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']);
|
||||
const children = app?.children ?? [];
|
||||
expect(children.map((route) => route.path)).toEqual([
|
||||
'',
|
||||
'explorar',
|
||||
'p/:key',
|
||||
]);
|
||||
expect(children[0].canActivate).toContain(authGuard);
|
||||
expect(children[1].canActivate).toBeUndefined();
|
||||
expect(children[2].canActivate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes every project section', () => {
|
||||
const project = appRoutes.find((route) => route.path === '')?.children?.find((route) => route.path === 'p/:key');
|
||||
const project = appRoutes
|
||||
.find((route) => route.path === '')
|
||||
?.children?.find((route) => route.path === 'p/:key');
|
||||
expect(project?.children?.map((route) => route.path)).toEqual([
|
||||
'',
|
||||
'board',
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Route } from '@angular/router';
|
||||
import {
|
||||
authGuard,
|
||||
identityRoutes,
|
||||
} from '@task-stream/web/identity/presenters';
|
||||
import { identityRoutes } from '@task-stream/web/identity/presenters';
|
||||
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
...identityRoutes,
|
||||
{ path: '', canActivate: [authGuard], children: trackerRoutes },
|
||||
// Not all behind the guard: public projects are readable without a session.
|
||||
{ path: '', children: trackerRoutes },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
NavigationEnd,
|
||||
Router,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { filter, map } from 'rxjs';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
@@ -27,8 +35,20 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
Proyectos
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
shellNav
|
||||
class="ts-nav-link"
|
||||
routerLink="/explorar"
|
||||
routerLinkActive="is-active"
|
||||
>
|
||||
Explorar
|
||||
</a>
|
||||
@if (auth.isAuthenticated()) {
|
||||
<ts-user-menu shellUser />
|
||||
} @else if (auth.settled() && !onLogin()) {
|
||||
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
||||
>Iniciar sesión</a
|
||||
>
|
||||
}
|
||||
<router-outlet />
|
||||
</ts-app-shell>
|
||||
@@ -36,4 +56,13 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
})
|
||||
export class App {
|
||||
protected readonly auth = inject(AuthFacade);
|
||||
private readonly router = inject(Router);
|
||||
/** The login page has its own form: no "Iniciar sesión" button there. */
|
||||
protected readonly onLogin = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map(() => this.router.url.startsWith('/login')),
|
||||
),
|
||||
{ initialValue: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,6 +482,18 @@ h3 {
|
||||
font-weight: 600;
|
||||
background: var(--pi-color-primary-50);
|
||||
}
|
||||
|
||||
// "Iniciar sesión" for visitors of public projects.
|
||||
&--cta {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
background: var(--ts-brand);
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
|
||||
| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
|
||||
@@ -112,6 +112,7 @@ sequenceDiagram
|
||||
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
|
||||
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
|
||||
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
|
||||
- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
|
||||
|
||||
### Lectura: MongoDB
|
||||
|
||||
@@ -128,6 +129,34 @@ Tras actualizar Mongo, el handler publica en Redis pub/sub (`ChangeFeed`). Las s
|
||||
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
|
||||
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
|
||||
|
||||
### Sagas: reacciones entre agregados
|
||||
|
||||
Cuando un evento de un agregado debe cambiar otros agregados, lo hace una saga (`application/sagas`), con el `@Saga()`
|
||||
estándar de `@nestjs/cqrs`: un observable que filtra eventos (`ofType`) y emite comandos. Como el `EventBus` en memoria
|
||||
nunca ve los eventos guardados (su publisher es el event store), las sagas las ejecuta el **`EventsBridge`**: a cada
|
||||
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
|
||||
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
||||
|
||||
| Saga | Evento | Comandos |
|
||||
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
|
||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||
|
||||
Reglas que cumplen:
|
||||
|
||||
- **A quién afecta sale del event store**, no del read model (`TrackerStreamsRepository`, `ReleaseIssuesRepository`),
|
||||
salvo la saga de usuarios, que busca tickets asignados ahora mismo en Mongo.
|
||||
- **Comandos idempotentes**: un evento puede procesarse dos veces, así que cada comando comprueba si aún hay algo que
|
||||
hacer (`WriteAccess.live()` devuelve `null` si el agregado no existe o ya está borrado; desasignar solo si sigue
|
||||
asignado a esa persona…). Son comandos internos: ningún presenter los expone.
|
||||
- **Nada bloquea el stream**: si el dominio rechaza un comando (no encontrado, regla de negocio…), el bridge lo registra
|
||||
y sigue; los conflictos de concurrencia se reintentan hasta 5 veces y, si persisten, el evento queda pendiente.
|
||||
- **Autoría**: los cambios se atribuyen a quien provocó el evento (quien borró el proyecto o archivó la release).
|
||||
- **Entre contextos**: el webhook de Pireon guarda `UserDeactivatedEvent` en el event store (`EventRecorder`, stream
|
||||
`identity-user-<id>`), y la saga del tracker reacciona a él sin que los contextos se llamen entre sí.
|
||||
|
||||
### Consistencia eventual
|
||||
|
||||
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
|
||||
|
||||
@@ -48,6 +48,26 @@ classDiagram
|
||||
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
||||
el proyecto existe.
|
||||
|
||||
### Proyectos públicos
|
||||
|
||||
Con `isPublic` (al crear, o después con `updateProject` / `PATCH /api/projects/{project}`, propietario o admin),
|
||||
**cualquiera puede leer** el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
|
||||
historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
|
||||
miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
|
||||
lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
|
||||
|
||||
`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
|
||||
miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
|
||||
leer esos proyectos.
|
||||
|
||||
### Borrar un proyecto
|
||||
|
||||
Solo el **propietario**, escribiendo la clave del proyecto para confirmar (`deleteProject(projectId, confirmKey)` o
|
||||
`DELETE /api/projects/{project}?confirm=CLAVE`). El proyecto desaparece al momento y, a continuación, una saga borra
|
||||
sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros proyectos tenían hacia los suyos (ver
|
||||
[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
|
||||
store como auditoría, pero desde la API no se puede deshacer.
|
||||
|
||||
### Configuración por proyecto
|
||||
|
||||
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
|
||||
@@ -131,11 +151,11 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
|
||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||
|
||||
| Agregado | Eventos |
|
||||
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` |
|
||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
||||
|
||||
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
||||
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||
@@ -161,19 +181,22 @@ reconstruye el read model (ver [Despliegue](07-despliegue.md)).
|
||||
|
||||
## 3.2 GraphQL
|
||||
|
||||
Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`).
|
||||
Las operaciones requieren `Authorization: Bearer <token de Pireon>` (en subscriptions, en el `connection_init`), salvo
|
||||
las lecturas de proyectos públicos (`project`, `issue`, `issues`, `release`, `activity`, `publicProjects` y sus campos),
|
||||
que funcionan también sin token.
|
||||
|
||||
### Queries
|
||||
|
||||
| Query | Devuelve |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||
| `user(id)` | Un usuario |
|
||||
| `myProjects` | Proyectos de los que soy miembro |
|
||||
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||
| `project(idOrKey)` | Un proyecto con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||
| `publicProjects` | Proyectos públicos (sin token) |
|
||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||
@@ -184,8 +207,8 @@ Todas las operaciones requieren `Authorization: Bearer <token de Pireon>` (en su
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||
@@ -367,9 +390,10 @@ la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/htt
|
||||
|
||||
### Autenticación: siempre a través de Pireon
|
||||
|
||||
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth` y el
|
||||
webhook, exigen `Authorization: Bearer <token de Pireon>` (el `AuthGuard` global, igual que en GraphQL). El documento
|
||||
declara dos esquemas:
|
||||
Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, el
|
||||
webhook, `explore` y las lecturas de proyectos públicos, exigen `Authorization: Bearer <token de Pireon>` (el
|
||||
`AuthGuard` global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
|
||||
un requisito de seguridad vacío (`{}`). Declara dos esquemas:
|
||||
|
||||
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
|
||||
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
|
||||
@@ -386,10 +410,11 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
| Recurso | Rutas |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
|
||||
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
|
||||
|
||||
@@ -114,6 +114,7 @@ Qué gana la página:
|
||||
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
|
||||
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
|
||||
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
|
||||
| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
|
||||
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
|
||||
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
|
||||
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
|
||||
@@ -223,6 +224,18 @@ visualización de la plataforma:
|
||||
la tabla, para que nada dependa del color ni del ratón.
|
||||
- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.
|
||||
|
||||
### Proyectos públicos y visitantes sin sesión
|
||||
|
||||
- Solo la portada (`/`) exige sesión. `/explorar` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
|
||||
cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`).
|
||||
- Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo
|
||||
lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
|
||||
- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
|
||||
cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
|
||||
- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el
|
||||
proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
|
||||
abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
|
||||
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
@@ -230,6 +243,7 @@ visualización de la plataforma:
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
|
||||
| `/explorar` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
|
||||
| `/p/:key/issues/:issueKey` | Ticket |
|
||||
| `/p/:key/releases` | Releases |
|
||||
@@ -239,5 +253,6 @@ visualización de la plataforma:
|
||||
| `/p/:key/activity` | Actividad en vivo |
|
||||
| `/p/:key/members` | Miembros |
|
||||
|
||||
Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de
|
||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
|
||||
los componentes (`withComponentInputBinding`).
|
||||
|
||||
@@ -29,7 +29,11 @@ sequenceDiagram
|
||||
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
|
||||
- Si Pireon marca al usuario como inactivo, el token se rechaza.
|
||||
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
|
||||
tokens y webhook).
|
||||
tokens, webhook y `GET /api/explore/projects`).
|
||||
- `@OptionalAuth()` (o `@ApiOptionalPireonAuth()` en REST) deja pasar sin token: es para las lecturas que permiten los
|
||||
proyectos públicos (proyecto, tablero, tickets, comentarios, releases, actividad, estadísticas). Sin token, el
|
||||
usuario es `undefined` y el visitante es anónimo; un token presente pero inválido sigue dando 401. Las escrituras, las
|
||||
subscriptions y lo «mío» (`myProjects`, `myIssues`, `/api/me/…`) siguen exigiendo sesión.
|
||||
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
|
||||
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
|
||||
|
||||
@@ -120,7 +124,9 @@ caduque la caché):
|
||||
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
|
||||
|
||||
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
|
||||
sesiones cacheadas. Sin `PIREON_WEBHOOK_SECRET`, el endpoint rechaza todas las llamadas.
|
||||
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.
|
||||
|
||||
## 5.4 Usuarios en TaskStream
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -9,7 +9,8 @@ Casos de uso de identidad.
|
||||
|
||||
- `IdentityService`: punto de entrada para los presenters.
|
||||
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
|
||||
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
|
||||
- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand` (al desactivar a un
|
||||
usuario, guarda `UserDeactivatedEvent` en el event store para que el tracker reaccione).
|
||||
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
|
||||
|
||||
Ver [Autenticación](../../../../docs/05-autenticacion.md).
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:application"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:application"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { EventRecorder } from '@task-stream/api/shared/application';
|
||||
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||
import { SessionCache } from '../ports/session-cache.port';
|
||||
import { UserRegistry } from '../ports/user-registry.port';
|
||||
|
||||
@@ -22,6 +24,7 @@ export class HandleIdentityWebhookHandler
|
||||
constructor(
|
||||
private readonly cache: SessionCache,
|
||||
private readonly registry: UserRegistry,
|
||||
private readonly events: EventRecorder,
|
||||
) {}
|
||||
|
||||
async execute({ event, data }: HandleIdentityWebhookCommand): Promise<void> {
|
||||
@@ -50,8 +53,14 @@ export class HandleIdentityWebhookHandler
|
||||
|
||||
private async deactivate(userId: string): Promise<void> {
|
||||
const user = await this.registry.findById(userId);
|
||||
if (!user) return;
|
||||
if (!user?.isActive) return;
|
||||
user.deactivate();
|
||||
await this.registry.upsert(user);
|
||||
// Stored so other contexts react (the tracker unassigns their open tickets).
|
||||
for (const event of user.getUncommittedEvents()) {
|
||||
if (event instanceof UserDeactivatedEvent)
|
||||
await this.events.record(`identity-user-${userId}`, event);
|
||||
}
|
||||
user.uncommit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:domain"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:infrastructure"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ Adaptadores de entrada de identidad.
|
||||
|
||||
## Contenido
|
||||
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@OptionalAuth()` / `@ApiOptionalPireonAuth()` (lecturas de proyectos
|
||||
públicos: sin token, el usuario es `undefined`), `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
|
||||
(subscriptions) y `@ApiPireonAuth()` (esquemas OAuth2 / Bearer de Pireon en OpenAPI).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`), `UsersController` (`/api/users/me`,
|
||||
directorio de Pireon) y `PireonWebhookController` (firma HMAC). Documentados con `@nestjs/swagger`.
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:presenters"
|
||||
],
|
||||
"tags": ["scope:api-identity", "type:presenters"],
|
||||
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,27 @@ import {
|
||||
applyDecorators,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOAuth2 } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiOAuth2, ApiSecurity } from '@nestjs/swagger';
|
||||
import { getAuthRequest } from './auth-request';
|
||||
|
||||
export const IS_PUBLIC = 'identity:isPublic';
|
||||
export const IS_OPTIONAL_AUTH = 'identity:optionalAuth';
|
||||
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
||||
|
||||
/** Skips authentication (health checks, webhooks...). */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
||||
|
||||
/**
|
||||
* Anonymous callers are let through (`@CurrentUser()` is undefined); a token,
|
||||
* if sent, must still be valid. For reads that public projects allow.
|
||||
*/
|
||||
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH, true);
|
||||
|
||||
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
||||
export const RequireAppRoles = (...roles: string[]) =>
|
||||
SetMetadata(REQUIRED_APP_ROLES, roles);
|
||||
|
||||
/** The authenticated caller (User). */
|
||||
/** The authenticated caller (User); undefined on anonymous `@OptionalAuth()` calls. */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
||||
);
|
||||
@@ -39,3 +46,10 @@ export const PIREON_BEARER = 'pireon';
|
||||
*/
|
||||
export const ApiPireonAuth = () =>
|
||||
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
|
||||
|
||||
/**
|
||||
* `@OptionalAuth()` + OpenAPI: adds "no token" to the security requirements
|
||||
* the controller declares with `@ApiPireonAuth()`.
|
||||
*/
|
||||
export const ApiOptionalPireonAuth = () =>
|
||||
applyDecorators(OptionalAuth(), ApiSecurity({}));
|
||||
|
||||
@@ -11,9 +11,16 @@ import {
|
||||
InvalidAccessTokenError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { extractBearerToken, getAuthRequest } from './auth-request';
|
||||
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
|
||||
import {
|
||||
IS_OPTIONAL_AUTH,
|
||||
IS_PUBLIC,
|
||||
REQUIRED_APP_ROLES,
|
||||
} from './auth.decorators';
|
||||
|
||||
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
|
||||
/**
|
||||
* Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless
|
||||
* @Public(); with @OptionalAuth() the token may be missing (but not invalid).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@@ -30,7 +37,13 @@ export class AuthGuard implements CanActivate {
|
||||
// WebSocket subscriptions are authenticated once in connection_init.
|
||||
if (!req.user) {
|
||||
const token = extractBearerToken(req.headers['authorization']);
|
||||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
||||
if (!token) {
|
||||
if (
|
||||
this.reflector.getAllAndOverride<boolean>(IS_OPTIONAL_AUTH, targets)
|
||||
)
|
||||
return true;
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
try {
|
||||
req.user = await this.identity.resolveSession(token);
|
||||
req.accessToken = token;
|
||||
|
||||
@@ -9,7 +9,10 @@ Contrato del event store y reconstrucción de agregados.
|
||||
|
||||
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
||||
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
||||
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store y el rehydrator a todos los contextos.
|
||||
- `EventRecorder`: guarda un evento en un stream que no es de ningún agregado propio (p. ej. un usuario de Pireon), en
|
||||
la siguiente posición, para que otros contextos reaccionen a él.
|
||||
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store, el rehydrator y el recorder a todos
|
||||
los contextos.
|
||||
|
||||
## Comandos
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:application"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:application"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './lib/ports/event-store';
|
||||
export * from './lib/aggregate-rehydrator';
|
||||
export * from './lib/shared.module';
|
||||
export * from './lib/event-recorder';
|
||||
|
||||
35
libs/api/shared/application/src/lib/event-recorder.ts
Normal file
35
libs/api/shared/application/src/lib/event-recorder.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConcurrencyError, DomainEvent } from '@task-stream/api/shared/domain';
|
||||
import { EventStore } from './ports/event-store';
|
||||
|
||||
/**
|
||||
* Appends a domain event to a stream that no aggregate of ours owns (e.g. a
|
||||
* user, owned by Pireon), at the next position. Like any stored event, it
|
||||
* reaches the event handlers and sagas of every context through the events
|
||||
* bridge.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventRecorder {
|
||||
constructor(private readonly eventStore: EventStore) {}
|
||||
|
||||
async record(streamId: string, event: DomainEvent): Promise<void> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const position =
|
||||
(await this.eventStore.getEventsByStreamId(streamId)).length + 1;
|
||||
try {
|
||||
await this.eventStore.persist({
|
||||
streamId,
|
||||
position,
|
||||
type: event.constructor.name,
|
||||
data: JSON.parse(
|
||||
JSON.stringify({ ...event, aggregateVersion: position }),
|
||||
),
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
// Someone appended meanwhile: take the next position.
|
||||
if (!(error instanceof ConcurrencyError) || attempt === 3) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||
import { EventRecorder } from './event-recorder';
|
||||
|
||||
/**
|
||||
* Shared building blocks for every bounded context: the event store (from
|
||||
* the infrastructure module plugged in by the composition root) and the
|
||||
* AggregateRehydrator.
|
||||
* the infrastructure module plugged in by the composition root), the
|
||||
* AggregateRehydrator and the EventRecorder.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
@@ -16,8 +17,8 @@ export class SharedModule {
|
||||
return {
|
||||
module: SharedModule,
|
||||
imports: [CqrsModule, infrastructureModule],
|
||||
providers: [AggregateRehydrator],
|
||||
exports: [infrastructureModule, AggregateRehydrator],
|
||||
providers: [AggregateRehydrator, EventRecorder],
|
||||
exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:domain"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:presenters"
|
||||
],
|
||||
"tags": ["scope:api-shared", "type:presenters"],
|
||||
"// targets": "to see all targets run: nx show project api-shared-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ Casos de uso del tracker (CQRS).
|
||||
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
|
||||
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
|
||||
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
|
||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`);
|
||||
- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban el acceso
|
||||
(`ReadAccess`: miembros, o cualquiera si el proyecto es público; `viewerId` null = anónimo);
|
||||
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
|
||||
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
|
||||
notificaciones en tiempo real y feed de actividad.
|
||||
- `sagas/`: `ProjectDeletionSaga` (cascada al borrar un proyecto y sus tickets), `ReleaseArchivedSaga` (los tickets sin
|
||||
terminar salen de una release archivada) y `UserDeactivatedSaga` (desasigna los tickets abiertos de un usuario que
|
||||
Pireon desactiva). Las ejecuta el `EventsBridge`; sus comandos son internos e idempotentes. Tests en `sagas.spec.ts`.
|
||||
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
|
||||
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
|
||||
- `TrackerApplicationModule.withInfrastructure(...)`.
|
||||
|
||||
@@ -18,6 +18,13 @@ import { CreateReleaseCommandHandler } from './create-release.command-handler';
|
||||
import { UpdateReleaseCommandHandler } from './update-release.command-handler';
|
||||
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
|
||||
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
|
||||
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
|
||||
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
|
||||
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
|
||||
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
|
||||
import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
|
||||
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
|
||||
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
CreateProjectCommandHandler,
|
||||
@@ -40,4 +47,12 @@ export const COMMAND_HANDLERS = [
|
||||
UpdateReleaseCommandHandler,
|
||||
PublishReleaseCommandHandler,
|
||||
ArchiveReleaseCommandHandler,
|
||||
DeleteProjectCommandHandler,
|
||||
// Emitted by sagas only (cascades and reactions to other aggregates).
|
||||
DeleteIssueCommandHandler,
|
||||
DeleteReleaseCommandHandler,
|
||||
PurgeCommentCommandHandler,
|
||||
DropLinksToIssueCommandHandler,
|
||||
DetachIssueFromReleaseCommandHandler,
|
||||
UnassignIssueCommandHandler,
|
||||
];
|
||||
|
||||
@@ -19,6 +19,7 @@ export class CreateProjectCommandHandler
|
||||
key: command.key,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
isPublic: command.isPublic,
|
||||
ownerId: command.actorId,
|
||||
});
|
||||
await this.projectKeys.reserve(project.key, project.id);
|
||||
|
||||
@@ -7,6 +7,7 @@ export class CreateProjectCommand extends Command<CommandResult> {
|
||||
public readonly key: string,
|
||||
public readonly name: string,
|
||||
public readonly description?: string | null,
|
||||
public readonly isPublic = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue } from '@task-stream/api/tracker/domain';
|
||||
import { DeleteIssueCommand } from './delete-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DeleteIssueCommand)
|
||||
export class DeleteIssueCommandHandler
|
||||
implements ICommandHandler<DeleteIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
if (!issue) return;
|
||||
issue.delete(command.actorId);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: deletes a ticket of a deleted project. */
|
||||
export class DeleteIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DeleteProjectCommand } from './delete-project.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** Tickets, comments and releases follow through ProjectDeletionSaga. */
|
||||
@CommandHandler(DeleteProjectCommand)
|
||||
export class DeleteProjectCommandHandler
|
||||
implements ICommandHandler<DeleteProjectCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly projectKeys: ProjectKeysRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteProjectCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.delete(command.actorId, command.confirmKey);
|
||||
await project.commitAndWait();
|
||||
// The key can be used again by a new project.
|
||||
await this.projectKeys.release(project.key, project.id);
|
||||
return ack(project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** The owner deletes a project, typing its key to confirm. */
|
||||
export class DeleteProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly confirmKey: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Release } from '@task-stream/api/tracker/domain';
|
||||
import { DeleteReleaseCommand } from './delete-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DeleteReleaseCommand)
|
||||
export class DeleteReleaseCommandHandler
|
||||
implements ICommandHandler<DeleteReleaseCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteReleaseCommand): Promise<void> {
|
||||
const release = await this.access.live(command.releaseId, Release);
|
||||
if (!release) return;
|
||||
release.delete(command.actorId);
|
||||
await release.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: deletes a release of a deleted project. */
|
||||
export class DeleteReleaseCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DetachIssueFromReleaseCommand } from './detach-issue-from-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DetachIssueFromReleaseCommand)
|
||||
export class DetachIssueFromReleaseCommandHandler
|
||||
implements ICommandHandler<DetachIssueFromReleaseCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DetachIssueFromReleaseCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
if (!issue || issue.releaseId !== command.releaseId) return;
|
||||
const project = await this.access.live(issue.projectId, Project);
|
||||
// Finished tickets stay: they are what the release shipped.
|
||||
if (!project || project.workflow.isFinished(issue.status)) return;
|
||||
issue.setRelease(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: takes an unfinished ticket out of an archived release. */
|
||||
export class DetachIssueFromReleaseCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DropLinksToIssueCommand } from './drop-links-to-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DropLinksToIssueCommand)
|
||||
export class DropLinksToIssueCommandHandler
|
||||
implements ICommandHandler<DropLinksToIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DropLinksToIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone, or about to be (its project was deleted too): nothing to tidy.
|
||||
if (!issue || !(await this.access.live(issue.projectId, Project))) return;
|
||||
issue.unlinkAllTo(command.actorId, command.targetIssueId);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: removes the links of `issueId` that point to a deleted ticket. */
|
||||
export class DropLinksToIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly targetIssueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,10 @@ export * from './update-issue.command';
|
||||
export * from './update-project.command';
|
||||
export * from './update-project-settings.command';
|
||||
export * from './update-release.command';
|
||||
export * from './delete-project.command';
|
||||
export * from './delete-issue.command';
|
||||
export * from './delete-release.command';
|
||||
export * from './purge-comment.command';
|
||||
export * from './drop-links-to-issue.command';
|
||||
export * from './detach-issue-from-release.command';
|
||||
export * from './unassign-issue.command';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Comment } from '@task-stream/api/tracker/domain';
|
||||
import { PurgeCommentCommand } from './purge-comment.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(PurgeCommentCommand)
|
||||
export class PurgeCommentCommandHandler
|
||||
implements ICommandHandler<PurgeCommentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: PurgeCommentCommand): Promise<void> {
|
||||
const comment = await this.access.live(command.commentId, Comment);
|
||||
if (!comment || comment.deleted) return;
|
||||
comment.delete(command.actorId, true);
|
||||
await comment.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: deletes a comment of a deleted ticket. */
|
||||
export class PurgeCommentCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue } from '@task-stream/api/tracker/domain';
|
||||
import { UnassignIssueCommand } from './unassign-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(UnassignIssueCommand)
|
||||
export class UnassignIssueCommandHandler
|
||||
implements ICommandHandler<UnassignIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UnassignIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Someone may have reassigned it meanwhile: only undo our user's assignment.
|
||||
if (!issue || issue.assigneeId !== command.userId) return;
|
||||
issue.assign(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: unassigns a ticket if it is still assigned to `userId` (deactivated in Pireon). */
|
||||
export class UnassignIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly userId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ export class UpdateProjectCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly changes: { name?: string; description?: string | null },
|
||||
public readonly changes: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
|
||||
type Aggregate = VersionedAggregateRoot & {
|
||||
createdAt?: string;
|
||||
deleted?: boolean;
|
||||
};
|
||||
|
||||
/** Loads aggregates from the event store, failing with domain errors. */
|
||||
@Injectable()
|
||||
export class WriteAccess {
|
||||
@@ -78,13 +83,31 @@ export class WriteAccess {
|
||||
}
|
||||
}
|
||||
|
||||
private async load<T extends VersionedAggregateRoot & { createdAt?: string }>(
|
||||
/**
|
||||
* For saga commands, which must be idempotent: the aggregate, or null when
|
||||
* it does not exist or was deleted (nothing left to do).
|
||||
*/
|
||||
async live<T extends Aggregate>(
|
||||
id: string,
|
||||
AggregateCls: Type<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
return await this.load(id, AggregateCls);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async load<T extends Aggregate>(
|
||||
id: string,
|
||||
AggregateCls: Type<T>,
|
||||
): Promise<T> {
|
||||
const aggregate = await this.rehydrator.rehydrate(id, AggregateCls);
|
||||
// The stream exists but belongs to another kind of aggregate.
|
||||
if (!aggregate.createdAt) throw new NotFoundError(AggregateCls.name, id);
|
||||
// The stream exists but belongs to another kind of aggregate, or the
|
||||
// aggregate was deleted: same answer, it is not there.
|
||||
if (!aggregate.createdAt || aggregate.deleted)
|
||||
throw new NotFoundError(AggregateCls.name, id);
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class CommentsService {
|
||||
}
|
||||
|
||||
list(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
issueId: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
ReleaseUpdatedEvent,
|
||||
TrackerEvent,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SYSTEM_ACTOR_ID,
|
||||
SYSTEM_ACTOR_NAME,
|
||||
ProjectSettingsChangedEvent,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -80,12 +82,16 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
? await this.findReleases.findById(releaseId)
|
||||
: null;
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
// The project was deleted (cascade in progress): no feed to write to.
|
||||
if (!project) return;
|
||||
const mentioned = [
|
||||
event.actorId,
|
||||
(event as { userId?: string }).userId,
|
||||
(event as { assigneeId?: string }).assigneeId,
|
||||
];
|
||||
const users = await this.users.refs(mentioned);
|
||||
const actorOf = (id: string) =>
|
||||
id === SYSTEM_ACTOR_ID ? { id, name: SYSTEM_ACTOR_NAME } : users.of(id);
|
||||
|
||||
const activity: ActivityReadModel = {
|
||||
id: `${event.aggregateId}:${versionOf(event)}`,
|
||||
@@ -93,9 +99,9 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
projectId: event.projectId,
|
||||
issueId,
|
||||
issueKey,
|
||||
actor: users.of(event.actorId),
|
||||
actor: actorOf(event.actorId),
|
||||
summary: describeEvent(event, {
|
||||
actor: users.of(event.actorId).name,
|
||||
actor: actorOf(event.actorId).name,
|
||||
user: (id) => users.ofNullable(id)?.name ?? '?',
|
||||
issueKey: issueKey ?? '',
|
||||
projectKey: project?.key ?? '',
|
||||
|
||||
@@ -21,10 +21,16 @@ import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
|
||||
import { ReadModelNotifier } from './read-model-notifier';
|
||||
import { IssueTimelineEventHandler } from './issue-timeline.event-handler';
|
||||
import { ProjectSettingsChangedEventHandler } from './project-settings-changed.event-handler';
|
||||
import { ProjectDeletedEventHandler } from './project-deleted.event-handler';
|
||||
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
|
||||
import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
ActivityEventHandler,
|
||||
ProjectDeletedEventHandler,
|
||||
IssueDeletedEventHandler,
|
||||
ReleaseDeletedEventHandler,
|
||||
CommentAddedEventHandler,
|
||||
CommentDeletedEventHandler,
|
||||
CommentEditedEventHandler,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
|
||||
/** Removes the ticket, its timeline and the links other tickets show to it. */
|
||||
@EventsHandler(IssueDeletedEvent)
|
||||
export class IssueDeletedEventHandler
|
||||
implements IEventHandler<IssueDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueDeletedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.remove(event.aggregateId);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export class ProjectCreatedEventHandler
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
isPublic: project.isPublic ?? false,
|
||||
owner: users.of(project.ownerId),
|
||||
members: project.members.map((m) => ({
|
||||
user: users.of(m.userId),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
|
||||
import { versionOf } from './read-model-notifier';
|
||||
|
||||
/**
|
||||
* Removes the project (and its activity and timeline) from the read model,
|
||||
* and tells its members, whose project lists drop it. Tickets, comments and
|
||||
* releases go as the cascade saga deletes them.
|
||||
*/
|
||||
@EventsHandler(ProjectDeletedEvent)
|
||||
export class ProjectDeletedEventHandler
|
||||
implements IEventHandler<ProjectDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectDeletedEvent) {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
await this.upsertMaterializedProjectRepository.remove(event.projectId);
|
||||
if (!project) return; // Already removed (event handled twice).
|
||||
await this.changeFeed.publishProject(
|
||||
{
|
||||
action: event.constructor.name,
|
||||
version: versionOf(event),
|
||||
actorId: event.actorId,
|
||||
project: { ...project, version: versionOf(event) },
|
||||
},
|
||||
event.memberIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
|
||||
@EventsHandler(ReleaseDeletedEvent)
|
||||
export class ReleaseDeletedEventHandler
|
||||
implements IEventHandler<ReleaseDeletedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseDeletedEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.remove(event.aggregateId);
|
||||
}
|
||||
}
|
||||
@@ -126,15 +126,18 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
findOne(viewerId: string, idOrKey: string): Promise<IssueReadModel> {
|
||||
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
|
||||
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
||||
}
|
||||
|
||||
findMany(viewerId: string, ids: string[]): Promise<IssueReadModel[]> {
|
||||
findMany(viewerId: string | null, ids: string[]): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||
}
|
||||
|
||||
search(viewerId: string, filter: IssueFilter): Promise<Page<IssueReadModel>> {
|
||||
search(
|
||||
viewerId: string | null,
|
||||
filter: IssueFilter,
|
||||
): Promise<Page<IssueReadModel>> {
|
||||
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
|
||||
}
|
||||
|
||||
@@ -147,7 +150,7 @@ export class IssuesService {
|
||||
}
|
||||
|
||||
activity(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
params: {
|
||||
projectId: string;
|
||||
issueId?: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export abstract class FindIssuesRepository {
|
||||
filter: CrossProjectIssueFilter,
|
||||
): Promise<Page<IssueReadModel>>;
|
||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||
/** Every ticket of a project (statistics). */
|
||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||
abstract releaseProgress(
|
||||
|
||||
@@ -8,4 +8,6 @@ export abstract class FindProjectsRepository {
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<ProjectRole | null>;
|
||||
/** Projects anyone can read, by name. */
|
||||
abstract findPublic(): Promise<ProjectReadModel[]>;
|
||||
}
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './user-directory';
|
||||
export * from './release-issues.repository';
|
||||
export * from './upsert-materialized-timeline.repository';
|
||||
export * from './find-timeline.repository';
|
||||
export * from './tracker-streams.repository';
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
export abstract class ProjectKeysRepository {
|
||||
/** Throws BusinessRuleError if the key is already taken. */
|
||||
abstract reserve(key: string, projectId: string): Promise<void>;
|
||||
/** Frees the key of a deleted project (only if still held by it). */
|
||||
abstract release(key: string, projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Write-side lookups over the event store (the source of truth, unlike the
|
||||
* eventually-consistent read model): which streams belong to a project or a
|
||||
* ticket. The cascade saga uses them to delete everything that hangs off a
|
||||
* deleted project.
|
||||
*/
|
||||
export abstract class TrackerStreamsRepository {
|
||||
abstract issueIdsOfProject(projectId: string): Promise<string[]>;
|
||||
abstract releaseIdsOfProject(projectId: string): Promise<string[]>;
|
||||
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
||||
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
||||
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export abstract class UpsertMaterializedIssueRepository {
|
||||
projectId: string,
|
||||
categories: Record<string, StatusCategory>,
|
||||
): Promise<void>;
|
||||
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
|
||||
abstract remove(issueId: string): Promise<void>;
|
||||
/** Keeps the denormalised release name of every issue in sync. */
|
||||
abstract renameRelease(releaseId: string, name: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,4 +6,6 @@ export abstract class UpsertMaterializedProjectRepository {
|
||||
project: Pick<ProjectReadModel, 'id' | 'version'> &
|
||||
Partial<ProjectReadModel>,
|
||||
): Promise<void>;
|
||||
/** The project was deleted: its view, activity and timeline go. */
|
||||
abstract remove(projectId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ export abstract class UpsertMaterializedReleaseRepository {
|
||||
release: Pick<ReleaseReadModel, 'id' | 'version'> &
|
||||
Partial<ReleaseReadModel>,
|
||||
): Promise<void>;
|
||||
abstract remove(releaseId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AddProjectMemberCommand } from './commands/add-project-member.command';
|
||||
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateProjectCommand } from './commands/create-project.command';
|
||||
import { DeleteProjectCommand } from './commands/delete-project.command';
|
||||
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
|
||||
import { UpdateProjectCommand } from './commands/update-project.command';
|
||||
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
|
||||
@@ -20,6 +21,7 @@ import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
|
||||
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
|
||||
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
|
||||
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
|
||||
import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
|
||||
import { GetProjectQuery } from './queries/get-project.query';
|
||||
import { GetReleasesQuery } from './queries/get-releases.query';
|
||||
|
||||
@@ -32,7 +34,12 @@ export class ProjectsService {
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
input: { key: string; name: string; description?: string | null },
|
||||
input: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
},
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateProjectCommand(
|
||||
@@ -40,6 +47,7 @@ export class ProjectsService {
|
||||
input.key,
|
||||
input.name,
|
||||
input.description,
|
||||
input.isPublic ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -47,13 +55,32 @@ export class ProjectsService {
|
||||
update(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
changes: { name?: string; description?: string | null },
|
||||
changes: { name?: string; description?: string | null; isPublic?: boolean },
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateProjectCommand(actorId, projectId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner only, typing the key to confirm. Tickets, comments and releases
|
||||
* are deleted right after by ProjectDeletionSaga; the key is freed.
|
||||
*/
|
||||
delete(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
confirmKey: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteProjectCommand(actorId, projectId, confirmKey),
|
||||
);
|
||||
}
|
||||
|
||||
/** Projects anyone can read, also without signing in. */
|
||||
findPublic(): Promise<ProjectReadModel[]> {
|
||||
return this.queryBus.execute(new GetPublicProjectsQuery());
|
||||
}
|
||||
|
||||
/** Statuses, transitions, ticket types and areas (owners and admins). */
|
||||
updateSettings(
|
||||
actorId: string,
|
||||
@@ -109,16 +136,19 @@ export class ProjectsService {
|
||||
return this.queryBus.execute(new GetMyProjectsQuery(viewerId));
|
||||
}
|
||||
|
||||
findOne(viewerId: string, idOrKey: string): Promise<ProjectReadModel> {
|
||||
findOne(viewerId: string | null, idOrKey: string): Promise<ProjectReadModel> {
|
||||
return this.queryBus.execute(new GetProjectQuery(viewerId, idOrKey));
|
||||
}
|
||||
|
||||
stats(viewerId: string, projectId: string): Promise<ProjectStatsReadModel> {
|
||||
stats(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
): Promise<ProjectStatsReadModel> {
|
||||
return this.queryBus.execute(new GetProjectStatsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
board(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
|
||||
perColumn?: number,
|
||||
): Promise<BoardColumn[]> {
|
||||
@@ -127,13 +157,16 @@ export class ProjectsService {
|
||||
);
|
||||
}
|
||||
|
||||
releases(viewerId: string, projectId: string): Promise<ReleaseReadModel[]> {
|
||||
releases(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
): Promise<ReleaseReadModel[]> {
|
||||
return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
|
||||
analytics(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
days: number,
|
||||
): Promise<ProjectAnalyticsReadModel> {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Query } from '@nestjs/cqrs';
|
||||
|
||||
export class CanViewProjectQuery extends Query<boolean> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetActivityQuery extends Query<ActivityReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
public readonly issueId?: string,
|
||||
public readonly limit = 50,
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface BoardColumn {
|
||||
/** Kanban board: one column per status of the project (cancelled ones aside). */
|
||||
export class GetBoardQuery extends Query<BoardColumn[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
|
||||
public readonly perColumn = 50,
|
||||
) {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Page } from '../ports/find-issues.repository';
|
||||
|
||||
export class GetCommentsQuery extends Query<Page<CommentReadModel>> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly issueId: string,
|
||||
public readonly limit = 50,
|
||||
public readonly offset = 0,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetIssueQuery extends Query<IssueReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
/** Issue id or key ("TS-42"). */
|
||||
public readonly idOrKey: string,
|
||||
) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
import { GetIssuesByIdsQuery } from './get-issues-by-ids.query';
|
||||
|
||||
@QueryHandler(GetIssuesByIdsQuery)
|
||||
@@ -10,14 +10,19 @@ export class GetIssuesByIdsQueryHandler
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetIssuesByIdsQuery): Promise<IssueReadModel[]> {
|
||||
const issues = await this.findIssues.findByIds([...new Set(query.ids)]);
|
||||
const visible = new Set(
|
||||
(await this.findProjects.findByMember(query.viewerId)).map((p) => p.id),
|
||||
// Linked tickets may sit in other projects: keep the ones the viewer can read.
|
||||
const visible = new Map<string, boolean>();
|
||||
for (const projectId of new Set(issues.map((issue) => issue.projectId))) {
|
||||
visible.set(
|
||||
projectId,
|
||||
await this.access.canView(projectId, query.viewerId),
|
||||
);
|
||||
return issues.filter((issue) => visible.has(issue.projectId));
|
||||
}
|
||||
return issues.filter((issue) => visible.get(issue.projectId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetIssuesByIdsQuery extends Query<IssueReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly ids: string[],
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -4,7 +4,8 @@ import { ProjectAnalyticsReadModel } from '@task-stream/api/tracker/domain';
|
||||
/** Statistics of a project over the last `days` days. */
|
||||
export class GetProjectAnalyticsQuery extends Query<ProjectAnalyticsReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
public readonly days: number,
|
||||
) {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ProjectStatsReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetProjectStatsQuery extends Query<ProjectStatsReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetProjectQuery extends Query<ProjectReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
/** Project id or key ("TS"). */
|
||||
public readonly idOrKey: string,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { GetPublicProjectsQuery } from './get-public-projects.query';
|
||||
|
||||
@QueryHandler(GetPublicProjectsQuery)
|
||||
export class GetPublicProjectsQueryHandler
|
||||
implements IQueryHandler<GetPublicProjectsQuery, ProjectReadModel[]>
|
||||
{
|
||||
constructor(private readonly findProjects: FindProjectsRepository) {}
|
||||
|
||||
execute(): Promise<ProjectReadModel[]> {
|
||||
return this.findProjects.findPublic();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Projects anyone can read (no sign-in needed). */
|
||||
export class GetPublicProjectsQuery extends Query<ProjectReadModel[]> {}
|
||||
@@ -4,7 +4,8 @@ import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
|
||||
/** Scope vs finished tickets of a release, day by day. */
|
||||
export class GetReleaseBurnupQuery extends Query<ReleaseBurnupReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ReleaseProgressReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetReleaseProgressQuery extends Query<ReleaseProgressReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetReleaseQuery extends Query<ReleaseReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly releaseId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export class GetReleasesQuery extends Query<ReleaseReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from './get-releases.query';
|
||||
export * from './get-upcoming-releases.query';
|
||||
export * from './search-issues.query';
|
||||
export * from './search-my-issues.query';
|
||||
export * from './get-public-projects.query';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GetPublicProjectsQueryHandler } from './get-public-projects.query-handler';
|
||||
import { CanViewProjectQueryHandler } from './can-view-project.query-handler';
|
||||
import { GetActivityQueryHandler } from './get-activity.query-handler';
|
||||
import { GetBoardQueryHandler } from './get-board.query-handler';
|
||||
@@ -17,6 +18,7 @@ import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
GetPublicProjectsQueryHandler,
|
||||
CanViewProjectQueryHandler,
|
||||
GetActivityQueryHandler,
|
||||
GetBoardQueryHandler,
|
||||
|
||||
@@ -3,23 +3,38 @@ import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
/** Authorisation for the read side, based on the projected memberships. */
|
||||
/**
|
||||
* Authorisation for the read side, based on the projected memberships:
|
||||
* members read their projects; anyone (also anonymous, `viewerId` null)
|
||||
* reads public ones.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReadAccess {
|
||||
constructor(private readonly findProjects: FindProjectsRepository) {}
|
||||
|
||||
/** The viewer's role, or null when they read a public project as an outsider. */
|
||||
async assertCanView(
|
||||
projectId: string,
|
||||
viewerId: string,
|
||||
): Promise<ProjectRole> {
|
||||
const role = await this.findProjects.findRole(projectId, viewerId);
|
||||
viewerId: string | null,
|
||||
): Promise<ProjectRole | null> {
|
||||
const role = viewerId
|
||||
? await this.findProjects.findRole(projectId, viewerId)
|
||||
: null;
|
||||
if (role) return role;
|
||||
const project = await this.findProjects.findById(projectId);
|
||||
// Same answer for "doesn't exist" and "not yours": don't leak projects.
|
||||
if (!role) throw new NotFoundError('Project', projectId);
|
||||
return role;
|
||||
if (!project?.isPublic) throw new NotFoundError('Project', projectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
async canView(projectId: string, viewerId: string): Promise<boolean> {
|
||||
return (await this.findProjects.findRole(projectId, viewerId)) !== null;
|
||||
async canView(projectId: string, viewerId: string | null): Promise<boolean> {
|
||||
try {
|
||||
await this.assertCanView(projectId, viewerId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
assertFound<T>(value: T | null, entity: string, id: string): T {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { IssueFilter, Page } from '../ports/find-issues.repository';
|
||||
|
||||
export class SearchIssuesQuery extends Query<Page<IssueReadModel>> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly filter: IssueFilter,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -70,7 +70,10 @@ export class ReleasesService {
|
||||
);
|
||||
}
|
||||
|
||||
findOne(viewerId: string, releaseId: string): Promise<ReleaseReadModel> {
|
||||
findOne(
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
): Promise<ReleaseReadModel> {
|
||||
return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId));
|
||||
}
|
||||
|
||||
@@ -80,7 +83,7 @@ export class ReleasesService {
|
||||
}
|
||||
|
||||
progress(
|
||||
viewerId: string,
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
): Promise<ReleaseProgressReadModel> {
|
||||
return this.queryBus.execute(
|
||||
@@ -89,7 +92,10 @@ export class ReleasesService {
|
||||
}
|
||||
|
||||
/** Scope vs finished tickets of the release, day by day. */
|
||||
burnup(viewerId: string, releaseId: string): Promise<ReleaseBurnupReadModel> {
|
||||
burnup(
|
||||
viewerId: string | null,
|
||||
releaseId: string,
|
||||
): Promise<ReleaseBurnupReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetReleaseBurnupQuery(viewerId, releaseId),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueDeletedEvent,
|
||||
ProjectDeletedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
||||
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
||||
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
|
||||
/**
|
||||
* Deleting a project cascades, one aggregate per command (each stream ends
|
||||
* with its own "deleted" event):
|
||||
*
|
||||
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
||||
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
||||
* of other projects that pointed to it)
|
||||
*
|
||||
* Who to delete comes from the event store, not the read model. The changes
|
||||
* are attributed to whoever deleted the project.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProjectDeletionSaga {
|
||||
constructor(private readonly streams: TrackerStreamsRepository) {}
|
||||
|
||||
@Saga()
|
||||
projectDeleted = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(ProjectDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.contentsOf(event.projectId)).pipe(
|
||||
mergeMap(({ issueIds, releaseIds }) => [
|
||||
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
|
||||
...releaseIds.map(
|
||||
(id) => new DeleteReleaseCommand(event.actorId, id),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@Saga()
|
||||
issueDeleted = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(IssueDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.dependentsOf(event.aggregateId)).pipe(
|
||||
mergeMap(({ commentIds, linkingIds }) => [
|
||||
...commentIds.map(
|
||||
(id) => new PurgeCommentCommand(event.actorId, id),
|
||||
),
|
||||
...linkingIds.map(
|
||||
(id) =>
|
||||
new DropLinksToIssueCommand(
|
||||
event.actorId,
|
||||
id,
|
||||
event.aggregateId,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
private async contentsOf(projectId: string) {
|
||||
const [issueIds, releaseIds] = await Promise.all([
|
||||
this.streams.issueIdsOfProject(projectId),
|
||||
this.streams.releaseIdsOfProject(projectId),
|
||||
]);
|
||||
return { issueIds, releaseIds };
|
||||
}
|
||||
|
||||
private async dependentsOf(issueId: string) {
|
||||
const [commentIds, linkingIds] = await Promise.all([
|
||||
this.streams.commentIdsOfIssue(issueId),
|
||||
this.streams.issueIdsLinkingTo(issueId),
|
||||
]);
|
||||
return {
|
||||
commentIds,
|
||||
linkingIds: linkingIds.filter((id) => id !== issueId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import { ReleaseArchivedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { DetachIssueFromReleaseCommand } from '../commands/detach-issue-from-release.command';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
|
||||
/**
|
||||
* An archived release no longer accepts changes, so its unfinished tickets
|
||||
* would be stuck in it: they leave it (back to "no release"), attributed to
|
||||
* whoever archived it. Finished tickets stay, as the record of what shipped.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReleaseArchivedSaga {
|
||||
constructor(private readonly releaseIssues: ReleaseIssuesRepository) {}
|
||||
|
||||
@Saga()
|
||||
releaseArchived = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(ReleaseArchivedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.releaseIssues.findIssueIds(event.aggregateId)).pipe(
|
||||
mergeMap((issueIds) =>
|
||||
issueIds.map(
|
||||
(issueId) =>
|
||||
new DetachIssueFromReleaseCommand(
|
||||
event.actorId,
|
||||
issueId,
|
||||
event.aggregateId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
237
libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
Normal file
237
libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CqrsModule,
|
||||
EventBus,
|
||||
ICommand,
|
||||
ISaga,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
AggregateRehydrator,
|
||||
EventStore,
|
||||
} from '@task-stream/api/shared/application';
|
||||
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
IssueLinkType,
|
||||
ProjectRole,
|
||||
Release,
|
||||
TRACKER_FACTORIES,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { lastValueFrom, of, toArray } from 'rxjs';
|
||||
import {
|
||||
FakeUserDirectory,
|
||||
InMemoryEventStore,
|
||||
InMemoryEventStorePublisher,
|
||||
InMemoryIssueNumbers,
|
||||
InMemoryProjectKeys,
|
||||
InMemoryReleaseIssues,
|
||||
InMemoryReleaseNames,
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { AddCommentCommand } from '../commands/add-comment.command';
|
||||
import { AddProjectMemberCommand } from '../commands/add-project-member.command';
|
||||
import { ArchiveReleaseCommand } from '../commands/archive-release.command';
|
||||
import { ChangeIssueStatusCommand } from '../commands/change-issue-status.command';
|
||||
import { COMMAND_HANDLERS } from '../commands/command-handlers';
|
||||
import { CommandResult } from '../commands/command-result';
|
||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||
import { CreateProjectCommand } from '../commands/create-project.command';
|
||||
import { CreateReleaseCommand } from '../commands/create-release.command';
|
||||
import { DeleteProjectCommand } from '../commands/delete-project.command';
|
||||
import { LinkIssuesCommand } from '../commands/link-issues.command';
|
||||
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const users = new FakeUserDirectory().add('owner').add('dev').add('qa');
|
||||
// Read model double for the user saga: open tickets are tracked by the test.
|
||||
const openAssigned = new Map<string, string[]>();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...TRACKER_FACTORIES,
|
||||
ProjectDeletionSaga,
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
WriteAccess,
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: UserDirectory, useValue: users },
|
||||
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
|
||||
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
|
||||
{ provide: ReleaseNamesRepository, useClass: InMemoryReleaseNames },
|
||||
{
|
||||
provide: ReleaseIssuesRepository,
|
||||
useValue: new InMemoryReleaseIssues(store),
|
||||
},
|
||||
{
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
findOpenAssignedTo: async (userId: string) =>
|
||||
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
InMemoryEventStorePublisher.install(moduleRef.get(EventBus), store);
|
||||
|
||||
const commandBus = moduleRef.get(CommandBus);
|
||||
const run = (command: object): Promise<CommandResult> =>
|
||||
commandBus.execute(command as never);
|
||||
const sagas: ISaga[] = [
|
||||
...Object.values(moduleRef.get(ProjectDeletionSaga)),
|
||||
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
|
||||
...Object.values(moduleRef.get(UserDeactivatedSaga)),
|
||||
].filter((value): value is ISaga => typeof value === 'function');
|
||||
|
||||
/** What the events bridge does: every stored event through every saga, until quiet. */
|
||||
let cursor = 0;
|
||||
const react = async (event: object) => {
|
||||
for (const saga of sagas) {
|
||||
const commands = await lastValueFrom(saga(of(event)).pipe(toArray()));
|
||||
for (const command of commands)
|
||||
await commandBus.execute(command as ICommand);
|
||||
}
|
||||
};
|
||||
const drain = async () => {
|
||||
while (cursor < store.events.length) {
|
||||
const stored = store.events[cursor++];
|
||||
const [event] = (await store.getEventsByStreamId(stored.streamId))
|
||||
.filter((e) => e.position === stored.position)
|
||||
.map((e) => e.data);
|
||||
await react(event);
|
||||
}
|
||||
};
|
||||
|
||||
const access = moduleRef.get(WriteAccess);
|
||||
const rehydrator = moduleRef.get(AggregateRehydrator);
|
||||
return { run, drain, react, access, rehydrator, openAssigned };
|
||||
}
|
||||
|
||||
const ticket = (projectId: string, title: string) =>
|
||||
new CreateIssueCommand('owner', projectId, 'TASK', title, {});
|
||||
|
||||
describe('sagas', () => {
|
||||
it('deleting a project cascades to its tickets, comments, releases and links', async () => {
|
||||
const { run, drain, access, rehydrator } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
const api = (await run(new CreateProjectCommand('owner', 'API', 'Api'))).id;
|
||||
const a = (await run(ticket(web, 'A'))).id;
|
||||
const b = (await run(ticket(web, 'B'))).id;
|
||||
const c = (await run(ticket(api, 'C'))).id;
|
||||
const comments = [
|
||||
(await run(new AddCommentCommand('owner', a, 'one'))).id,
|
||||
(await run(new AddCommentCommand('owner', a, 'two'))).id,
|
||||
];
|
||||
await run(new LinkIssuesCommand('owner', a, IssueLinkType.BLOCKS, b));
|
||||
await run(new LinkIssuesCommand('owner', c, IssueLinkType.DEPENDS_ON, a));
|
||||
const release = (
|
||||
await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
|
||||
).id;
|
||||
|
||||
await expect(
|
||||
run(new DeleteProjectCommand('dev', web, 'WEB')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(new DeleteProjectCommand('owner', web, 'web'));
|
||||
await drain();
|
||||
|
||||
for (const [id, Cls] of [
|
||||
[a, Issue],
|
||||
[b, Issue],
|
||||
[release, Release],
|
||||
[comments[0], Comment],
|
||||
[comments[1], Comment],
|
||||
] as const) {
|
||||
expect(await access.live(id, Cls as never)).toBeNull();
|
||||
}
|
||||
// The ticket of the other project no longer points to the deleted one.
|
||||
expect((await rehydrator.rehydrate(c, Issue)).links).toEqual([]);
|
||||
// Commands on the deleted project fail as if it never existed.
|
||||
await expect(run(ticket(web, 'late'))).rejects.toThrow(NotFoundError);
|
||||
// And its key is free again.
|
||||
await expect(
|
||||
run(new CreateProjectCommand('owner', 'WEB', 'Web again')),
|
||||
).resolves.toMatchObject({ key: 'WEB' });
|
||||
});
|
||||
|
||||
it('only the owner deletes a project', async () => {
|
||||
const { run } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
await expect(
|
||||
run(new DeleteProjectCommand('owner', web, 'NOPE')),
|
||||
).rejects.toThrow(/WEB/);
|
||||
await run(
|
||||
new AddProjectMemberCommand('owner', web, 'dev', ProjectRole.ADMIN),
|
||||
);
|
||||
await expect(
|
||||
run(new DeleteProjectCommand('dev', web, 'WEB')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('archiving a release lets its unfinished tickets go', async () => {
|
||||
const { run, drain, rehydrator } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
const release = (
|
||||
await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
|
||||
).id;
|
||||
const open = (
|
||||
await run(
|
||||
new CreateIssueCommand('owner', web, 'TASK', 'open', {}, null, release),
|
||||
)
|
||||
).id;
|
||||
const done = (
|
||||
await run(
|
||||
new CreateIssueCommand('owner', web, 'TASK', 'done', {}, null, release),
|
||||
)
|
||||
).id;
|
||||
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
|
||||
await run(new ChangeIssueStatusCommand('owner', done, status));
|
||||
|
||||
await run(new ArchiveReleaseCommand('owner', release));
|
||||
await drain();
|
||||
|
||||
expect((await rehydrator.rehydrate(open, Issue)).releaseId).toBeNull();
|
||||
expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release);
|
||||
});
|
||||
|
||||
it('a user deactivated in Pireon leaves their open tickets unassigned', async () => {
|
||||
const { run, react, rehydrator, openAssigned } = await setup();
|
||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||
for (const user of ['dev', 'qa'])
|
||||
await run(
|
||||
new AddProjectMemberCommand('owner', web, user, ProjectRole.MEMBER),
|
||||
);
|
||||
const mine = (await run(ticket(web, 'mine'))).id;
|
||||
const moved = (await run(ticket(web, 'moved'))).id;
|
||||
await run(new AssignIssueCommand('owner', mine, 'dev'));
|
||||
// The read model still lists it as dev's, but it was reassigned meanwhile.
|
||||
await run(new AssignIssueCommand('owner', moved, 'qa'));
|
||||
openAssigned.set('dev', [mine, moved]);
|
||||
|
||||
await react(new UserDeactivatedEvent('dev'));
|
||||
|
||||
const unassigned = await rehydrator.rehydrate(mine, Issue);
|
||||
expect(unassigned.assigneeId).toBeNull();
|
||||
expect((await rehydrator.rehydrate(moved, Issue)).assigneeId).toBe('qa');
|
||||
});
|
||||
});
|
||||
10
libs/api/tracker/application/src/lib/sagas/sagas.ts
Normal file
10
libs/api/tracker/application/src/lib/sagas/sagas.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ProjectDeletionSaga } from './project-deletion.saga';
|
||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||
|
||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||
export const SAGAS = [
|
||||
ProjectDeletionSaga,
|
||||
ReleaseArchivedSaga,
|
||||
UserDeactivatedSaga,
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
|
||||
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { UnassignIssueCommand } from '../commands/unassign-issue.command';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
|
||||
/**
|
||||
* Pireon deactivated a user (identity context, `user.deactivated` webhook):
|
||||
* their open tickets in every project become unassigned, so nobody waits on
|
||||
* someone who is gone. Done by the system actor ("TaskStream").
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserDeactivatedSaga {
|
||||
constructor(private readonly findIssues: FindIssuesRepository) {}
|
||||
|
||||
@Saga()
|
||||
userDeactivated = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(UserDeactivatedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.findIssues.findOpenAssignedTo(event.userId)).pipe(
|
||||
mergeMap((issues) =>
|
||||
issues.map(
|
||||
(issue) =>
|
||||
new UnassignIssueCommand(
|
||||
SYSTEM_ACTOR_ID,
|
||||
issue.id,
|
||||
event.userId,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ProjectsService } from './projects.service';
|
||||
import { QUERY_HANDLERS } from './queries/query-handlers';
|
||||
import { ReadAccess } from './queries/read-access';
|
||||
import { ReleasesService } from './releases.service';
|
||||
import { SAGAS } from './sagas/sagas';
|
||||
import { WorkflowsService } from './workflows.service';
|
||||
|
||||
const SERVICES = [
|
||||
@@ -41,6 +42,7 @@ const SERVICES = [
|
||||
...COMMAND_HANDLERS,
|
||||
...QUERY_HANDLERS,
|
||||
...EVENT_HANDLERS,
|
||||
...SAGAS,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { IssueNumbersRepository } from '../lib/ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../lib/ports/project-keys.repository';
|
||||
import { ReleaseIssuesRepository } from '../lib/ports/release-issues.repository';
|
||||
import { ReleaseNamesRepository } from '../lib/ports/release-names.repository';
|
||||
import { TrackerStreamsRepository } from '../lib/ports/tracker-streams.repository';
|
||||
import {
|
||||
DirectoryUser,
|
||||
UserDirectory,
|
||||
@@ -128,6 +129,9 @@ export class InMemoryProjectKeys extends ProjectKeysRepository {
|
||||
throw new BusinessRuleError(`Project key ${key} is already taken`);
|
||||
this.keys.add(key);
|
||||
}
|
||||
async release(key: string) {
|
||||
this.keys.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryIssueNumbers extends IssueNumbersRepository {
|
||||
@@ -189,3 +193,54 @@ export class InMemoryReleaseIssues extends ReleaseIssuesRepository {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** Same lookups as the Postgres adapter, over the in-memory events. */
|
||||
export class InMemoryTrackerStreams extends TrackerStreamsRepository {
|
||||
constructor(private readonly store: InMemoryEventStore) {
|
||||
super();
|
||||
}
|
||||
|
||||
private streams(
|
||||
type: string,
|
||||
matches: (data: Record<string, unknown>) => boolean,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
this.store.events
|
||||
.filter(
|
||||
(e) =>
|
||||
e.type === type && matches(e.data as Record<string, unknown>),
|
||||
)
|
||||
.map((e) => e.streamId),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
async issueIdsOfProject(projectId: string) {
|
||||
return this.streams(
|
||||
'IssueCreatedEvent',
|
||||
(d) => d['projectId'] === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
async releaseIdsOfProject(projectId: string) {
|
||||
return this.streams(
|
||||
'ReleaseCreatedEvent',
|
||||
(d) => d['projectId'] === projectId,
|
||||
);
|
||||
}
|
||||
|
||||
async commentIdsOfIssue(issueId: string) {
|
||||
return this.streams(
|
||||
'CommentAddedEvent',
|
||||
(d) => (d['comment'] as { issueId: string }).issueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
async issueIdsLinkingTo(issueId: string) {
|
||||
return this.streams(
|
||||
'IssueLinkedEvent',
|
||||
(d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ El modelo del producto: proyectos, tickets, comentarios y releases.
|
||||
|
||||
- Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on<Evento>`.
|
||||
- `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
|
||||
- Proyectos públicos (`Project.isPublic`) y borrado: `Project.delete` (propietario, confirmando la clave),
|
||||
`Issue.delete`, `Release.delete` (los usa la cascada); `SYSTEM_ACTOR_ID` para cambios que no hace una persona.
|
||||
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
|
||||
- `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus
|
||||
valores por defecto, `ProjectWorkflow` (lo que consultan tickets y queries) y `validateProjectSettings` (archivar,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user