feat: a dashboard for clients, behind a secret link
Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.
REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
|
||||||
async function gql<T = Record<string, any>>(
|
async function gql<T = Record<string, any>>(
|
||||||
token: string,
|
token: string | null,
|
||||||
query: string,
|
query: string,
|
||||||
variables?: object,
|
variables?: object,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
authorization: `Bearer ${token}`,
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
});
|
||||||
@@ -1915,6 +1915,118 @@ describe('task-stream (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shares a dashboard with clients through a secret link', async () => {
|
||||||
|
const key = 'K' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
|
await rest('token-ana', 'POST', '/projects', {
|
||||||
|
key,
|
||||||
|
name: `Cliente ${key}`,
|
||||||
|
});
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/members`, {
|
||||||
|
userId: '102',
|
||||||
|
role: 'MEMBER',
|
||||||
|
});
|
||||||
|
const epic = (
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'FEATURE',
|
||||||
|
title: 'Pagos',
|
||||||
|
})
|
||||||
|
).body['key'];
|
||||||
|
await eventually(
|
||||||
|
() => rest('token-ana', 'GET', `/issues/${epic}`),
|
||||||
|
(r) => r.status === 200,
|
||||||
|
);
|
||||||
|
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Parte',
|
||||||
|
parent: epic,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await rest('token-ana', 'GET', `/projects/${key}/share`)).status,
|
||||||
|
).toBe(204);
|
||||||
|
// Only known blocks are kept; a member cannot share.
|
||||||
|
const made = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||||
|
blocks: ['epics', 'flow', 'nope'],
|
||||||
|
});
|
||||||
|
expect(made.status).toBe(200);
|
||||||
|
expect(made.body['blocks']).toEqual(['flow', 'epics']);
|
||||||
|
const token = made.body['token'] as string;
|
||||||
|
expect(token.length).toBeGreaterThan(30);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await rest('token-bob', 'PUT', `/projects/${key}/share`, {
|
||||||
|
blocks: ['flow'],
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe(403);
|
||||||
|
|
||||||
|
// Anyone with the link sees the chosen blocks, nothing else.
|
||||||
|
const seen = await eventually(
|
||||||
|
() => rest(null, 'GET', `/shared/dashboards/${token}`),
|
||||||
|
(r) => r.status === 200 && r.body['epics']?.[0]?.total === 1,
|
||||||
|
);
|
||||||
|
expect(seen.body).toMatchObject({
|
||||||
|
projectKey: key,
|
||||||
|
sprint: null,
|
||||||
|
releases: null,
|
||||||
|
activity: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
seen.body['flow'].reduce(
|
||||||
|
(n: number, f: { count: number }) => n + f.count,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toBe(2);
|
||||||
|
expect(seen.body['epics'][0]).toMatchObject({
|
||||||
|
key: epic,
|
||||||
|
title: 'Pagos',
|
||||||
|
done: 0,
|
||||||
|
});
|
||||||
|
const viaGql = await gql(
|
||||||
|
null,
|
||||||
|
`query($t: String!) { sharedDashboard(token: $t) { projectKey blocks } }`,
|
||||||
|
{ t: token },
|
||||||
|
);
|
||||||
|
expect(viaGql.sharedDashboard).toEqual({
|
||||||
|
projectKey: key,
|
||||||
|
blocks: ['flow', 'epics'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Changing blocks keeps the link; renewing it kills the old one.
|
||||||
|
const changed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||||
|
blocks: ['activity'],
|
||||||
|
});
|
||||||
|
expect(changed.body['token']).toBe(token);
|
||||||
|
const renewed = await rest('token-ana', 'PUT', `/projects/${key}/share`, {
|
||||||
|
blocks: ['activity'],
|
||||||
|
renew: true,
|
||||||
|
});
|
||||||
|
expect(renewed.body['token']).not.toBe(token);
|
||||||
|
expect(
|
||||||
|
(await rest(null, 'GET', `/shared/dashboards/${token}`)).status,
|
||||||
|
).toBe(404);
|
||||||
|
const fresh = await rest(
|
||||||
|
null,
|
||||||
|
'GET',
|
||||||
|
`/shared/dashboards/${renewed.body['token']}`,
|
||||||
|
);
|
||||||
|
expect(fresh.body['activity']).toHaveLength(84);
|
||||||
|
expect(fresh.body['flow']).toBeNull();
|
||||||
|
|
||||||
|
// Off: the link stops working.
|
||||||
|
expect(
|
||||||
|
(await rest('token-ana', 'DELETE', `/projects/${key}/share`)).status,
|
||||||
|
).toBe(204);
|
||||||
|
expect(
|
||||||
|
(await rest(null, 'GET', `/shared/dashboards/${renewed.body['token']}`))
|
||||||
|
.status,
|
||||||
|
).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('fills the home page: agenda, activity, the week and who is here', async () => {
|
it('fills the home page: agenda, activity, the week and who is here', async () => {
|
||||||
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||||
|
|||||||
@@ -10680,6 +10680,324 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/projects/{project}/share": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SharingController_share",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DashboardShareDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"204": {
|
||||||
|
"description": "It has none"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "The project's link for clients, if any",
|
||||||
|
"tags": [
|
||||||
|
"Projects"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "SharingController_save",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShareDashboardDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DashboardShareDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "Turn the link on or change its blocks (renew: a new secret)",
|
||||||
|
"tags": [
|
||||||
|
"Projects"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "SharingController_remove",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"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": "The link stops working",
|
||||||
|
"tags": [
|
||||||
|
"Projects"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/shared/dashboards/{token}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.",
|
||||||
|
"operationId": "SharingController_shared",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SharedDashboardDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "What a link for clients shows (no session)",
|
||||||
|
"tags": [
|
||||||
|
"Projects"
|
||||||
|
],
|
||||||
|
"security": []
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/projects/{project}/export.csv": {
|
"/api/projects/{project}/export.csv": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.",
|
"description": "UTF-8 with BOM. Cells that a spreadsheet would run as a formula start with a quote mark.",
|
||||||
@@ -16285,6 +16603,186 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DashboardShareDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Secret of the link /share/{token}"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"example": [
|
||||||
|
"flow",
|
||||||
|
"sprint",
|
||||||
|
"releases"
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"projectId",
|
||||||
|
"token",
|
||||||
|
"blocks",
|
||||||
|
"createdAt"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ShareDashboardDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"blocks": {
|
||||||
|
"example": [
|
||||||
|
"flow",
|
||||||
|
"sprint",
|
||||||
|
"releases"
|
||||||
|
],
|
||||||
|
"description": "Blocks to show: flow, sprint, releases, epics, activity",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"renew": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "A new secret: the old link stops working"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"blocks"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SharedFlowDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A colour name of the project settings"
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"name",
|
||||||
|
"color",
|
||||||
|
"count"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SharedEpicDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dueDate": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"percent": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"key",
|
||||||
|
"title",
|
||||||
|
"dueDate",
|
||||||
|
"done",
|
||||||
|
"total",
|
||||||
|
"percent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SharedDashboardDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"projectKey": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"projectName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"generatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flow": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SharedFlowDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sprint": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SprintPulseDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"releases": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ReleaseForecastDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"epics": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SharedEpicDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ActivityDayDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"projectKey",
|
||||||
|
"projectName",
|
||||||
|
"generatedAt",
|
||||||
|
"blocks",
|
||||||
|
"flow",
|
||||||
|
"sprint",
|
||||||
|
"releases",
|
||||||
|
"epics",
|
||||||
|
"activity"
|
||||||
|
]
|
||||||
|
},
|
||||||
"ImportLineDto": {
|
"ImportLineDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -465,6 +465,16 @@ input CustomFieldValueInput {
|
|||||||
value: String
|
value: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""The project's link for clients (owners and admins)"""
|
||||||
|
type DashboardShare {
|
||||||
|
blocks: [String!]!
|
||||||
|
createdAt: String!
|
||||||
|
projectId: ID!
|
||||||
|
|
||||||
|
"""Secret of the link /share/{token}"""
|
||||||
|
token: String!
|
||||||
|
}
|
||||||
|
|
||||||
"""A commit, branch or pull request that mentions the ticket"""
|
"""A commit, branch or pull request that mentions the ticket"""
|
||||||
type DevLink {
|
type DevLink {
|
||||||
at: String!
|
at: String!
|
||||||
@@ -1010,6 +1020,9 @@ type Mutation {
|
|||||||
"""Put a ticket in a sprint (null: back to the backlog)"""
|
"""Put a ticket in a sprint (null: back to the backlog)"""
|
||||||
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
|
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
|
||||||
|
|
||||||
|
"""Turn the link on or change its blocks; renew for a new secret"""
|
||||||
|
shareDashboard(input: ShareDashboardInput!): DashboardShare!
|
||||||
|
|
||||||
"""Start a planned sprint (only one under way per project)"""
|
"""Start a planned sprint (only one under way per project)"""
|
||||||
startSprint(sprintId: ID!): CommandResult!
|
startSprint(sprintId: ID!): CommandResult!
|
||||||
|
|
||||||
@@ -1033,6 +1046,9 @@ type Mutation {
|
|||||||
unarchiveProject(projectId: ID!): CommandResult!
|
unarchiveProject(projectId: ID!): CommandResult!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
|
"""The link stops working"""
|
||||||
|
unshareDashboard(projectId: ID!): Boolean!
|
||||||
|
|
||||||
"""Push off on this device"""
|
"""Push off on this device"""
|
||||||
unsubscribePush(endpoint: String!): Boolean!
|
unsubscribePush(endpoint: String!): Boolean!
|
||||||
|
|
||||||
@@ -1456,6 +1472,9 @@ type Query {
|
|||||||
): [Activity!]!
|
): [Activity!]!
|
||||||
automations(projectId: ID!): [Automation!]!
|
automations(projectId: ID!): [Automation!]!
|
||||||
|
|
||||||
|
"""The project's link for clients, if any (owners and admins)"""
|
||||||
|
dashboardShare(projectId: ID!): DashboardShare
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A project's epics: top-level tickets that group others, with the progress of those
|
A project's epics: top-level tickets that group others, with the progress of those
|
||||||
"""
|
"""
|
||||||
@@ -1532,6 +1551,9 @@ type Query {
|
|||||||
|
|
||||||
"""My views of the project's board, then the shared ones"""
|
"""My views of the project's board, then the shared ones"""
|
||||||
savedFilters(projectId: ID!): [SavedFilter!]!
|
savedFilters(projectId: ID!): [SavedFilter!]!
|
||||||
|
|
||||||
|
"""What a link for clients shows, for anyone with it"""
|
||||||
|
sharedDashboard(token: String!): SharedDashboard
|
||||||
sprint(id: ID!): Sprint!
|
sprint(id: ID!): Sprint!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -1707,6 +1729,50 @@ input SavedFilterCriteriaInput {
|
|||||||
unassigned: Boolean
|
unassigned: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""The project's link for clients"""
|
||||||
|
input ShareDashboardInput {
|
||||||
|
"""Blocks to show: flow, sprint, releases, epics, activity"""
|
||||||
|
blocks: [String!]!
|
||||||
|
projectId: ID!
|
||||||
|
|
||||||
|
"""A new secret: the old link stops working"""
|
||||||
|
renew: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
What a link for clients shows: only the chosen blocks (the others are null)
|
||||||
|
"""
|
||||||
|
type SharedDashboard {
|
||||||
|
activity: [ActivityDay!]
|
||||||
|
blocks: [String!]!
|
||||||
|
epics: [SharedEpic!]
|
||||||
|
flow: [SharedFlow!]
|
||||||
|
generatedAt: String!
|
||||||
|
projectKey: String!
|
||||||
|
projectName: String!
|
||||||
|
releases: [ReleaseForecast!]
|
||||||
|
sprint: SprintPulse
|
||||||
|
}
|
||||||
|
|
||||||
|
"""An open epic and how far it got"""
|
||||||
|
type SharedEpic {
|
||||||
|
done: Int!
|
||||||
|
dueDate: String
|
||||||
|
key: String!
|
||||||
|
percent: Int!
|
||||||
|
title: String!
|
||||||
|
total: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
"""Tickets in one status"""
|
||||||
|
type SharedFlow {
|
||||||
|
"""A colour name of the project settings"""
|
||||||
|
color: String!
|
||||||
|
count: Int!
|
||||||
|
name: String!
|
||||||
|
status: String!
|
||||||
|
}
|
||||||
|
|
||||||
"""A time-boxed iteration: the tickets the team commits to"""
|
"""A time-boxed iteration: the tickets the team commits to"""
|
||||||
type Sprint {
|
type Sprint {
|
||||||
burndown: SprintBurndown!
|
burndown: SprintBurndown!
|
||||||
|
|||||||
@@ -1636,6 +1636,45 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/projects/{project}/share": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** The project's link for clients, if any */
|
||||||
|
get: operations["SharingController_share"];
|
||||||
|
/** Turn the link on or change its blocks (renew: a new secret) */
|
||||||
|
put: operations["SharingController_save"];
|
||||||
|
post?: never;
|
||||||
|
/** The link stops working */
|
||||||
|
delete: operations["SharingController_remove"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/shared/dashboards/{token}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* What a link for clients shows (no session)
|
||||||
|
* @description Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.
|
||||||
|
*/
|
||||||
|
get: operations["SharingController_shared"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/projects/{project}/export.csv": {
|
"/api/projects/{project}/export.csv": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3424,6 +3463,59 @@ export interface components {
|
|||||||
/** @description Paused webhooks get nothing */
|
/** @description Paused webhooks get nothing */
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
DashboardShareDto: {
|
||||||
|
projectId: string;
|
||||||
|
/** @description Secret of the link /share/{token} */
|
||||||
|
token: string;
|
||||||
|
/**
|
||||||
|
* @example [
|
||||||
|
* "flow",
|
||||||
|
* "sprint",
|
||||||
|
* "releases"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
blocks: string[];
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
ShareDashboardDto: {
|
||||||
|
/**
|
||||||
|
* @description Blocks to show: flow, sprint, releases, epics, activity
|
||||||
|
* @example [
|
||||||
|
* "flow",
|
||||||
|
* "sprint",
|
||||||
|
* "releases"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
blocks: string[];
|
||||||
|
/** @description A new secret: the old link stops working */
|
||||||
|
renew?: boolean;
|
||||||
|
};
|
||||||
|
SharedFlowDto: {
|
||||||
|
status: string;
|
||||||
|
name: string;
|
||||||
|
/** @description A colour name of the project settings */
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
SharedEpicDto: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
SharedDashboardDto: {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
generatedAt: string;
|
||||||
|
blocks: string[];
|
||||||
|
flow: components["schemas"]["SharedFlowDto"][] | null;
|
||||||
|
sprint: components["schemas"]["SprintPulseDto"] | null;
|
||||||
|
releases: components["schemas"]["ReleaseForecastDto"][] | null;
|
||||||
|
epics: components["schemas"]["SharedEpicDto"][] | null;
|
||||||
|
activity: components["schemas"]["ActivityDayDto"][] | null;
|
||||||
|
};
|
||||||
ImportLineDto: {
|
ImportLineDto: {
|
||||||
/** @description Line of the file (the header is 1) */
|
/** @description Line of the file (the header is 1) */
|
||||||
line: number;
|
line: number;
|
||||||
@@ -10881,6 +10973,246 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SharingController_share: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DashboardShareDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description It has none */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SharingController_save: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ShareDashboardDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DashboardShareDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SharingController_remove: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
SharingController_shared: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SharedDashboardDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
TransferController_exportCsv: {
|
TransferController_exportCsv: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe('appRoutes', () => {
|
|||||||
'',
|
'',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
'explore',
|
'explore',
|
||||||
|
'share/:token',
|
||||||
'cartera',
|
'cartera',
|
||||||
'explorar',
|
'explorar',
|
||||||
'p/:key',
|
'p/:key',
|
||||||
@@ -31,10 +32,12 @@ describe('appRoutes', () => {
|
|||||||
expect(children[0].canActivate).toContain(authGuard);
|
expect(children[0].canActivate).toContain(authGuard);
|
||||||
expect(children[1].canActivate).toContain(authGuard);
|
expect(children[1].canActivate).toContain(authGuard);
|
||||||
expect(children[2].canActivate).toBeUndefined();
|
expect(children[2].canActivate).toBeUndefined();
|
||||||
expect(children[5].canActivate).toBeUndefined();
|
// The page for clients needs no session: its link is the key.
|
||||||
|
expect(children[3].canActivate).toBeUndefined();
|
||||||
|
expect(children[6].canActivate).toBeUndefined();
|
||||||
// The Spanish paths of shared links lead to the English ones.
|
// The Spanish paths of shared links lead to the English ones.
|
||||||
expect(children[3].redirectTo).toBe('portfolio');
|
expect(children[4].redirectTo).toBe('portfolio');
|
||||||
expect(children[4].redirectTo).toBe('explore');
|
expect(children[5].redirectTo).toBe('explore');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives each page its own state, not the root', async () => {
|
it('gives each page its own state, not the root', async () => {
|
||||||
|
|||||||
@@ -339,6 +339,25 @@ Cada evento enlaza a la web (`WEB_URL`). El token (32 bytes aleatorios) se crea,
|
|||||||
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
|
servicio la URL anterior. Se guarda tal cual en `calendar_feeds` para poder volver a copiar la URL: solo da lectura de
|
||||||
fechas y títulos que ya están en el read model.
|
fechas y títulos que ya están en el read model.
|
||||||
|
|
||||||
|
### Panel para clientes
|
||||||
|
|
||||||
|
Cada proyecto puede enseñar su estado a sus clientes con un enlace de solo lectura, sin cuenta: `/share/{token}` en la
|
||||||
|
web, que lee `GET /api/shared/dashboards/{token}` (GraphQL: `sharedDashboard(token)`), ambos sin sesión. Solo trae los
|
||||||
|
bloques elegidos, y los demás llegan a `null`:
|
||||||
|
|
||||||
|
| Bloque | Qué da |
|
||||||
|
| ---------- | -------------------------------------------------------------------------- |
|
||||||
|
| `flow` | Tickets en cada estado activo, con su nombre y color |
|
||||||
|
| `sprint` | El sprint en curso, como en el panel (¿llega?) |
|
||||||
|
| `releases` | Las releases planificadas con su previsión al ritmo real |
|
||||||
|
| `epics` | Las épicas abiertas (8, por fecha): título, fecha, terminados y porcentaje |
|
||||||
|
| `activity` | Movimientos por día de las últimas 12 semanas |
|
||||||
|
|
||||||
|
No enseña personas ni tickets sueltos (salvo las épicas). Propietarios y admins lo activan, cambian sus bloques o lo
|
||||||
|
renuevan con `GET`, `PUT` y `DELETE /api/projects/{project}/share` (`{ blocks, renew }`; GraphQL: `dashboardShare`,
|
||||||
|
`shareDashboard`, `unshareDashboard`). Renovarlo deja sin servicio el enlace anterior. El token (32 bytes aleatorios)
|
||||||
|
se guarda tal cual en `dashboard_shares` para poder volver a copiar el enlace, y se borra con el proyecto.
|
||||||
|
|
||||||
### Notificaciones
|
### Notificaciones
|
||||||
|
|
||||||
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
||||||
@@ -707,6 +726,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
|
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
|
||||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||||
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ quieta con la misma caja, así que nada se mueve al llegar. Lo demás es de su p
|
|||||||
|
|
||||||
- Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los
|
- Las de una página que se vuelve a visitar a menudo (portada, Explorar, el formulario de propuestas) van en los
|
||||||
`providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`,
|
`providers` de su ruta (`provideHomeFacade()`, `provideMyHomeFacade()`, `provideMyViewsFacade()`,
|
||||||
`provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`): cargan con la página y el inyector de
|
`provideCalendarFacade()`, `provideExploreFacade()`, `provideProposeFacade()`, `provideSharedDashboardFacade()`): cargan con la página y el inyector de
|
||||||
la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga.
|
la ruta las conserva, así que al volver se ve lo último al instante mientras se recarga.
|
||||||
|
- `SharingFacade` (el enlace para clientes en Configuración) va en los `providers` de su propio componente.
|
||||||
- Las de un proyecto o un ticket se registran con `provideProjectFacade()`, `provideBoardFacade()` o
|
- Las de un proyecto o un ticket se registran con `provideProjectFacade()`, `provideBoardFacade()` o
|
||||||
`provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus
|
`provideIssueFacade()` en los `providers` del componente de la página (store + facade) y desaparecen al salir (con sus
|
||||||
subscriptions). Las páginas hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
subscriptions). Las páginas hijas de `/p/:key` reciben la misma `ProjectFacade`.
|
||||||
@@ -310,9 +311,10 @@ visualización de la plataforma:
|
|||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor, el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor, el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
|
||||||
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
|
||||||
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
|
||||||
| `/p/:key/activity` | Actividad en vivo |
|
| `/p/:key/activity` | Actividad en vivo |
|
||||||
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
|
||||||
|
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
|
||||||
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
|
||||||
|
|
||||||
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ cuando hay pistas, por dónde empezar.
|
|||||||
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
|
| Limpiar los datos de prueba de producción | DEMO, TEST y los tickets de las pruebas del MCP | Borrar los proyectos desde Configuración (borrado definitivo con su clave) |
|
||||||
| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) |
|
| Registrar el webhook del cliente de contraseña en Pireon | Sin él, revocar un token de quien entra con contraseña no se nota hasta que caduca la caché | Solo si `PIREON_PASSWORD_CLIENT_ID` tiene valor: webhook en ese cliente y su secret en `PIREON_PASSWORD_WEBHOOK_SECRET` (ver [Autenticación](05-autenticacion.md)) |
|
||||||
| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) |
|
| Una versión 1.0 | Un punto fijo al que volver | Desplegar todo, etiquetar `v1.0.0` y redactar la lista de cambios (las notas de versión de TaskStream dan el borrador) |
|
||||||
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
|
| Revisión de seguridad | Hay rutas sin sesión (proyectos públicos, escaparate, propuestas, panel para clientes) y un MCP abierto a Internet | Permisos por rol, qué ve un anónimo, límites de peticiones (solo las propuestas tienen), CORS, cabeceras |
|
||||||
| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP |
|
| Registro de errores en producción | Hoy un fallo solo se conoce si alguien avisa | Sentry (o similar) en la API, la web y el MCP |
|
||||||
|
|
||||||
## 10.2 Fallos y deuda conocidos
|
## 10.2 Fallos y deuda conocidos
|
||||||
@@ -130,3 +130,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi
|
|||||||
con las más votadas sin respuesta.
|
con las más votadas sin respuesta.
|
||||||
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
|
- Modo pantalla del panel: a pantalla completa para una tele del equipo, un bloque cada vez que cambia solo, en
|
||||||
directo y con la hora.
|
directo y con la hora.
|
||||||
|
- Panel para clientes: un enlace de solo lectura, sin cuenta, con los bloques que elijan el propietario y los admins
|
||||||
|
(flujo, sprint, releases, épicas, actividad); se renueva o se retira desde Configuración.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,8 @@ export * from './lib/transfer.service';
|
|||||||
export * from './lib/automations.service';
|
export * from './lib/automations.service';
|
||||||
export * from './lib/calendar.service';
|
export * from './lib/calendar.service';
|
||||||
export * from './lib/calendar/calendar';
|
export * from './lib/calendar/calendar';
|
||||||
|
export * from './lib/sharing.service';
|
||||||
|
export * from './lib/sharing/dashboard-shares';
|
||||||
export * from './lib/automations/automations';
|
export * from './lib/automations/automations';
|
||||||
export * from './lib/automations/automation-rules';
|
export * from './lib/automations/automation-rules';
|
||||||
export * from './lib/transfer/import-export';
|
export * from './lib/transfer/import-export';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||||
|
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
|
||||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||||
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
|
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||||
import { ChangeFeed } from '../ports/change-feed';
|
import { ChangeFeed } from '../ports/change-feed';
|
||||||
@@ -8,7 +9,7 @@ import { versionOf } from './read-model-notifier';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the project (and its activity and timeline) from the read model,
|
* Removes the project (and its activity and timeline) from the read model,
|
||||||
* forgets its Git repositories' secrets, and tells its members, whose project
|
* forgets its Git repositories' secrets and its link for clients, and tells its members, whose project
|
||||||
* lists drop it. Tickets, comments and
|
* lists drop it. Tickets, comments and
|
||||||
* releases go as the cascade saga deletes them.
|
* releases go as the cascade saga deletes them.
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +22,7 @@ export class ProjectDeletedEventHandler
|
|||||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||||
private readonly changeFeed: ChangeFeed,
|
private readonly changeFeed: ChangeFeed,
|
||||||
private readonly gitIntegrations: GitIntegrationsRepository,
|
private readonly gitIntegrations: GitIntegrationsRepository,
|
||||||
|
private readonly shares: DashboardSharesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: ProjectDeletedEvent) {
|
async handle(event: ProjectDeletedEvent) {
|
||||||
@@ -28,6 +30,8 @@ export class ProjectDeletedEventHandler
|
|||||||
await this.upsertMaterializedProjectRepository.remove(event.projectId);
|
await this.upsertMaterializedProjectRepository.remove(event.projectId);
|
||||||
// No webhook of it should be accepted any more.
|
// No webhook of it should be accepted any more.
|
||||||
await this.gitIntegrations.saveAll(event.projectId, []);
|
await this.gitIntegrations.saveAll(event.projectId, []);
|
||||||
|
// Nor its link for clients.
|
||||||
|
await this.shares.remove(event.projectId);
|
||||||
if (!project) return; // Already removed (event handled twice).
|
if (!project) return; // Already removed (event handled twice).
|
||||||
await this.changeFeed.publishProject(
|
await this.changeFeed.publishProject(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DashboardShareReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One link for clients per project. The token is kept as it is (not hashed)
|
||||||
|
* so the link can be copied again; it only reads the blocks chosen.
|
||||||
|
*/
|
||||||
|
export abstract class DashboardSharesRepository {
|
||||||
|
abstract findByProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<DashboardShareReadModel | null>;
|
||||||
|
abstract findByToken(token: string): Promise<DashboardShareReadModel | null>;
|
||||||
|
/** Replaces the project's link, if any. */
|
||||||
|
abstract save(share: DashboardShareReadModel): Promise<void>;
|
||||||
|
abstract remove(projectId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -30,3 +30,4 @@ export * from './rate-limit.store';
|
|||||||
export * from './calendar-feeds.repository';
|
export * from './calendar-feeds.repository';
|
||||||
export * from './push-sender';
|
export * from './push-sender';
|
||||||
export * from './push-subscriptions.repository';
|
export * from './push-subscriptions.repository';
|
||||||
|
export * from './dashboard-shares.repository';
|
||||||
|
|||||||
55
libs/api/tracker/application/src/lib/sharing.service.ts
Normal file
55
libs/api/tracker/application/src/lib/sharing.service.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { QueryBus } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
DashboardShareReadModel,
|
||||||
|
SharedDashboardReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import {
|
||||||
|
GetDashboardShareQuery,
|
||||||
|
GetSharedDashboardQuery,
|
||||||
|
ShareDashboardCommand,
|
||||||
|
UnshareDashboardCommand,
|
||||||
|
} from './sharing/dashboard-shares';
|
||||||
|
|
||||||
|
/** The project's dashboard for clients, behind a secret link. */
|
||||||
|
@Injectable()
|
||||||
|
export class SharingService {
|
||||||
|
constructor(
|
||||||
|
private readonly commands: CommandRunner,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The project's link, if it has one (owners and admins). */
|
||||||
|
share(
|
||||||
|
viewerId: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<DashboardShareReadModel | null> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetDashboardShareQuery(projectId, viewerId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns the link on or changes its blocks; `renew` makes a new secret. */
|
||||||
|
save(
|
||||||
|
actorId: string,
|
||||||
|
projectId: string,
|
||||||
|
blocks: string[],
|
||||||
|
renew = false,
|
||||||
|
): Promise<DashboardShareReadModel> {
|
||||||
|
return this.commands.execute(
|
||||||
|
new ShareDashboardCommand(projectId, actorId, blocks, renew),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(actorId: string, projectId: string): Promise<void> {
|
||||||
|
return this.commands.execute(
|
||||||
|
new UnshareDashboardCommand(projectId, actorId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a link shows now, or null if no project has it. */
|
||||||
|
shared(token: string): Promise<SharedDashboardReadModel | null> {
|
||||||
|
return this.queryBus.execute(new GetSharedDashboardQuery(token));
|
||||||
|
}
|
||||||
|
}
|
||||||
255
libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
Normal file
255
libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandHandler,
|
||||||
|
ICommandHandler,
|
||||||
|
IQueryHandler,
|
||||||
|
Query,
|
||||||
|
QueryHandler,
|
||||||
|
} from '@nestjs/cqrs';
|
||||||
|
import { assertThat } from '@task-stream/api/shared/domain';
|
||||||
|
import {
|
||||||
|
DashboardShareReadModel,
|
||||||
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
|
SHARED_BLOCKS,
|
||||||
|
SharedBlock,
|
||||||
|
SharedDashboardReadModel,
|
||||||
|
SprintStatus,
|
||||||
|
StatusCategory,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeInsights, heatmapStart } from '../analytics/insights';
|
||||||
|
import { WriteAccess } from '../commands/write-access';
|
||||||
|
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
|
||||||
|
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
|
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||||
|
|
||||||
|
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||||
|
/** Epics a client sees, nearest due date first. */
|
||||||
|
const EPICS = 8;
|
||||||
|
|
||||||
|
/** Only known blocks, each once, in the order they are shown. */
|
||||||
|
function cleanBlocks(blocks: string[]): SharedBlock[] {
|
||||||
|
const chosen = new Set(blocks);
|
||||||
|
return SHARED_BLOCKS.filter((b) => chosen.has(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the project's link for clients on (or changes its blocks). With
|
||||||
|
* `renew`, or the first time, a new secret: the old link stops working.
|
||||||
|
*/
|
||||||
|
export class ShareDashboardCommand extends Command<DashboardShareReadModel> {
|
||||||
|
constructor(
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly blocks: string[],
|
||||||
|
public readonly renew: boolean,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project's link for clients stops working. */
|
||||||
|
export class UnshareDashboardCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly actorId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project's link for clients, for who manages it. */
|
||||||
|
export class GetDashboardShareQuery extends Query<DashboardShareReadModel | null> {
|
||||||
|
constructor(
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly viewerId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a link shows, read now; null for an unknown token. */
|
||||||
|
export class GetSharedDashboardQuery extends Query<SharedDashboardReadModel | null> {
|
||||||
|
constructor(public readonly token: string) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(ShareDashboardCommand)
|
||||||
|
export class ShareDashboardCommandHandler
|
||||||
|
implements ICommandHandler<ShareDashboardCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly shares: DashboardSharesRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
command: ShareDashboardCommand,
|
||||||
|
): Promise<DashboardShareReadModel> {
|
||||||
|
(await this.access.project(command.projectId)).assertCanManage(
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
const blocks = cleanBlocks(command.blocks);
|
||||||
|
assertThat(blocks.length > 0, 'Pick at least one block');
|
||||||
|
const current = await this.shares.findByProject(command.projectId);
|
||||||
|
const fresh = !current || command.renew;
|
||||||
|
const share: DashboardShareReadModel = {
|
||||||
|
projectId: command.projectId,
|
||||||
|
// 32 random bytes: nobody guesses it.
|
||||||
|
token: fresh ? randomBytes(32).toString('base64url') : current.token,
|
||||||
|
blocks,
|
||||||
|
createdAt: fresh ? new Date().toISOString() : current.createdAt,
|
||||||
|
};
|
||||||
|
await this.shares.save(share);
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(UnshareDashboardCommand)
|
||||||
|
export class UnshareDashboardCommandHandler
|
||||||
|
implements ICommandHandler<UnshareDashboardCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly shares: DashboardSharesRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: UnshareDashboardCommand): Promise<void> {
|
||||||
|
(await this.access.project(command.projectId)).assertManages(
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
await this.shares.remove(command.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetDashboardShareQuery)
|
||||||
|
export class GetDashboardShareQueryHandler
|
||||||
|
implements
|
||||||
|
IQueryHandler<GetDashboardShareQuery, DashboardShareReadModel | null>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly shares: DashboardSharesRepository,
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetDashboardShareQuery,
|
||||||
|
): Promise<DashboardShareReadModel | null> {
|
||||||
|
(await this.access.project(query.projectId)).assertManages(query.viewerId);
|
||||||
|
return this.shares.findByProject(query.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chosen blocks of a project, for anyone with the link: computed like
|
||||||
|
* the dashboard, then cut down to what outsiders may see.
|
||||||
|
*/
|
||||||
|
@QueryHandler(GetSharedDashboardQuery)
|
||||||
|
export class GetSharedDashboardQueryHandler
|
||||||
|
implements
|
||||||
|
IQueryHandler<GetSharedDashboardQuery, SharedDashboardReadModel | null>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly shares: DashboardSharesRepository,
|
||||||
|
private readonly findProjects: FindProjectsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly findTimeline: FindTimelineRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly findReleases: FindReleasesRepository,
|
||||||
|
private readonly findActivity: FindActivityRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetSharedDashboardQuery,
|
||||||
|
): Promise<SharedDashboardReadModel | null> {
|
||||||
|
const share = await this.shares.findByToken(query.token);
|
||||||
|
if (!share) return null;
|
||||||
|
const project = await this.findProjects.findById(share.projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
const has = (block: SharedBlock) => share.blocks.includes(block);
|
||||||
|
const now = new Date();
|
||||||
|
const [issues, timeline, sprints, releases, epics, activity] =
|
||||||
|
await Promise.all([
|
||||||
|
this.findIssues.findByProject(project.id),
|
||||||
|
this.findTimeline.findByProject(project.id),
|
||||||
|
this.findSprints.findByProject(project.id),
|
||||||
|
this.findReleases.findByProject(project.id),
|
||||||
|
has('epics') ? this.findIssues.epics(project.id) : [],
|
||||||
|
has('activity')
|
||||||
|
? this.findActivity.dailyCounts(
|
||||||
|
[project.id],
|
||||||
|
heatmapStart(now.getTime()),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
const settings = project.settings ?? DEFAULT_PROJECT_SETTINGS;
|
||||||
|
const reading = computeInsights({
|
||||||
|
issues,
|
||||||
|
timeline,
|
||||||
|
settings,
|
||||||
|
activeSprint:
|
||||||
|
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||||
|
sprints,
|
||||||
|
releases,
|
||||||
|
members: project.members,
|
||||||
|
since: null,
|
||||||
|
mentions: 0,
|
||||||
|
activity,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
const live = issues.filter((i) => !i.trashedAt);
|
||||||
|
return {
|
||||||
|
projectKey: project.key,
|
||||||
|
projectName: project.name,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
blocks: share.blocks,
|
||||||
|
flow: has('flow')
|
||||||
|
? settings.statuses
|
||||||
|
.filter((s) => !s.archived)
|
||||||
|
.map((s) => ({
|
||||||
|
status: s.id,
|
||||||
|
name: s.name,
|
||||||
|
color: s.color,
|
||||||
|
count: live.filter((i) => i.status === s.id).length,
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
sprint: has('sprint') ? reading.sprint : null,
|
||||||
|
releases: has('releases') ? reading.releases : null,
|
||||||
|
epics: has('epics')
|
||||||
|
? epics
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
!FINISHED.includes(e.issue.statusCategory) &&
|
||||||
|
!e.issue.trashedAt,
|
||||||
|
)
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.issue.dueDate ?? '9999').localeCompare(
|
||||||
|
b.issue.dueDate ?? '9999',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.slice(0, EPICS)
|
||||||
|
.map((e) => ({
|
||||||
|
key: e.issue.key,
|
||||||
|
title: e.issue.title,
|
||||||
|
dueDate: e.issue.dueDate,
|
||||||
|
done: e.progress.finished,
|
||||||
|
total: e.progress.total,
|
||||||
|
percent: e.progress.percent,
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
activity: has('activity') ? reading.heatmap : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHARING_HANDLERS = [
|
||||||
|
ShareDashboardCommandHandler,
|
||||||
|
UnshareDashboardCommandHandler,
|
||||||
|
GetDashboardShareQueryHandler,
|
||||||
|
GetSharedDashboardQueryHandler,
|
||||||
|
];
|
||||||
@@ -49,6 +49,8 @@ import { PresenceService } from './presence.service';
|
|||||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||||
import { CALENDAR_HANDLERS } from './calendar/calendar';
|
import { CALENDAR_HANDLERS } from './calendar/calendar';
|
||||||
import { CalendarService } from './calendar.service';
|
import { CalendarService } from './calendar.service';
|
||||||
|
import { SharingService } from './sharing.service';
|
||||||
|
import { SHARING_HANDLERS } from './sharing/dashboard-shares';
|
||||||
import { AutomationsEventHandler } from './automations/automations.event-handler';
|
import { AutomationsEventHandler } from './automations/automations.event-handler';
|
||||||
|
|
||||||
/** Notifications: derived from events, kept and dismissed per person. */
|
/** Notifications: derived from events, kept and dismissed per person. */
|
||||||
@@ -66,6 +68,7 @@ const NOTIFICATIONS = [
|
|||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
CalendarService,
|
CalendarService,
|
||||||
|
SharingService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
CommentsService,
|
CommentsService,
|
||||||
@@ -110,6 +113,7 @@ const SERVICES = [
|
|||||||
...TRANSFER_HANDLERS,
|
...TRANSFER_HANDLERS,
|
||||||
...AUTOMATION_HANDLERS,
|
...AUTOMATION_HANDLERS,
|
||||||
...CALENDAR_HANDLERS,
|
...CALENDAR_HANDLERS,
|
||||||
|
...SHARING_HANDLERS,
|
||||||
AutomationsEventHandler,
|
AutomationsEventHandler,
|
||||||
],
|
],
|
||||||
exports: SERVICES,
|
exports: SERVICES,
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from './saved-filter.read-model';
|
|||||||
export * from './sprint.read-model';
|
export * from './sprint.read-model';
|
||||||
export * from './insights.read-model';
|
export * from './insights.read-model';
|
||||||
export * from './home.read-model';
|
export * from './home.read-model';
|
||||||
|
export * from './shared-dashboard.read-model';
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
ActivityDayReadModel,
|
||||||
|
ReleaseForecastReadModel,
|
||||||
|
SprintPulseReadModel,
|
||||||
|
} from './insights.read-model';
|
||||||
|
|
||||||
|
/** The blocks a project can show to outsiders, in the order they are shown. */
|
||||||
|
export const SHARED_BLOCKS = [
|
||||||
|
'flow',
|
||||||
|
'sprint',
|
||||||
|
'releases',
|
||||||
|
'epics',
|
||||||
|
'activity',
|
||||||
|
] as const;
|
||||||
|
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
|
||||||
|
|
||||||
|
/** A project's link for clients: the secret, and what it shows. */
|
||||||
|
export class DashboardShareReadModel {
|
||||||
|
projectId!: string;
|
||||||
|
/** Random and long; whoever has it sees the chosen blocks. */
|
||||||
|
token!: string;
|
||||||
|
blocks!: SharedBlock[];
|
||||||
|
createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets in one status (the board's columns, archived ones left out). */
|
||||||
|
export class SharedFlowReadModel {
|
||||||
|
status!: string;
|
||||||
|
name!: string;
|
||||||
|
/** A colour name of the project's settings. */
|
||||||
|
color!: string;
|
||||||
|
count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An open epic and how far it got: no more than its key and title. */
|
||||||
|
export class SharedEpicReadModel {
|
||||||
|
key!: string;
|
||||||
|
title!: string;
|
||||||
|
dueDate!: string | null;
|
||||||
|
done!: number;
|
||||||
|
total!: number;
|
||||||
|
percent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a client sees through the link: only the chosen blocks (the others
|
||||||
|
* are null), nothing about people or single tickets but the epics.
|
||||||
|
*/
|
||||||
|
export class SharedDashboardReadModel {
|
||||||
|
projectKey!: string;
|
||||||
|
projectName!: string;
|
||||||
|
generatedAt!: string;
|
||||||
|
blocks!: SharedBlock[];
|
||||||
|
flow!: SharedFlowReadModel[] | null;
|
||||||
|
sprint!: SprintPulseReadModel | null;
|
||||||
|
releases!: ReleaseForecastReadModel[] | null;
|
||||||
|
epics!: SharedEpicReadModel[] | null;
|
||||||
|
activity!: ActivityDayReadModel[] | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import { DashboardSharesRepository } from '@task-stream/api/tracker/application';
|
||||||
|
import {
|
||||||
|
DashboardShareReadModel,
|
||||||
|
SharedBlock,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { DashboardShareDoc, ReadModels } from './read-model.schemas';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoDashboardSharesRepository extends DashboardSharesRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.DashboardShare)
|
||||||
|
private readonly shares: Model<DashboardShareDoc>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByProject(
|
||||||
|
projectId: string,
|
||||||
|
): Promise<DashboardShareReadModel | null> {
|
||||||
|
const doc = await this.shares
|
||||||
|
.findOne({ id: projectId })
|
||||||
|
.lean<DashboardShareDoc>();
|
||||||
|
return doc ? toShare(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByToken(token: string): Promise<DashboardShareReadModel | null> {
|
||||||
|
const doc = await this.shares.findOne({ token }).lean<DashboardShareDoc>();
|
||||||
|
return doc ? toShare(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(share: DashboardShareReadModel): Promise<void> {
|
||||||
|
await this.shares.updateOne(
|
||||||
|
{ id: share.projectId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
token: share.token,
|
||||||
|
blocks: share.blocks,
|
||||||
|
createdAt: share.createdAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(projectId: string): Promise<void> {
|
||||||
|
await this.shares.deleteOne({ id: projectId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toShare(doc: DashboardShareDoc): DashboardShareReadModel {
|
||||||
|
return {
|
||||||
|
projectId: doc.id,
|
||||||
|
token: doc.token,
|
||||||
|
blocks: (doc.blocks ?? []) as SharedBlock[],
|
||||||
|
createdAt: doc.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ export interface PushSubscriptionDoc {
|
|||||||
keys: { p256dh: string; auth: string };
|
keys: { p256dh: string; auth: string };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
export interface DashboardShareDoc {
|
||||||
|
/** The project's id. */
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
blocks: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
export interface CalendarFeedDoc {
|
export interface CalendarFeedDoc {
|
||||||
/** The user's id. */
|
/** The user's id. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -103,6 +110,7 @@ export const ReadModels = {
|
|||||||
Automation: 'Automation',
|
Automation: 'Automation',
|
||||||
AutomationRun: 'AutomationRun',
|
AutomationRun: 'AutomationRun',
|
||||||
CalendarFeed: 'CalendarFeed',
|
CalendarFeed: 'CalendarFeed',
|
||||||
|
DashboardShare: 'DashboardShare',
|
||||||
PushSubscription: 'PushSubscription',
|
PushSubscription: 'PushSubscription',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -370,6 +378,19 @@ class CalendarFeedDocument {
|
|||||||
export const CalendarFeedSchema =
|
export const CalendarFeedSchema =
|
||||||
SchemaFactory.createForClass(CalendarFeedDocument);
|
SchemaFactory.createForClass(CalendarFeedDocument);
|
||||||
|
|
||||||
|
/** One per project: the secret of its link for clients, and what it shows. */
|
||||||
|
@Schema({ ...LOOSE, collection: 'dashboard_shares' })
|
||||||
|
class DashboardShareDocument {
|
||||||
|
/** The project's id. */
|
||||||
|
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||||
|
@Prop({ type: String, required: true, unique: true }) token!: string;
|
||||||
|
@Prop({ type: [String] }) blocks!: string[];
|
||||||
|
@Prop({ type: String }) createdAt!: string;
|
||||||
|
}
|
||||||
|
export const DashboardShareSchema = SchemaFactory.createForClass(
|
||||||
|
DashboardShareDocument,
|
||||||
|
);
|
||||||
|
|
||||||
/** The devices where each person turned push on. */
|
/** The devices where each person turned push on. */
|
||||||
@Schema({ ...LOOSE, collection: 'push_subscriptions' })
|
@Schema({ ...LOOSE, collection: 'push_subscriptions' })
|
||||||
class PushSubscriptionDocument {
|
class PushSubscriptionDocument {
|
||||||
@@ -404,6 +425,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||||
{ name: ReadModels.MyView, schema: MyViewSchema },
|
{ name: ReadModels.MyView, schema: MyViewSchema },
|
||||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||||
|
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
NotificationMailer,
|
NotificationMailer,
|
||||||
NotificationsRepository,
|
NotificationsRepository,
|
||||||
CalendarFeedsRepository,
|
CalendarFeedsRepository,
|
||||||
|
DashboardSharesRepository,
|
||||||
PushSubscriptionsRepository,
|
PushSubscriptionsRepository,
|
||||||
PushSender,
|
PushSender,
|
||||||
SavedFiltersRepository,
|
SavedFiltersRepository,
|
||||||
@@ -72,6 +73,7 @@ import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
|||||||
import { RedisPresenceStore } from './redis/redis-presence.store';
|
import { RedisPresenceStore } from './redis/redis-presence.store';
|
||||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||||
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
||||||
|
import { MongoDashboardSharesRepository } from './mongo/dashboard-shares.repository';
|
||||||
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
||||||
import { WebPushSender } from './push/web-push.sender';
|
import { WebPushSender } from './push/web-push.sender';
|
||||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||||
@@ -141,6 +143,10 @@ const PORTS = [
|
|||||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||||
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
|
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
|
||||||
|
{
|
||||||
|
provide: DashboardSharesRepository,
|
||||||
|
useClass: MongoDashboardSharesRepository,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PushSubscriptionsRepository,
|
provide: PushSubscriptionsRepository,
|
||||||
useClass: MongoPushSubscriptionsRepository,
|
useClass: MongoPushSubscriptionsRepository,
|
||||||
|
|||||||
@@ -861,3 +861,22 @@ export class SaveMyViewInput {
|
|||||||
@Type(() => MyViewCriteriaInput)
|
@Type(() => MyViewCriteriaInput)
|
||||||
criteria!: MyViewCriteriaInput;
|
criteria!: MyViewCriteriaInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType({ description: "The project's link for clients" })
|
||||||
|
export class ShareDashboardInput {
|
||||||
|
@Field(() => ID) @IsUUID() projectId!: string;
|
||||||
|
@Field(() => [String], {
|
||||||
|
description: 'Blocks to show: flow, sprint, releases, epics, activity',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(10)
|
||||||
|
@IsString({ each: true })
|
||||||
|
blocks!: string[];
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
description: 'A new secret: the old link stops working',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
renew?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
OptionalAuth,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import { SharingService } from '@task-stream/api/tracker/application';
|
||||||
|
import { ShareDashboardInput } from './inputs';
|
||||||
|
import { DashboardShareGql, SharedDashboardGql } from './types';
|
||||||
|
|
||||||
|
/** The project's dashboard for clients, behind a secret link. */
|
||||||
|
@Resolver(() => DashboardShareGql)
|
||||||
|
export class SharingResolver {
|
||||||
|
constructor(private readonly sharing: SharingService) {}
|
||||||
|
|
||||||
|
@Query(() => DashboardShareGql, {
|
||||||
|
nullable: true,
|
||||||
|
description: "The project's link for clients, if any (owners and admins)",
|
||||||
|
})
|
||||||
|
dashboardShare(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
return this.sharing.share(me.id, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => DashboardShareGql, {
|
||||||
|
description:
|
||||||
|
'Turn the link on or change its blocks; renew for a new secret',
|
||||||
|
})
|
||||||
|
shareDashboard(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: ShareDashboardInput,
|
||||||
|
) {
|
||||||
|
return this.sharing.save(
|
||||||
|
me.id,
|
||||||
|
input.projectId,
|
||||||
|
input.blocks,
|
||||||
|
input.renew ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, { description: 'The link stops working' })
|
||||||
|
async unshareDashboard(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
await this.sharing.remove(me.id, projectId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptionalAuth()
|
||||||
|
@Query(() => SharedDashboardGql, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'What a link for clients shows, for anyone with it',
|
||||||
|
})
|
||||||
|
sharedDashboard(@Args('token') token: string) {
|
||||||
|
return this.sharing.shared(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1260,3 +1260,53 @@ export class HomeGql {
|
|||||||
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
|
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
|
||||||
@Field(() => [OnlineGql]) online!: OnlineGql[];
|
@Field(() => [OnlineGql]) online!: OnlineGql[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('DashboardShare', {
|
||||||
|
description: "The project's link for clients (owners and admins)",
|
||||||
|
})
|
||||||
|
export class DashboardShareGql {
|
||||||
|
@Field(() => ID) projectId!: string;
|
||||||
|
@Field({ description: 'Secret of the link /share/{token}' }) token!: string;
|
||||||
|
@Field(() => [String]) blocks!: string[];
|
||||||
|
@Field() createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SharedFlow', { description: 'Tickets in one status' })
|
||||||
|
export class SharedFlowGql {
|
||||||
|
@Field() status!: string;
|
||||||
|
@Field() name!: string;
|
||||||
|
@Field({ description: 'A colour name of the project settings' })
|
||||||
|
color!: string;
|
||||||
|
@Field(() => Int) count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SharedEpic', { description: 'An open epic and how far it got' })
|
||||||
|
export class SharedEpicGql {
|
||||||
|
@Field() key!: string;
|
||||||
|
@Field() title!: string;
|
||||||
|
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||||
|
@Field(() => Int) done!: number;
|
||||||
|
@Field(() => Int) total!: number;
|
||||||
|
@Field(() => Int) percent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SharedDashboard', {
|
||||||
|
description:
|
||||||
|
'What a link for clients shows: only the chosen blocks (the others are null)',
|
||||||
|
})
|
||||||
|
export class SharedDashboardGql {
|
||||||
|
@Field() projectKey!: string;
|
||||||
|
@Field() projectName!: string;
|
||||||
|
@Field() generatedAt!: string;
|
||||||
|
@Field(() => [String]) blocks!: string[];
|
||||||
|
@Field(() => [SharedFlowGql], { nullable: true })
|
||||||
|
flow!: SharedFlowGql[] | null;
|
||||||
|
@Field(() => SprintPulseGql, { nullable: true })
|
||||||
|
sprint!: SprintPulseGql | null;
|
||||||
|
@Field(() => [ReleaseForecastGql], { nullable: true })
|
||||||
|
releases!: ReleaseForecastGql[] | null;
|
||||||
|
@Field(() => [SharedEpicGql], { nullable: true })
|
||||||
|
epics!: SharedEpicGql[] | null;
|
||||||
|
@Field(() => [ActivityDayGql], { nullable: true })
|
||||||
|
activity!: ActivityDayGql[] | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1526,3 +1526,22 @@ export class AddGitRepositoryDto {
|
|||||||
@MaxLength(40)
|
@MaxLength(40)
|
||||||
name!: string;
|
name!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ShareDashboardDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
example: ['flow', 'sprint', 'releases'],
|
||||||
|
description: 'Blocks to show: flow, sprint, releases, epics, activity',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(10)
|
||||||
|
@IsString({ each: true })
|
||||||
|
blocks!: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'A new secret: the old link stops working',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
renew?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1305,3 +1305,46 @@ export class HomeDto {
|
|||||||
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
|
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
|
||||||
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
|
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class DashboardShareDto {
|
||||||
|
@ApiProperty() projectId!: string;
|
||||||
|
@ApiProperty({ description: 'Secret of the link /share/{token}' })
|
||||||
|
token!: string;
|
||||||
|
@ApiProperty({ type: [String], example: ['flow', 'sprint', 'releases'] })
|
||||||
|
blocks!: string[];
|
||||||
|
@ApiProperty() createdAt!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SharedFlowDto {
|
||||||
|
@ApiProperty() status!: string;
|
||||||
|
@ApiProperty() name!: string;
|
||||||
|
@ApiProperty({ description: 'A colour name of the project settings' })
|
||||||
|
color!: string;
|
||||||
|
@ApiProperty() count!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SharedEpicDto {
|
||||||
|
@ApiProperty() key!: string;
|
||||||
|
@ApiProperty() title!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) dueDate!: string | null;
|
||||||
|
@ApiProperty() done!: number;
|
||||||
|
@ApiProperty() total!: number;
|
||||||
|
@ApiProperty() percent!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SharedDashboardDto {
|
||||||
|
@ApiProperty() projectKey!: string;
|
||||||
|
@ApiProperty() projectName!: string;
|
||||||
|
@ApiProperty() generatedAt!: string;
|
||||||
|
@ApiProperty({ type: [String] }) blocks!: string[];
|
||||||
|
@ApiProperty({ type: [SharedFlowDto], nullable: true })
|
||||||
|
flow!: SharedFlowDto[] | null;
|
||||||
|
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||||
|
sprint!: SprintPulseDto | null;
|
||||||
|
@ApiProperty({ type: [ReleaseForecastDto], nullable: true })
|
||||||
|
releases!: ReleaseForecastDto[] | null;
|
||||||
|
@ApiProperty({ type: [SharedEpicDto], nullable: true })
|
||||||
|
epics!: SharedEpicDto[] | null;
|
||||||
|
@ApiProperty({ type: [ActivityDayDto], nullable: true })
|
||||||
|
activity!: ActivityDayDto[] | null;
|
||||||
|
}
|
||||||
|
|||||||
120
libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
Normal file
120
libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Put,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiNoContentResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import {
|
||||||
|
ApiPireonAuth,
|
||||||
|
CurrentUser,
|
||||||
|
Public,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import {
|
||||||
|
ApiCommandErrors,
|
||||||
|
ApiReadErrors,
|
||||||
|
} from '@task-stream/api/shared/presenters';
|
||||||
|
import { SharingService } from '@task-stream/api/tracker/application';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { ShareDashboardDto } from './dto/requests.dto';
|
||||||
|
import { DashboardShareDto, SharedDashboardDto } from './dto/responses.dto';
|
||||||
|
import { Refs } from './refs';
|
||||||
|
|
||||||
|
const PROJECT = ApiParam({
|
||||||
|
name: 'project',
|
||||||
|
description: 'Project id or key',
|
||||||
|
example: 'WEB',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project's dashboard for clients: a secret link (/share/{token}) that
|
||||||
|
* shows the blocks its owners and admins chose, without an account.
|
||||||
|
*/
|
||||||
|
@ApiTags('Projects')
|
||||||
|
@Controller()
|
||||||
|
export class SharingController {
|
||||||
|
constructor(
|
||||||
|
private readonly sharing: SharingService,
|
||||||
|
private readonly refs: Refs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('projects/:project/share')
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({ summary: "The project's link for clients, if any" })
|
||||||
|
@ApiOkResponse({ type: DashboardShareDto })
|
||||||
|
@ApiNoContentResponse({ description: 'It has none' })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async share(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const share = await this.sharing.share(
|
||||||
|
me.id,
|
||||||
|
await this.refs.projectId(me.id, project),
|
||||||
|
);
|
||||||
|
if (!share) response.status(204);
|
||||||
|
return share ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('projects/:project/share')
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Turn the link on or change its blocks (renew: a new secret)',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: DashboardShareDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async save(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Body() body: ShareDashboardDto,
|
||||||
|
) {
|
||||||
|
return this.sharing.save(
|
||||||
|
me.id,
|
||||||
|
await this.refs.projectId(me.id, project),
|
||||||
|
body.blocks,
|
||||||
|
body.renew ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('projects/:project/share')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({ summary: 'The link stops working' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async remove(@CurrentUser() me: User, @Param('project') project: string) {
|
||||||
|
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('shared/dashboards/:token')
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'What a link for clients shows (no session)',
|
||||||
|
description:
|
||||||
|
'Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: SharedDashboardDto })
|
||||||
|
async shared(@Param('token') token: string) {
|
||||||
|
const dashboard = /^[\w-]{20,100}$/.test(token)
|
||||||
|
? await this.sharing.shared(token)
|
||||||
|
: null;
|
||||||
|
if (!dashboard) throw new NotFoundException('Unknown link');
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,9 @@ import { SprintsController } from './http/sprints.controller';
|
|||||||
import { GitController } from './http/git.controller';
|
import { GitController } from './http/git.controller';
|
||||||
import { GitResolver } from './graphql/git.resolver';
|
import { GitResolver } from './graphql/git.resolver';
|
||||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||||
|
import { SharingResolver } from './graphql/sharing.resolver';
|
||||||
import { WebhooksController } from './http/webhooks.controller';
|
import { WebhooksController } from './http/webhooks.controller';
|
||||||
|
import { SharingController } from './http/sharing.controller';
|
||||||
import { TransferController } from './http/transfer.controller';
|
import { TransferController } from './http/transfer.controller';
|
||||||
import { AutomationResolver } from './graphql/automation.resolver';
|
import { AutomationResolver } from './graphql/automation.resolver';
|
||||||
|
|
||||||
@@ -65,6 +67,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
|||||||
SprintsController,
|
SprintsController,
|
||||||
GitController,
|
GitController,
|
||||||
WebhooksController,
|
WebhooksController,
|
||||||
|
SharingController,
|
||||||
TransferController,
|
TransferController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -84,6 +87,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
|||||||
SprintResolver,
|
SprintResolver,
|
||||||
GitResolver,
|
GitResolver,
|
||||||
WebhookResolver,
|
WebhookResolver,
|
||||||
|
SharingResolver,
|
||||||
AutomationResolver,
|
AutomationResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './lib/notifications';
|
export * from './lib/notifications';
|
||||||
export * from './lib/facades/projects.facade';
|
export * from './lib/facades/projects.facade';
|
||||||
export * from './lib/facades/calendar.facade';
|
export * from './lib/facades/calendar.facade';
|
||||||
|
export * from './lib/facades/sharing.facade';
|
||||||
export * from './lib/facades/push.facade';
|
export * from './lib/facades/push.facade';
|
||||||
export * from './lib/facades/project.facade';
|
export * from './lib/facades/project.facade';
|
||||||
export * from './lib/facades/board.facade';
|
export * from './lib/facades/board.facade';
|
||||||
|
|||||||
128
libs/web/tracker/application/src/lib/facades/sharing.facade.ts
Normal file
128
libs/web/tracker/application/src/lib/facades/sharing.facade.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
DestroyRef,
|
||||||
|
inject,
|
||||||
|
Injectable,
|
||||||
|
Provider,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
DashboardShare,
|
||||||
|
SharedBlock,
|
||||||
|
SharedDashboard,
|
||||||
|
SharingGateway,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { describeError } from '../notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project's link for clients, in Configuración: turn it on, pick its
|
||||||
|
* blocks, renew or drop it. Seldom touched, so it lives in this facade
|
||||||
|
* alone, without a store.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SharingFacade {
|
||||||
|
private readonly gateway = inject(SharingGateway);
|
||||||
|
private projectId: string | null = null;
|
||||||
|
|
||||||
|
readonly share = signal<DashboardShare | null>(null);
|
||||||
|
readonly loaded = signal(false);
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
load(projectId: string): void {
|
||||||
|
this.projectId = projectId;
|
||||||
|
this.run(this.gateway.share(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On (or new blocks); `renew` makes a new secret, the old link dies. */
|
||||||
|
save(blocks: SharedBlock[], renew = false): void {
|
||||||
|
if (this.projectId)
|
||||||
|
this.run(this.gateway.save(this.projectId, blocks, renew));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(): void {
|
||||||
|
if (!this.projectId) return;
|
||||||
|
this.busy.set(true);
|
||||||
|
this.gateway.remove(this.projectId).subscribe({
|
||||||
|
next: () => this.done(null),
|
||||||
|
error: (error) => this.failed(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(call: Observable<DashboardShare | null>): void {
|
||||||
|
this.busy.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
call.subscribe({
|
||||||
|
next: (share) => this.done(share),
|
||||||
|
error: (error) => this.failed(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private done(share: DashboardShare | null): void {
|
||||||
|
this.share.set(share);
|
||||||
|
this.loaded.set(true);
|
||||||
|
this.busy.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private failed(error: unknown): void {
|
||||||
|
this.loaded.set(true);
|
||||||
|
this.busy.set(false);
|
||||||
|
this.error.set(describeError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The link for clients, in Configuración: provided by the page. */
|
||||||
|
export function provideSharingFacade(): Provider[] {
|
||||||
|
return [SharingFacade];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every minute: there is no live connection without a session. */
|
||||||
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
/** What a client sees through the link, read again every minute. */
|
||||||
|
@Injectable()
|
||||||
|
export class SharedDashboardFacade {
|
||||||
|
private readonly gateway = inject(SharingGateway);
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
readonly dashboard = signal<SharedDashboard | null>(null);
|
||||||
|
readonly loaded = signal(false);
|
||||||
|
/** No project has this link (never had, renewed or dropped). */
|
||||||
|
readonly missing = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
inject(DestroyRef).onDestroy(() => this.stop());
|
||||||
|
}
|
||||||
|
|
||||||
|
open(token: string): void {
|
||||||
|
this.stop();
|
||||||
|
this.read(token);
|
||||||
|
this.timer = setInterval(() => this.read(token), REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(token: string): void {
|
||||||
|
this.gateway.shared(token).subscribe({
|
||||||
|
next: (dashboard) => {
|
||||||
|
this.dashboard.set(dashboard);
|
||||||
|
this.missing.set(!dashboard);
|
||||||
|
this.error.set(null);
|
||||||
|
this.loaded.set(true);
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.error.set(describeError(error));
|
||||||
|
this.loaded.set(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stop(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page for clients: provided by its route. */
|
||||||
|
export function provideSharedDashboardFacade(): Provider[] {
|
||||||
|
return [SharedDashboardFacade];
|
||||||
|
}
|
||||||
@@ -14,3 +14,4 @@ export * from './lib/models/due';
|
|||||||
export * from './lib/models/calendar';
|
export * from './lib/models/calendar';
|
||||||
export * from './lib/models/insights';
|
export * from './lib/models/insights';
|
||||||
export * from './lib/models/home';
|
export * from './lib/models/home';
|
||||||
|
export * from './lib/models/sharing';
|
||||||
|
|||||||
41
libs/web/tracker/domain/src/lib/models/sharing.ts
Normal file
41
libs/web/tracker/domain/src/lib/models/sharing.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ReleaseForecast, SprintPulse } from './insights';
|
||||||
|
|
||||||
|
/** The blocks a project can show to its clients, in the order they are shown. */
|
||||||
|
export const SHARED_BLOCKS = [
|
||||||
|
'flow',
|
||||||
|
'sprint',
|
||||||
|
'releases',
|
||||||
|
'epics',
|
||||||
|
'activity',
|
||||||
|
] as const;
|
||||||
|
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
|
||||||
|
|
||||||
|
/** The project's link for clients: its secret and what it shows. */
|
||||||
|
export interface DashboardShare {
|
||||||
|
projectId: string;
|
||||||
|
token: string;
|
||||||
|
blocks: SharedBlock[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a client sees through the link (blocks not chosen come as null). */
|
||||||
|
export interface SharedDashboard {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
generatedAt: string;
|
||||||
|
blocks: SharedBlock[];
|
||||||
|
flow: { status: string; name: string; color: string; count: number }[] | null;
|
||||||
|
sprint: SprintPulse | null;
|
||||||
|
releases: ReleaseForecast[] | null;
|
||||||
|
epics:
|
||||||
|
| {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
activity: { day: string; count: number }[] | null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Home } from '../models/home';
|
import { Home } from '../models/home';
|
||||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||||
|
import {
|
||||||
|
DashboardShare,
|
||||||
|
SharedBlock,
|
||||||
|
SharedDashboard,
|
||||||
|
} from '../models/sharing';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -368,6 +373,21 @@ export abstract class CalendarGateway {
|
|||||||
abstract revoke(): Observable<void>;
|
abstract revoke(): Observable<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The project's dashboard for clients, behind a secret link. */
|
||||||
|
export abstract class SharingGateway {
|
||||||
|
/** The project's link, if it has one (owners and admins). */
|
||||||
|
abstract share(projectId: string): Observable<DashboardShare | null>;
|
||||||
|
/** Turns it on or changes its blocks; `renew` makes a new secret. */
|
||||||
|
abstract save(
|
||||||
|
projectId: string,
|
||||||
|
blocks: SharedBlock[],
|
||||||
|
renew: boolean,
|
||||||
|
): Observable<DashboardShare>;
|
||||||
|
abstract remove(projectId: string): Observable<void>;
|
||||||
|
/** What a link shows, for anyone (null: no such link). */
|
||||||
|
abstract shared(token: string): Observable<SharedDashboard | null>;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class NotificationsGateway {
|
export abstract class NotificationsGateway {
|
||||||
abstract mine(
|
abstract mine(
|
||||||
unreadOnly: boolean,
|
unreadOnly: boolean,
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
DashboardShare,
|
||||||
|
SharedBlock,
|
||||||
|
SharedDashboard,
|
||||||
|
SharingGateway,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
DashboardShareDocument,
|
||||||
|
ShareDashboardDocument,
|
||||||
|
SharedDashboardDocument,
|
||||||
|
UnshareDashboardDocument,
|
||||||
|
} from '../graphql/generated';
|
||||||
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GraphqlSharingGateway extends SharingGateway {
|
||||||
|
private readonly gql = inject(GraphqlOperations);
|
||||||
|
|
||||||
|
share(projectId: string): Observable<DashboardShare | null> {
|
||||||
|
return this.gql
|
||||||
|
.query(DashboardShareDocument, { projectId })
|
||||||
|
.pipe(map((d) => (d.dashboardShare as DashboardShare | null) ?? null));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(
|
||||||
|
projectId: string,
|
||||||
|
blocks: SharedBlock[],
|
||||||
|
renew: boolean,
|
||||||
|
): Observable<DashboardShare> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(ShareDashboardDocument, { input: { projectId, blocks, renew } })
|
||||||
|
.pipe(map((d) => d.shareDashboard as DashboardShare));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(projectId: string): Observable<void> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(UnshareDashboardDocument, { projectId })
|
||||||
|
.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
shared(token: string): Observable<SharedDashboard | null> {
|
||||||
|
return this.gql
|
||||||
|
.query(SharedDashboardDocument, { token })
|
||||||
|
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -329,6 +329,15 @@ export type SavedFilterCriteriaInput = {
|
|||||||
unassigned?: boolean | null | undefined;
|
unassigned?: boolean | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The project's link for clients */
|
||||||
|
export type ShareDashboardInput = {
|
||||||
|
/** Blocks to show: flow, sprint, releases, epics, activity */
|
||||||
|
blocks: Array<string>;
|
||||||
|
projectId: string;
|
||||||
|
/** A new secret: the old link stops working */
|
||||||
|
renew?: boolean | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
|
/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
|
||||||
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
|
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
|
||||||
|
|
||||||
@@ -2437,6 +2446,98 @@ export type DeleteMyViewMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type DeleteMyViewMutation = { deleteMyView: boolean };
|
export type DeleteMyViewMutation = { deleteMyView: boolean };
|
||||||
|
|
||||||
|
export type DashboardShareFieldsFragment = {
|
||||||
|
projectId: string;
|
||||||
|
token: string;
|
||||||
|
blocks: Array<string>;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardShareQueryVariables = Exact<{
|
||||||
|
projectId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type DashboardShareQuery = {
|
||||||
|
dashboardShare: {
|
||||||
|
projectId: string;
|
||||||
|
token: string;
|
||||||
|
blocks: Array<string>;
|
||||||
|
createdAt: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShareDashboardMutationVariables = Exact<{
|
||||||
|
input: ShareDashboardInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ShareDashboardMutation = {
|
||||||
|
shareDashboard: {
|
||||||
|
projectId: string;
|
||||||
|
token: string;
|
||||||
|
blocks: Array<string>;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnshareDashboardMutationVariables = Exact<{
|
||||||
|
projectId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type UnshareDashboardMutation = { unshareDashboard: boolean };
|
||||||
|
|
||||||
|
export type SharedDashboardQueryVariables = Exact<{
|
||||||
|
token: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SharedDashboardQuery = {
|
||||||
|
sharedDashboard: {
|
||||||
|
projectKey: string;
|
||||||
|
projectName: string;
|
||||||
|
generatedAt: string;
|
||||||
|
blocks: Array<string>;
|
||||||
|
flow: Array<{
|
||||||
|
status: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
}> | null;
|
||||||
|
sprint: {
|
||||||
|
sprintId: string;
|
||||||
|
name: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalDays: number;
|
||||||
|
daysLeft: number;
|
||||||
|
tickets: number;
|
||||||
|
doneTickets: number;
|
||||||
|
points: number;
|
||||||
|
donePoints: number;
|
||||||
|
pacePerDay: number | null;
|
||||||
|
neededPerDay: number | null;
|
||||||
|
behind: boolean;
|
||||||
|
} | null;
|
||||||
|
releases: Array<{
|
||||||
|
releaseId: string;
|
||||||
|
name: string;
|
||||||
|
targetDate: string | null;
|
||||||
|
total: number;
|
||||||
|
finished: number;
|
||||||
|
likelyDate: string | null;
|
||||||
|
safeDate: string | null;
|
||||||
|
verdict: string;
|
||||||
|
}> | null;
|
||||||
|
epics: Array<{
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
dueDate: string | null;
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
}> | null;
|
||||||
|
activity: Array<{ day: string; count: number }> | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SprintFieldsFragment = {
|
export type SprintFieldsFragment = {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -3948,6 +4049,28 @@ export const SavedFilterFieldsFragmentDoc = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
|
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
|
||||||
|
export const DashboardShareFieldsFragmentDoc = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShare' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>;
|
||||||
export const SprintFieldsFragmentDoc = {
|
export const SprintFieldsFragmentDoc = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -14377,6 +14500,391 @@ export const DeleteMyViewDocument = {
|
|||||||
DeleteMyViewMutation,
|
DeleteMyViewMutation,
|
||||||
DeleteMyViewMutationVariables
|
DeleteMyViewMutationVariables
|
||||||
>;
|
>;
|
||||||
|
export const DashboardShareDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShare' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'dashboardShare' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShare' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<DashboardShareQuery, DashboardShareQueryVariables>;
|
||||||
|
export const ShareDashboardDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'ShareDashboard' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'ShareDashboardInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'shareDashboard' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'DashboardShare' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
ShareDashboardMutation,
|
||||||
|
ShareDashboardMutationVariables
|
||||||
|
>;
|
||||||
|
export const UnshareDashboardDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'UnshareDashboard' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'unshareDashboard' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
UnshareDashboardMutation,
|
||||||
|
UnshareDashboardMutationVariables
|
||||||
|
>;
|
||||||
|
export const SharedDashboardDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'SharedDashboard' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'token' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'String' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sharedDashboard' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'token' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'token' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectName' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'generatedAt' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'flow' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'status' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'color' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sprint' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'sprintId' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'startDate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'endDate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'totalDays' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'daysLeft' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'tickets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'doneTickets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'points' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'donePoints' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'pacePerDay' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'neededPerDay' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'behind' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'releases' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'releaseId' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'targetDate' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'finished' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'likelyDate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'safeDate' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'verdict' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'epics' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'dueDate' },
|
||||||
|
},
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'percent' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'activity' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
SharedDashboardQuery,
|
||||||
|
SharedDashboardQueryVariables
|
||||||
|
>;
|
||||||
export const SprintsDocument = {
|
export const SprintsDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
fragment DashboardShareFields on DashboardShare {
|
||||||
|
projectId
|
||||||
|
token
|
||||||
|
blocks
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
query DashboardShare($projectId: ID!) {
|
||||||
|
dashboardShare(projectId: $projectId) {
|
||||||
|
...DashboardShareFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation ShareDashboard($input: ShareDashboardInput!) {
|
||||||
|
shareDashboard(input: $input) {
|
||||||
|
...DashboardShareFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UnshareDashboard($projectId: ID!) {
|
||||||
|
unshareDashboard(projectId: $projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
query SharedDashboard($token: String!) {
|
||||||
|
sharedDashboard(token: $token) {
|
||||||
|
projectKey
|
||||||
|
projectName
|
||||||
|
generatedAt
|
||||||
|
blocks
|
||||||
|
flow {
|
||||||
|
status
|
||||||
|
name
|
||||||
|
color
|
||||||
|
count
|
||||||
|
}
|
||||||
|
sprint {
|
||||||
|
sprintId
|
||||||
|
name
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
totalDays
|
||||||
|
daysLeft
|
||||||
|
tickets
|
||||||
|
doneTickets
|
||||||
|
points
|
||||||
|
donePoints
|
||||||
|
pacePerDay
|
||||||
|
neededPerDay
|
||||||
|
behind
|
||||||
|
}
|
||||||
|
releases {
|
||||||
|
releaseId
|
||||||
|
name
|
||||||
|
targetDate
|
||||||
|
total
|
||||||
|
finished
|
||||||
|
likelyDate
|
||||||
|
safeDate
|
||||||
|
verdict
|
||||||
|
}
|
||||||
|
epics {
|
||||||
|
key
|
||||||
|
title
|
||||||
|
dueDate
|
||||||
|
done
|
||||||
|
total
|
||||||
|
percent
|
||||||
|
}
|
||||||
|
activity {
|
||||||
|
day
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
WebhooksGateway,
|
WebhooksGateway,
|
||||||
TransferGateway,
|
TransferGateway,
|
||||||
AutomationsGateway,
|
AutomationsGateway,
|
||||||
|
SharingGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -44,6 +45,7 @@ import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
|||||||
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
||||||
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
|
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
|
||||||
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
|
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
|
||||||
|
import { GraphqlSharingGateway } from './adapters/graphql-sharing.gateway';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The adapters the app shell needs from the start (the command palette, the
|
* The adapters the app shell needs from the start (the command palette, the
|
||||||
@@ -79,5 +81,6 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
||||||
{ provide: TransferGateway, useClass: HttpTransferGateway },
|
{ provide: TransferGateway, useClass: HttpTransferGateway },
|
||||||
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
|
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
|
||||||
|
{ provide: SharingGateway, useClass: GraphqlSharingGateway },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { GitIntegrationComponent } from '../ui/git-integration';
|
|||||||
import { WebhooksComponent } from '../ui/webhooks';
|
import { WebhooksComponent } from '../ui/webhooks';
|
||||||
import { TransferComponent } from '../ui/transfer';
|
import { TransferComponent } from '../ui/transfer';
|
||||||
import { AutomationsComponent } from '../ui/automations';
|
import { AutomationsComponent } from '../ui/automations';
|
||||||
|
import { ClientShareComponent } from '../ui/client-share';
|
||||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||||
import {
|
import {
|
||||||
CustomFieldType,
|
CustomFieldType,
|
||||||
@@ -58,7 +59,7 @@ const SECTIONS: SettingsSection[] = [
|
|||||||
{
|
{
|
||||||
id: 'general',
|
id: 'general',
|
||||||
label: 'General',
|
label: 'General',
|
||||||
hint: 'Quién ve el proyecto y si el público puede proponer cosas.',
|
hint: 'Quién ve el proyecto, si el público puede proponer cosas y el panel para clientes.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'workflow',
|
id: 'workflow',
|
||||||
@@ -143,6 +144,7 @@ function idFrom(name: string): string {
|
|||||||
WebhooksComponent,
|
WebhooksComponent,
|
||||||
TransferComponent,
|
TransferComponent,
|
||||||
AutomationsComponent,
|
AutomationsComponent,
|
||||||
|
ClientShareComponent,
|
||||||
CdkDropList,
|
CdkDropList,
|
||||||
CdkDrag,
|
CdkDrag,
|
||||||
CdkDragHandle,
|
CdkDragHandle,
|
||||||
@@ -257,6 +259,10 @@ function idFrom(name: string): string {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (facade.canManage()) {
|
||||||
|
<ts-client-share [projectId]="project.id" />
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (draft(); as d) {
|
@if (draft(); as d) {
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
untracked,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui';
|
||||||
|
import {
|
||||||
|
provideSharedDashboardFacade,
|
||||||
|
SharedDashboardFacade,
|
||||||
|
} from '@task-stream/web/tracker/application';
|
||||||
|
import { tone } from '../ui/copy';
|
||||||
|
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
|
||||||
|
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
||||||
|
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /share/:token — a project's dashboard for its clients, without an
|
||||||
|
* account: only the blocks its owners chose, read again every minute.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-shared-dashboard-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
PiEmptyStateComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
SprintPulseComponent,
|
||||||
|
ReleaseForecastComponent,
|
||||||
|
ActivityHeatmapComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
@if (shared.missing()) {
|
||||||
|
<pi-empty-state
|
||||||
|
icon="lock"
|
||||||
|
title="Este enlace ya no funciona"
|
||||||
|
description="Puede que lo hayan renovado o retirado. Pide uno nuevo a quien te lo dio."
|
||||||
|
/>
|
||||||
|
} @else if (shared.dashboard(); as d) {
|
||||||
|
<header class="ts-page-header">
|
||||||
|
<div>
|
||||||
|
<p class="ts-eyebrow">{{ d.projectKey }}</p>
|
||||||
|
<h1>{{ d.projectName }}</h1>
|
||||||
|
<p>
|
||||||
|
Estado del proyecto · actualizado a las {{ time(d.generatedAt) }} ·
|
||||||
|
se actualiza solo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ts-shared">
|
||||||
|
@if (d.flow; as flow) {
|
||||||
|
<section
|
||||||
|
class="ts-panel ts-shared__wide"
|
||||||
|
aria-labelledby="flow-title"
|
||||||
|
>
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="flow-title">Flujo</h2>
|
||||||
|
<span class="ts-muted">{{ total() }} tickets</span>
|
||||||
|
</header>
|
||||||
|
@if (total()) {
|
||||||
|
<div
|
||||||
|
class="ts-flow__bar"
|
||||||
|
role="img"
|
||||||
|
[attr.aria-label]="flowLabel()"
|
||||||
|
>
|
||||||
|
@for (s of flow; track s.status) {
|
||||||
|
@if (s.count) {
|
||||||
|
<span
|
||||||
|
[style.--tone]="toneOf(s.color)"
|
||||||
|
[style.flex-grow]="s.count"
|
||||||
|
[attr.title]="s.name + ': ' + s.count"
|
||||||
|
></span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<ul class="ts-flow__legend">
|
||||||
|
@for (s of flow; track s.status) {
|
||||||
|
<li
|
||||||
|
[style.--tone]="toneOf(s.color)"
|
||||||
|
[class.is-empty]="!s.count"
|
||||||
|
>
|
||||||
|
<span class="ts-flow__count">{{ s.count }}</span>
|
||||||
|
{{ s.name }}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="ts-muted">Todavía no hay tickets.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@if (d.blocks.includes('sprint')) {
|
||||||
|
@if (d.sprint; as sprint) {
|
||||||
|
<ts-sprint-pulse [sprint]="sprint" [linked]="false" />
|
||||||
|
} @else {
|
||||||
|
<section class="ts-panel">
|
||||||
|
<h2 class="ts-shared__title">Sprint</h2>
|
||||||
|
<p class="ts-muted">No hay un sprint en curso.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (d.releases; as releases) {
|
||||||
|
@if (releases.length) {
|
||||||
|
<ts-release-forecast [releases]="releases" [linked]="false" />
|
||||||
|
} @else {
|
||||||
|
<section class="ts-panel">
|
||||||
|
<h2 class="ts-shared__title">Próximas releases</h2>
|
||||||
|
<p class="ts-muted">Ninguna planificada.</p>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (d.epics; as epics) {
|
||||||
|
<section class="ts-panel" aria-labelledby="epics-title">
|
||||||
|
<header class="ts-panel__head">
|
||||||
|
<h2 id="epics-title">Épicas</h2>
|
||||||
|
<span class="ts-muted">Abiertas, por fecha</span>
|
||||||
|
</header>
|
||||||
|
@if (epics.length) {
|
||||||
|
<ul class="ts-epics">
|
||||||
|
@for (e of epics; track e.key) {
|
||||||
|
<li>
|
||||||
|
<span class="ts-epics__title">{{ e.title }}</span>
|
||||||
|
<span class="ts-epics__meta"
|
||||||
|
>{{ e.done }}/{{ e.total }}
|
||||||
|
@if (e.dueDate) {
|
||||||
|
· {{ date(e.dueDate) }}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ts-epics__bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
[attr.aria-valuenow]="e.percent"
|
||||||
|
[attr.aria-label]="'Progreso de ' + e.title"
|
||||||
|
><span [style.width.%]="e.percent"></span
|
||||||
|
></span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<p class="ts-muted">Ninguna épica abierta.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
@if (d.activity; as activity) {
|
||||||
|
<ts-activity-heatmap [days]="activity" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="ts-shared__footer">Hecho con TaskStream</p>
|
||||||
|
} @else if (shared.error(); as error) {
|
||||||
|
<pi-empty-state
|
||||||
|
icon="exclamation"
|
||||||
|
title="No se pudo cargar"
|
||||||
|
[description]="error"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<pi-skeleton shape="block" height="12rem" />
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.ts-shared {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.ts-shared__wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.ts-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.ts-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ts-muted {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-shared__title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ts-flow__bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
height: 2rem;
|
||||||
|
span {
|
||||||
|
flex-basis: 0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: var(--tone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-flow__legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin: 0.875rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
li {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
background: var(--tone);
|
||||||
|
}
|
||||||
|
&.is-empty {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-flow__count {
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ts-epics {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-epics__title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
.ts-epics__meta {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ts-epics__bar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
overflow: hidden;
|
||||||
|
span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-shared__footer {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
providers: [provideSharedDashboardFacade()],
|
||||||
|
})
|
||||||
|
export class SharedDashboardPage {
|
||||||
|
/** Route param (`withComponentInputBinding`). */
|
||||||
|
readonly token = input.required<string>();
|
||||||
|
protected readonly shared = inject(SharedDashboardFacade);
|
||||||
|
|
||||||
|
protected readonly total = computed(() =>
|
||||||
|
(this.shared.dashboard()?.flow ?? []).reduce((n, s) => n + s.count, 0),
|
||||||
|
);
|
||||||
|
protected readonly flowLabel = computed(() =>
|
||||||
|
(this.shared.dashboard()?.flow ?? [])
|
||||||
|
.map((s) => `${s.name}: ${s.count}`)
|
||||||
|
.join(', '),
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const token = this.token();
|
||||||
|
untracked(() => this.shared.open(token));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected readonly toneOf = tone;
|
||||||
|
|
||||||
|
protected time(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString('es', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected date(day: string): string {
|
||||||
|
return new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ export const trackerRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/explore.page').then((m) => m.ExplorePage),
|
import('./pages/explore.page').then((m) => m.ExplorePage),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// A project's dashboard for its clients: the token is the access.
|
||||||
|
path: 'share/:token',
|
||||||
|
title: 'Estado del proyecto · TaskStream',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/shared-dashboard.page').then(
|
||||||
|
(m) => m.SharedDashboardPage,
|
||||||
|
),
|
||||||
|
},
|
||||||
// The Spanish paths, from links shared before.
|
// The Spanish paths, from links shared before.
|
||||||
{ path: 'cartera', redirectTo: 'portfolio' },
|
{ path: 'cartera', redirectTo: 'portfolio' },
|
||||||
{ path: 'explorar', redirectTo: 'explore' },
|
{ path: 'explorar', redirectTo: 'explore' },
|
||||||
|
|||||||
283
libs/web/tracker/presenters/src/lib/ui/client-share.ts
Normal file
283
libs/web/tracker/presenters/src/lib/ui/client-share.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
untracked,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { PiCheckboxComponent } from '@pi/ui';
|
||||||
|
import { ToastService } from '@task-stream/web/shared/ui';
|
||||||
|
import {
|
||||||
|
provideSharingFacade,
|
||||||
|
SharingFacade,
|
||||||
|
} from '@task-stream/web/tracker/application';
|
||||||
|
import { SHARED_BLOCKS, SharedBlock } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
/** What each block shows, for whoever picks them. */
|
||||||
|
const BLOCK_LABEL: Record<SharedBlock, { label: string; hint: string }> = {
|
||||||
|
flow: { label: 'Flujo', hint: 'Cuántos tickets hay en cada estado' },
|
||||||
|
sprint: { label: 'Sprint en curso', hint: 'Días que quedan y lo terminado' },
|
||||||
|
releases: {
|
||||||
|
label: 'Próximas releases',
|
||||||
|
hint: 'Con su fecha probable al ritmo real',
|
||||||
|
},
|
||||||
|
epics: { label: 'Épicas', hint: 'Las abiertas, con su progreso' },
|
||||||
|
activity: { label: 'Actividad', hint: 'Movimientos por día, 12 semanas' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What a new link shows until someone picks otherwise. */
|
||||||
|
const DEFAULT_BLOCKS: SharedBlock[] = ['flow', 'sprint', 'releases'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The project's dashboard for clients: a read-only link, without an
|
||||||
|
* account, showing only the blocks picked here.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-client-share',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiCheckboxComponent],
|
||||||
|
providers: [provideSharingFacade()],
|
||||||
|
host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'share-title' },
|
||||||
|
template: `
|
||||||
|
<div class="ts-share__head">
|
||||||
|
<div>
|
||||||
|
<h3 id="share-title">Panel para clientes</h3>
|
||||||
|
<p class="ts-muted">
|
||||||
|
Un enlace de solo lectura, sin cuenta, con los bloques que elijas. No
|
||||||
|
enseña personas ni tickets sueltos (salvo las épicas).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="ts-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
[checked]="!!sharing.share()"
|
||||||
|
[disabled]="!sharing.loaded() || sharing.busy()"
|
||||||
|
(change)="toggle($any($event.target).checked)"
|
||||||
|
/>
|
||||||
|
<span class="ts-switch__track" aria-hidden="true"></span>
|
||||||
|
<span>{{ sharing.share() ? 'Compartido' : 'Sin compartir' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (sharing.share(); as share) {
|
||||||
|
<fieldset class="ts-share__blocks" [disabled]="sharing.busy()">
|
||||||
|
<legend>Qué se ve</legend>
|
||||||
|
@for (block of blocks; track block) {
|
||||||
|
<div class="ts-share__block">
|
||||||
|
<pi-checkbox
|
||||||
|
[label]="label(block).label"
|
||||||
|
[checked]="share.blocks.includes(block)"
|
||||||
|
[disabled]="
|
||||||
|
share.blocks.length === 1 && share.blocks.includes(block)
|
||||||
|
"
|
||||||
|
(checkedChange)="setBlock(block, $event)"
|
||||||
|
/>
|
||||||
|
<span class="ts-muted">{{ label(block).hint }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
<div class="ts-share__link">
|
||||||
|
<input
|
||||||
|
class="ts-share__url"
|
||||||
|
readonly
|
||||||
|
aria-label="Enlace para clientes"
|
||||||
|
[value]="url()"
|
||||||
|
(focus)="$any($event.target).select()"
|
||||||
|
/>
|
||||||
|
<button type="button" class="ts-plain-link" (click)="copy()">
|
||||||
|
Copiar
|
||||||
|
</button>
|
||||||
|
<a class="ts-plain-link" [href]="url()" target="_blank" rel="noopener"
|
||||||
|
>Abrir</a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-plain-link"
|
||||||
|
[disabled]="sharing.busy()"
|
||||||
|
(click)="renew()"
|
||||||
|
>
|
||||||
|
Renovar enlace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="ts-muted ts-share__note">
|
||||||
|
Se actualiza solo cada minuto. Renovar hace un enlace nuevo y el
|
||||||
|
anterior deja de funcionar.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (sharing.error(); as error) {
|
||||||
|
<p class="ts-share__error" role="alert">{{ error }}</p>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ts-share__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
> div {
|
||||||
|
flex: 1 1 26rem;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-share__blocks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
|
||||||
|
gap: 0.5rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
legend {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-share__block {
|
||||||
|
display: grid;
|
||||||
|
.ts-muted {
|
||||||
|
margin-left: 2.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-share__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ts-share__url {
|
||||||
|
flex: 1 1 20rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--ts-line-strong);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ts-sunken);
|
||||||
|
color: var(--ts-ink);
|
||||||
|
font-family: var(--ts-font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ts-share__note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ts-share__error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ts-c-red);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.ts-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
input:focus-visible + .ts-switch__track {
|
||||||
|
outline: 2px solid var(--ts-brand);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
input:disabled ~ * {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-switch__track {
|
||||||
|
position: relative;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ts-line-strong);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ts-surface);
|
||||||
|
box-shadow: var(--ts-shadow);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-switch input:checked + .ts-switch__track {
|
||||||
|
background: var(--ts-brand);
|
||||||
|
&::after {
|
||||||
|
transform: translateX(1.125rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ClientShareComponent {
|
||||||
|
readonly projectId = input.required<string>();
|
||||||
|
protected readonly sharing = inject(SharingFacade);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
protected readonly blocks = SHARED_BLOCKS;
|
||||||
|
|
||||||
|
protected readonly url = computed(() => {
|
||||||
|
const share = this.sharing.share();
|
||||||
|
return share ? `${location.origin}/share/${share.token}` : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const projectId = this.projectId();
|
||||||
|
untracked(() => this.sharing.load(projectId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected label(block: SharedBlock) {
|
||||||
|
return BLOCK_LABEL[block];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggle(on: boolean): void {
|
||||||
|
if (on) this.sharing.save(DEFAULT_BLOCKS);
|
||||||
|
else this.sharing.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setBlock(block: SharedBlock, on: boolean): void {
|
||||||
|
const current = this.sharing.share()?.blocks ?? [];
|
||||||
|
const next = on ? [...current, block] : current.filter((b) => b !== block);
|
||||||
|
if (next.length) this.sharing.save(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renew(): void {
|
||||||
|
const blocks = this.sharing.share()?.blocks;
|
||||||
|
if (blocks) this.sharing.save(blocks, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected copy(): void {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(this.url())
|
||||||
|
.then(() => this.toast.success('Enlace copiado'))
|
||||||
|
.catch(() => this.toast.error('No se pudo copiar'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,11 @@ export function verdictOf(release: ReleaseForecast): {
|
|||||||
<ul class="ts-issues ts-forecast">
|
<ul class="ts-issues ts-forecast">
|
||||||
@for (row of rows(); track row.releaseId) {
|
@for (row of rows(); track row.releaseId) {
|
||||||
<li>
|
<li>
|
||||||
|
@if (linked()) {
|
||||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||||
|
} @else {
|
||||||
|
<span class="ts-title">{{ row.name }}</span>
|
||||||
|
}
|
||||||
<span class="ts-verdict" [style.--tone]="row.tone">{{
|
<span class="ts-verdict" [style.--tone]="row.tone">{{
|
||||||
row.label
|
row.label
|
||||||
}}</span>
|
}}</span>
|
||||||
@@ -110,6 +114,8 @@ export function verdictOf(release: ReleaseForecast): {
|
|||||||
})
|
})
|
||||||
export class ReleaseForecastComponent {
|
export class ReleaseForecastComponent {
|
||||||
readonly releases = input.required<ReleaseForecast[]>();
|
readonly releases = input.required<ReleaseForecast[]>();
|
||||||
|
/** False outside the app (the page for clients): no links into it. */
|
||||||
|
readonly linked = input(true);
|
||||||
|
|
||||||
protected readonly rows = computed(() =>
|
protected readonly rows = computed(() =>
|
||||||
this.releases().map((r) => ({
|
this.releases().map((r) => ({
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ const decimal = (n: number) =>
|
|||||||
@if (paceText(); as pace) {
|
@if (paceText(); as pace) {
|
||||||
{{ pace }}
|
{{ pace }}
|
||||||
}
|
}
|
||||||
|
@if (linked()) {
|
||||||
<a routerLink="../sprints">Ver el sprint →</a>
|
<a routerLink="../sprints">Ver el sprint →</a>
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
@@ -105,6 +107,8 @@ const decimal = (n: number) =>
|
|||||||
})
|
})
|
||||||
export class SprintPulseComponent {
|
export class SprintPulseComponent {
|
||||||
readonly sprint = input.required<SprintPulse>();
|
readonly sprint = input.required<SprintPulse>();
|
||||||
|
/** False outside the app (the page for clients): no links into it. */
|
||||||
|
readonly linked = input(true);
|
||||||
|
|
||||||
protected readonly when = computed(
|
protected readonly when = computed(
|
||||||
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user