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) {
|
||||
|
||||
Reference in New Issue
Block a user