feat: a dashboard for clients, behind a secret link

Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.

REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 10:39:26 +02:00
parent 4dc673997d
commit 8fe581ff3e
44 changed files with 3299 additions and 12 deletions

View File

@@ -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';

View File

@@ -0,0 +1,41 @@
import { ReleaseForecast, SprintPulse } from './insights';
/** The blocks a project can show to its clients, in the order they are shown. */
export const SHARED_BLOCKS = [
'flow',
'sprint',
'releases',
'epics',
'activity',
] as const;
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
/** The project's link for clients: its secret and what it shows. */
export interface DashboardShare {
projectId: string;
token: string;
blocks: SharedBlock[];
createdAt: string;
}
/** What a client sees through the link (blocks not chosen come as null). */
export interface SharedDashboard {
projectKey: string;
projectName: string;
generatedAt: string;
blocks: SharedBlock[];
flow: { status: string; name: string; color: string; count: number }[] | null;
sprint: SprintPulse | null;
releases: ReleaseForecast[] | null;
epics:
| {
key: string;
title: string;
dueDate: string | null;
done: number;
total: number;
percent: number;
}[]
| null;
activity: { day: string; count: number }[] | null;
}

View File

@@ -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<void>;
}
/** The project's dashboard for clients, behind a secret link. */
export abstract class SharingGateway {
/** The project's link, if it has one (owners and admins). */
abstract share(projectId: string): Observable<DashboardShare | null>;
/** Turns it on or changes its blocks; `renew` makes a new secret. */
abstract save(
projectId: string,
blocks: SharedBlock[],
renew: boolean,
): Observable<DashboardShare>;
abstract remove(projectId: string): Observable<void>;
/** What a link shows, for anyone (null: no such link). */
abstract shared(token: string): Observable<SharedDashboard | null>;
}
export abstract class NotificationsGateway {
abstract mine(
unreadOnly: boolean,