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

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

View File

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

View File

@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
NotFoundException,
Param,
Put,
Res,
} from '@nestjs/common';
import {
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
Public,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { SharingService } from '@task-stream/api/tracker/application';
import type { Response } from 'express';
import { ShareDashboardDto } from './dto/requests.dto';
import { DashboardShareDto, SharedDashboardDto } from './dto/responses.dto';
import { Refs } from './refs';
const PROJECT = ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
});
/**
* The project's dashboard for clients: a secret link (/share/{token}) that
* shows the blocks its owners and admins chose, without an account.
*/
@ApiTags('Projects')
@Controller()
export class SharingController {
constructor(
private readonly sharing: SharingService,
private readonly refs: Refs,
) {}
@Get('projects/:project/share')
@ApiPireonAuth()
@PROJECT
@ApiOperation({ summary: "The project's link for clients, if any" })
@ApiOkResponse({ type: DashboardShareDto })
@ApiNoContentResponse({ description: 'It has none' })
@ApiReadErrors()
async share(
@CurrentUser() me: User,
@Param('project') project: string,
@Res({ passthrough: true }) response: Response,
) {
const share = await this.sharing.share(
me.id,
await this.refs.projectId(me.id, project),
);
if (!share) response.status(204);
return share ?? undefined;
}
@Put('projects/:project/share')
@ApiPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Turn the link on or change its blocks (renew: a new secret)',
})
@ApiOkResponse({ type: DashboardShareDto })
@ApiCommandErrors()
async save(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: ShareDashboardDto,
) {
return this.sharing.save(
me.id,
await this.refs.projectId(me.id, project),
body.blocks,
body.renew ?? false,
);
}
@Delete('projects/:project/share')
@HttpCode(204)
@ApiPireonAuth()
@PROJECT
@ApiOperation({ summary: 'The link stops working' })
@ApiNoContentResponse()
@ApiCommandErrors()
async remove(@CurrentUser() me: User, @Param('project') project: string) {
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project));
}
@Get('shared/dashboards/:token')
@Public()
@ApiOperation({
summary: 'What a link for clients shows (no session)',
description:
'Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.',
})
@ApiOkResponse({ type: SharedDashboardDto })
async shared(@Param('token') token: string) {
const dashboard = /^[\w-]{20,100}$/.test(token)
? await this.sharing.shared(token)
: null;
if (!dashboard) throw new NotFoundException('Unknown link');
return dashboard;
}
}