diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts
index 1673542..23814e2 100644
--- a/apps/api/e2e/tracker.e2e-spec.ts
+++ b/apps/api/e2e/tracker.e2e-spec.ts
@@ -55,7 +55,7 @@ describe('task-stream (e2e)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test reads arbitrary GraphQL shapes
async function gql>(
- token: string,
+ token: string | null,
query: string,
variables?: object,
): Promise {
@@ -63,7 +63,7 @@ describe('task-stream (e2e)', () => {
method: 'POST',
headers: {
'content-type': 'application/json',
- authorization: `Bearer ${token}`,
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
},
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 () => {
const key = 'H' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', { key, name: key });
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 234d0c8..5783ffe 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -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": {
"get": {
"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": {
"type": "object",
"properties": {
diff --git a/apps/api/schema.gql b/apps/api/schema.gql
index e886175..14af85d 100644
--- a/apps/api/schema.gql
+++ b/apps/api/schema.gql
@@ -465,6 +465,16 @@ input CustomFieldValueInput {
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"""
type DevLink {
at: String!
@@ -1010,6 +1020,9 @@ type Mutation {
"""Put a ticket in a sprint (null: back to the backlog)"""
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)"""
startSprint(sprintId: ID!): CommandResult!
@@ -1033,6 +1046,9 @@ type Mutation {
unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
+ """The link stops working"""
+ unshareDashboard(projectId: ID!): Boolean!
+
"""Push off on this device"""
unsubscribePush(endpoint: String!): Boolean!
@@ -1456,6 +1472,9 @@ type Query {
): [Activity!]!
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
"""
@@ -1532,6 +1551,9 @@ type Query {
"""My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]!
+
+ """What a link for clients shows, for anyone with it"""
+ sharedDashboard(token: String!): SharedDashboard
sprint(id: ID!): Sprint!
"""
@@ -1707,6 +1729,50 @@ input SavedFilterCriteriaInput {
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"""
type Sprint {
burndown: SprintBurndown!
diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts
index dcd9138..85691c7 100644
--- a/apps/mcp/src/app/api/taskstream.schema.ts
+++ b/apps/mcp/src/app/api/taskstream.schema.ts
@@ -1636,6 +1636,45 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -3424,6 +3463,59 @@ export interface components {
/** @description Paused webhooks get nothing */
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: {
/** @description Line of the file (the header is 1) */
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: {
parameters: {
query?: never;
diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts
index 8d6cc02..208848b 100644
--- a/apps/web/src/app/app.routes.spec.ts
+++ b/apps/web/src/app/app.routes.spec.ts
@@ -24,6 +24,7 @@ describe('appRoutes', () => {
'',
'portfolio',
'explore',
+ 'share/:token',
'cartera',
'explorar',
'p/:key',
@@ -31,10 +32,12 @@ describe('appRoutes', () => {
expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toContain(authGuard);
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.
- expect(children[3].redirectTo).toBe('portfolio');
- expect(children[4].redirectTo).toBe('explore');
+ expect(children[4].redirectTo).toBe('portfolio');
+ expect(children[5].redirectTo).toBe('explore');
});
it('gives each page its own state, not the root', async () => {
diff --git a/docs/03-api.md b/docs/03-api.md
index 0a03673..3ef5e5a 100644
--- a/docs/03-api.md
+++ b/docs/03-api.md
@@ -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
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
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}` |
| 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) |
+| 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`) |
| 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) |
diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md
index f608ffb..0462427 100644
--- a/docs/04-cliente-web.md
+++ b/docs/04-cliente-web.md
@@ -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
`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.
+- `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
`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`.
@@ -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/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/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/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 |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
diff --git a/docs/10-futuro.md b/docs/10-futuro.md
index 95cefaa..b3fb632 100644
--- a/docs/10-futuro.md
+++ b/docs/10-futuro.md
@@ -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) |
| 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) |
-| 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 |
## 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.
- 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.
+- 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.
diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx
index e9dff2b..aeb5187 100644
Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ
diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf
index 955add3..31e2afc 100644
Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ
diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts
index 9105819..fbb5855 100644
--- a/libs/api/tracker/application/src/index.ts
+++ b/libs/api/tracker/application/src/index.ts
@@ -21,6 +21,8 @@ export * from './lib/transfer.service';
export * from './lib/automations.service';
export * from './lib/calendar.service';
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/automation-rules';
export * from './lib/transfer/import-export';
diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts
index 73eeeb3..5653f6d 100644
--- a/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts
+++ b/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts
@@ -1,4 +1,5 @@
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
+import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
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,
- * 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
* releases go as the cascade saga deletes them.
*/
@@ -21,6 +22,7 @@ export class ProjectDeletedEventHandler
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly changeFeed: ChangeFeed,
private readonly gitIntegrations: GitIntegrationsRepository,
+ private readonly shares: DashboardSharesRepository,
) {}
async handle(event: ProjectDeletedEvent) {
@@ -28,6 +30,8 @@ export class ProjectDeletedEventHandler
await this.upsertMaterializedProjectRepository.remove(event.projectId);
// No webhook of it should be accepted any more.
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).
await this.changeFeed.publishProject(
{
diff --git a/libs/api/tracker/application/src/lib/ports/dashboard-shares.repository.ts b/libs/api/tracker/application/src/lib/ports/dashboard-shares.repository.ts
new file mode 100644
index 0000000..6ca9cdd
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/ports/dashboard-shares.repository.ts
@@ -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;
+ abstract findByToken(token: string): Promise;
+ /** Replaces the project's link, if any. */
+ abstract save(share: DashboardShareReadModel): Promise;
+ abstract remove(projectId: string): Promise;
+}
diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts
index 9fe64d3..b63479f 100644
--- a/libs/api/tracker/application/src/lib/ports/index.ts
+++ b/libs/api/tracker/application/src/lib/ports/index.ts
@@ -30,3 +30,4 @@ export * from './rate-limit.store';
export * from './calendar-feeds.repository';
export * from './push-sender';
export * from './push-subscriptions.repository';
+export * from './dashboard-shares.repository';
diff --git a/libs/api/tracker/application/src/lib/sharing.service.ts b/libs/api/tracker/application/src/lib/sharing.service.ts
new file mode 100644
index 0000000..a734013
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sharing.service.ts
@@ -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 {
+ 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 {
+ return this.commands.execute(
+ new ShareDashboardCommand(projectId, actorId, blocks, renew),
+ );
+ }
+
+ remove(actorId: string, projectId: string): Promise {
+ return this.commands.execute(
+ new UnshareDashboardCommand(projectId, actorId),
+ );
+ }
+
+ /** What a link shows now, or null if no project has it. */
+ shared(token: string): Promise {
+ return this.queryBus.execute(new GetSharedDashboardQuery(token));
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts b/libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
new file mode 100644
index 0000000..b391184
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ constructor(public readonly token: string) {
+ super();
+ }
+}
+
+@CommandHandler(ShareDashboardCommand)
+export class ShareDashboardCommandHandler
+ implements ICommandHandler
+{
+ constructor(
+ private readonly shares: DashboardSharesRepository,
+ private readonly access: WriteAccess,
+ ) {}
+
+ async execute(
+ command: ShareDashboardCommand,
+ ): Promise {
+ (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
+{
+ constructor(
+ private readonly shares: DashboardSharesRepository,
+ private readonly access: WriteAccess,
+ ) {}
+
+ async execute(command: UnshareDashboardCommand): Promise {
+ (await this.access.project(command.projectId)).assertManages(
+ command.actorId,
+ );
+ await this.shares.remove(command.projectId);
+ }
+}
+
+@QueryHandler(GetDashboardShareQuery)
+export class GetDashboardShareQueryHandler
+ implements
+ IQueryHandler
+{
+ constructor(
+ private readonly shares: DashboardSharesRepository,
+ private readonly access: WriteAccess,
+ ) {}
+
+ async execute(
+ query: GetDashboardShareQuery,
+ ): Promise {
+ (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
+{
+ 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 {
+ 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,
+];
diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts
index 6bb553f..740208e 100644
--- a/libs/api/tracker/application/src/lib/tracker-application.module.ts
+++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts
@@ -49,6 +49,8 @@ import { PresenceService } from './presence.service';
import { AUTOMATION_HANDLERS } from './automations/automations';
import { CALENDAR_HANDLERS } from './calendar/calendar';
import { CalendarService } from './calendar.service';
+import { SharingService } from './sharing.service';
+import { SHARING_HANDLERS } from './sharing/dashboard-shares';
import { AutomationsEventHandler } from './automations/automations.event-handler';
/** Notifications: derived from events, kept and dismissed per person. */
@@ -66,6 +68,7 @@ const NOTIFICATIONS = [
const SERVICES = [
CalendarService,
+ SharingService,
ProjectsService,
IssuesService,
CommentsService,
@@ -110,6 +113,7 @@ const SERVICES = [
...TRANSFER_HANDLERS,
...AUTOMATION_HANDLERS,
...CALENDAR_HANDLERS,
+ ...SHARING_HANDLERS,
AutomationsEventHandler,
],
exports: SERVICES,
diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts
index b8498f4..eba7855 100644
--- a/libs/api/tracker/domain/src/lib/read-models/index.ts
+++ b/libs/api/tracker/domain/src/lib/read-models/index.ts
@@ -11,3 +11,4 @@ export * from './saved-filter.read-model';
export * from './sprint.read-model';
export * from './insights.read-model';
export * from './home.read-model';
+export * from './shared-dashboard.read-model';
diff --git a/libs/api/tracker/domain/src/lib/read-models/shared-dashboard.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/shared-dashboard.read-model.ts
new file mode 100644
index 0000000..a5af4c1
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/read-models/shared-dashboard.read-model.ts
@@ -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;
+}
diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/dashboard-shares.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/dashboard-shares.repository.ts
new file mode 100644
index 0000000..c270f89
--- /dev/null
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/dashboard-shares.repository.ts
@@ -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,
+ ) {
+ super();
+ }
+
+ async findByProject(
+ projectId: string,
+ ): Promise {
+ const doc = await this.shares
+ .findOne({ id: projectId })
+ .lean();
+ return doc ? toShare(doc) : null;
+ }
+
+ async findByToken(token: string): Promise {
+ const doc = await this.shares.findOne({ token }).lean();
+ return doc ? toShare(doc) : null;
+ }
+
+ async save(share: DashboardShareReadModel): Promise {
+ await this.shares.updateOne(
+ { id: share.projectId },
+ {
+ $set: {
+ token: share.token,
+ blocks: share.blocks,
+ createdAt: share.createdAt,
+ },
+ },
+ { upsert: true },
+ );
+ }
+
+ async remove(projectId: string): Promise {
+ 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,
+ };
+}
diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts
index d8b732a..8a3718b 100644
--- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts
@@ -74,6 +74,13 @@ export interface PushSubscriptionDoc {
keys: { p256dh: string; auth: string };
createdAt: string;
}
+export interface DashboardShareDoc {
+ /** The project's id. */
+ id: string;
+ token: string;
+ blocks: string[];
+ createdAt: string;
+}
export interface CalendarFeedDoc {
/** The user's id. */
id: string;
@@ -103,6 +110,7 @@ export const ReadModels = {
Automation: 'Automation',
AutomationRun: 'AutomationRun',
CalendarFeed: 'CalendarFeed',
+ DashboardShare: 'DashboardShare',
PushSubscription: 'PushSubscription',
} as const;
@@ -370,6 +378,19 @@ class CalendarFeedDocument {
export const CalendarFeedSchema =
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. */
@Schema({ ...LOOSE, collection: 'push_subscriptions' })
class PushSubscriptionDocument {
@@ -404,6 +425,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
{ name: ReadModels.MyView, schema: MyViewSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
+ { name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
];
diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
index b76cb65..5c6f826 100644
--- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
+++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
@@ -8,6 +8,7 @@ import {
NotificationMailer,
NotificationsRepository,
CalendarFeedsRepository,
+ DashboardSharesRepository,
PushSubscriptionsRepository,
PushSender,
SavedFiltersRepository,
@@ -72,6 +73,7 @@ import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
import { RedisPresenceStore } from './redis/redis-presence.store';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
+import { MongoDashboardSharesRepository } from './mongo/dashboard-shares.repository';
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
import { WebPushSender } from './push/web-push.sender';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
@@ -141,6 +143,10 @@ const PORTS = [
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
+ {
+ provide: DashboardSharesRepository,
+ useClass: MongoDashboardSharesRepository,
+ },
{
provide: PushSubscriptionsRepository,
useClass: MongoPushSubscriptionsRepository,
diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
index 3e5e3e3..c61cf62 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
@@ -861,3 +861,22 @@ export class SaveMyViewInput {
@Type(() => 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;
+}
diff --git a/libs/api/tracker/presenters/src/lib/graphql/sharing.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/sharing.resolver.ts
new file mode 100644
index 0000000..65eac7d
--- /dev/null
+++ b/libs/api/tracker/presenters/src/lib/graphql/sharing.resolver.ts
@@ -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);
+ }
+}
diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts
index 2c0d8b7..caba088 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/types.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts
@@ -1260,3 +1260,53 @@ export class HomeGql {
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
@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;
+}
diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
index bfc9539..c9ef52b 100644
--- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
+++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
@@ -1526,3 +1526,22 @@ export class AddGitRepositoryDto {
@MaxLength(40)
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;
+}
diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
index 08a681b..9615a55 100644
--- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
+++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
@@ -1305,3 +1305,46 @@ export class HomeDto {
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
@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;
+}
diff --git a/libs/api/tracker/presenters/src/lib/http/sharing.controller.ts b/libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
new file mode 100644
index 0000000..508e432
--- /dev/null
+++ b/libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
@@ -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;
+ }
+}
diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
index e3a5ec2..8dc13d8 100644
--- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
+++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
@@ -28,7 +28,9 @@ import { SprintsController } from './http/sprints.controller';
import { GitController } from './http/git.controller';
import { GitResolver } from './graphql/git.resolver';
import { WebhookResolver } from './graphql/webhook.resolver';
+import { SharingResolver } from './graphql/sharing.resolver';
import { WebhooksController } from './http/webhooks.controller';
+import { SharingController } from './http/sharing.controller';
import { TransferController } from './http/transfer.controller';
import { AutomationResolver } from './graphql/automation.resolver';
@@ -65,6 +67,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
SprintsController,
GitController,
WebhooksController,
+ SharingController,
TransferController,
],
providers: [
@@ -84,6 +87,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
SprintResolver,
GitResolver,
WebhookResolver,
+ SharingResolver,
AutomationResolver,
],
})
diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts
index 1ebaf4a..fb79870 100644
--- a/libs/web/tracker/application/src/index.ts
+++ b/libs/web/tracker/application/src/index.ts
@@ -1,6 +1,7 @@
export * from './lib/notifications';
export * from './lib/facades/projects.facade';
export * from './lib/facades/calendar.facade';
+export * from './lib/facades/sharing.facade';
export * from './lib/facades/push.facade';
export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
diff --git a/libs/web/tracker/application/src/lib/facades/sharing.facade.ts b/libs/web/tracker/application/src/lib/facades/sharing.facade.ts
new file mode 100644
index 0000000..08408dc
--- /dev/null
+++ b/libs/web/tracker/application/src/lib/facades/sharing.facade.ts
@@ -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(null);
+ readonly loaded = signal(false);
+ readonly busy = signal(false);
+ readonly error = signal(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): 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 | null = null;
+
+ readonly dashboard = signal(null);
+ readonly loaded = signal(false);
+ /** No project has this link (never had, renewed or dropped). */
+ readonly missing = signal(false);
+ readonly error = signal(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];
+}
diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts
index 5a0ade4..9081384 100644
--- a/libs/web/tracker/domain/src/index.ts
+++ b/libs/web/tracker/domain/src/index.ts
@@ -14,3 +14,4 @@ export * from './lib/models/due';
export * from './lib/models/calendar';
export * from './lib/models/insights';
export * from './lib/models/home';
+export * from './lib/models/sharing';
diff --git a/libs/web/tracker/domain/src/lib/models/sharing.ts b/libs/web/tracker/domain/src/lib/models/sharing.ts
new file mode 100644
index 0000000..e8d73bf
--- /dev/null
+++ b/libs/web/tracker/domain/src/lib/models/sharing.ts
@@ -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;
+}
diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts
index 73bd737..610465c 100644
--- a/libs/web/tracker/domain/src/lib/ports/gateways.ts
+++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts
@@ -1,5 +1,10 @@
import { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights';
+import {
+ DashboardShare,
+ SharedBlock,
+ SharedDashboard,
+} from '../models/sharing';
import { Observable } from 'rxjs';
import {
Activity,
@@ -368,6 +373,21 @@ export abstract class CalendarGateway {
abstract revoke(): Observable;
}
+/** 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;
+ /** Turns it on or changes its blocks; `renew` makes a new secret. */
+ abstract save(
+ projectId: string,
+ blocks: SharedBlock[],
+ renew: boolean,
+ ): Observable;
+ abstract remove(projectId: string): Observable;
+ /** What a link shows, for anyone (null: no such link). */
+ abstract shared(token: string): Observable;
+}
+
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,
diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sharing.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sharing.gateway.ts
new file mode 100644
index 0000000..431f25d
--- /dev/null
+++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sharing.gateway.ts
@@ -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 {
+ return this.gql
+ .query(DashboardShareDocument, { projectId })
+ .pipe(map((d) => (d.dashboardShare as DashboardShare | null) ?? null));
+ }
+
+ save(
+ projectId: string,
+ blocks: SharedBlock[],
+ renew: boolean,
+ ): Observable {
+ return this.gql
+ .mutate(ShareDashboardDocument, { input: { projectId, blocks, renew } })
+ .pipe(map((d) => d.shareDashboard as DashboardShare));
+ }
+
+ remove(projectId: string): Observable {
+ return this.gql
+ .mutate(UnshareDashboardDocument, { projectId })
+ .pipe(map(() => undefined));
+ }
+
+ shared(token: string): Observable {
+ return this.gql
+ .query(SharedDashboardDocument, { token })
+ .pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
+ }
+}
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
index f469553..d2c5054 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
@@ -329,6 +329,15 @@ export type SavedFilterCriteriaInput = {
unassigned?: boolean | null | undefined;
};
+/** The project's link for clients */
+export type ShareDashboardInput = {
+ /** Blocks to show: flow, sprint, releases, epics, activity */
+ blocks: Array;
+ projectId: string;
+ /** A new secret: the old link stops working */
+ renew?: boolean | null | undefined;
+};
+
/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
@@ -2437,6 +2446,98 @@ export type DeleteMyViewMutationVariables = Exact<{
export type DeleteMyViewMutation = { deleteMyView: boolean };
+export type DashboardShareFieldsFragment = {
+ projectId: string;
+ token: string;
+ blocks: Array;
+ createdAt: string;
+};
+
+export type DashboardShareQueryVariables = Exact<{
+ projectId: string;
+}>;
+
+export type DashboardShareQuery = {
+ dashboardShare: {
+ projectId: string;
+ token: string;
+ blocks: Array;
+ createdAt: string;
+ } | null;
+};
+
+export type ShareDashboardMutationVariables = Exact<{
+ input: ShareDashboardInput;
+}>;
+
+export type ShareDashboardMutation = {
+ shareDashboard: {
+ projectId: string;
+ token: string;
+ blocks: Array;
+ 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;
+ 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 = {
id: string;
projectId: string;
@@ -3948,6 +4049,28 @@ export const SavedFilterFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode;
+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;
export const SprintFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -14377,6 +14500,391 @@ export const DeleteMyViewDocument = {
DeleteMyViewMutation,
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;
+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 = {
kind: 'Document',
definitions: [
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/sharing.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/sharing.graphql
new file mode 100644
index 0000000..5937549
--- /dev/null
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/sharing.graphql
@@ -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
+ }
+ }
+}
diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts
index 46f4bf6..130cb72 100644
--- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts
+++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts
@@ -20,6 +20,7 @@ import {
WebhooksGateway,
TransferGateway,
AutomationsGateway,
+ SharingGateway,
TrackerRealtimeGateway,
UsersGateway,
} 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 { HttpTransferGateway } from './adapters/http-transfer.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
@@ -79,5 +81,6 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
{ provide: TransferGateway, useClass: HttpTransferGateway },
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
+ { provide: SharingGateway, useClass: GraphqlSharingGateway },
]);
}
diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts
index b2e7ee8..332ea78 100644
--- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts
@@ -22,6 +22,7 @@ import { GitIntegrationComponent } from '../ui/git-integration';
import { WebhooksComponent } from '../ui/webhooks';
import { TransferComponent } from '../ui/transfer';
import { AutomationsComponent } from '../ui/automations';
+import { ClientShareComponent } from '../ui/client-share';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import {
CustomFieldType,
@@ -58,7 +59,7 @@ const SECTIONS: SettingsSection[] = [
{
id: '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',
@@ -143,6 +144,7 @@ function idFrom(name: string): string {
WebhooksComponent,
TransferComponent,
AutomationsComponent,
+ ClientShareComponent,
CdkDropList,
CdkDrag,
CdkDragHandle,
@@ -257,6 +259,10 @@ function idFrom(name: string): string {
}}
+
+ @if (facade.canManage()) {
+
+ }
}
@if (draft(); as d) {
diff --git a/libs/web/tracker/presenters/src/lib/pages/shared-dashboard.page.ts b/libs/web/tracker/presenters/src/lib/pages/shared-dashboard.page.ts
new file mode 100644
index 0000000..7c24bfe
--- /dev/null
+++ b/libs/web/tracker/presenters/src/lib/pages/shared-dashboard.page.ts
@@ -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()) {
+
+ } @else if (shared.dashboard(); as d) {
+
+
+
+ @if (d.flow; as flow) {
+
+
+ Flujo
+ {{ total() }} tickets
+
+ @if (total()) {
+
+ @for (s of flow; track s.status) {
+ @if (s.count) {
+
+ }
+ }
+
+
+ @for (s of flow; track s.status) {
+ -
+ {{ s.count }}
+ {{ s.name }}
+
+ }
+
+ } @else {
+ Todavía no hay tickets.
+ }
+
+ }
+ @if (d.blocks.includes('sprint')) {
+ @if (d.sprint; as sprint) {
+
+ } @else {
+
+ Sprint
+ No hay un sprint en curso.
+
+ }
+ }
+ @if (d.releases; as releases) {
+ @if (releases.length) {
+
+ } @else {
+
+ Próximas releases
+ Ninguna planificada.
+
+ }
+ }
+ @if (d.epics; as epics) {
+
+
+ Épicas
+ Abiertas, por fecha
+
+ @if (epics.length) {
+
+ @for (e of epics; track e.key) {
+ -
+ {{ e.title }}
+ {{ e.done }}/{{ e.total }}
+ @if (e.dueDate) {
+ · {{ date(e.dueDate) }}
+ }
+
+
+
+ }
+
+ } @else {
+ Ninguna épica abierta.
+ }
+
+ }
+ @if (d.activity; as activity) {
+
+ }
+
+
+ } @else if (shared.error(); as error) {
+
+ } @else {
+
+ }
+ `,
+ 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();
+ 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',
+ });
+ }
+}
diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts
index 5eaa7aa..6c09fea 100644
--- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts
+++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts
@@ -45,6 +45,15 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
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.
{ path: 'cartera', redirectTo: 'portfolio' },
{ path: 'explorar', redirectTo: 'explore' },
diff --git a/libs/web/tracker/presenters/src/lib/ui/client-share.ts b/libs/web/tracker/presenters/src/lib/ui/client-share.ts
new file mode 100644
index 0000000..e290e5a
--- /dev/null
+++ b/libs/web/tracker/presenters/src/lib/ui/client-share.ts
@@ -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 = {
+ 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: `
+
+
+
Panel para clientes
+
+ Un enlace de solo lectura, sin cuenta, con los bloques que elijas. No
+ enseña personas ni tickets sueltos (salvo las épicas).
+
+
+
+
+
+ @if (sharing.share(); as share) {
+
+
+
+
+
Abrir
+
+
+
+ Se actualiza solo cada minuto. Renovar hace un enlace nuevo y el
+ anterior deja de funcionar.
+
+ }
+ @if (sharing.error(); as error) {
+ {{ error }}
+ }
+ `,
+ 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();
+ 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'));
+ }
+}
diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts b/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts
index 44f4bee..1236919 100644
--- a/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts
+++ b/libs/web/tracker/presenters/src/lib/ui/insights/release-forecast.ts
@@ -61,7 +61,11 @@ export function verdictOf(release: ReleaseForecast): {
@for (row of rows(); track row.releaseId) {
-
- {{ row.name }}
+ @if (linked()) {
+ {{ row.name }}
+ } @else {
+ {{ row.name }}
+ }
{{
row.label
}}
@@ -110,6 +114,8 @@ export function verdictOf(release: ReleaseForecast): {
})
export class ReleaseForecastComponent {
readonly releases = input.required();
+ /** False outside the app (the page for clients): no links into it. */
+ readonly linked = input(true);
protected readonly rows = computed(() =>
this.releases().map((r) => ({
diff --git a/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts b/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
index 1d6089a..5f343d6 100644
--- a/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
+++ b/libs/web/tracker/presenters/src/lib/ui/insights/sprint-pulse.ts
@@ -52,7 +52,9 @@ const decimal = (n: number) =>
@if (paceText(); as pace) {
{{ pace }}
}
- Ver el sprint →
+ @if (linked()) {
+ Ver el sprint →
+ }
`,
styles: `
@@ -105,6 +107,8 @@ const decimal = (n: number) =>
})
export class SprintPulseComponent {
readonly sprint = input.required();
+ /** False outside the app (the page for clients): no links into it. */
+ readonly linked = input(true);
protected readonly when = computed(
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,