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

@@ -0,0 +1,55 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
DashboardShareReadModel,
SharedDashboardReadModel,
} from '@task-stream/api/tracker/domain';
import {
GetDashboardShareQuery,
GetSharedDashboardQuery,
ShareDashboardCommand,
UnshareDashboardCommand,
} from './sharing/dashboard-shares';
/** The project's dashboard for clients, behind a secret link. */
@Injectable()
export class SharingService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
/** The project's link, if it has one (owners and admins). */
share(
viewerId: string,
projectId: string,
): Promise<DashboardShareReadModel | null> {
return this.queryBus.execute(
new GetDashboardShareQuery(projectId, viewerId),
);
}
/** Turns the link on or changes its blocks; `renew` makes a new secret. */
save(
actorId: string,
projectId: string,
blocks: string[],
renew = false,
): Promise<DashboardShareReadModel> {
return this.commands.execute(
new ShareDashboardCommand(projectId, actorId, blocks, renew),
);
}
remove(actorId: string, projectId: string): Promise<void> {
return this.commands.execute(
new UnshareDashboardCommand(projectId, actorId),
);
}
/** What a link shows now, or null if no project has it. */
shared(token: string): Promise<SharedDashboardReadModel | null> {
return this.queryBus.execute(new GetSharedDashboardQuery(token));
}
}