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

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

View File

@@ -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(
{

View File

@@ -0,0 +1,15 @@
import { DashboardShareReadModel } from '@task-stream/api/tracker/domain';
/**
* One link for clients per project. The token is kept as it is (not hashed)
* so the link can be copied again; it only reads the blocks chosen.
*/
export abstract class DashboardSharesRepository {
abstract findByProject(
projectId: string,
): Promise<DashboardShareReadModel | null>;
abstract findByToken(token: string): Promise<DashboardShareReadModel | null>;
/** Replaces the project's link, if any. */
abstract save(share: DashboardShareReadModel): Promise<void>;
abstract remove(projectId: string): Promise<void>;
}

View File

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

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

View File

@@ -0,0 +1,255 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { assertThat } from '@task-stream/api/shared/domain';
import {
DashboardShareReadModel,
DEFAULT_PROJECT_SETTINGS,
SHARED_BLOCKS,
SharedBlock,
SharedDashboardReadModel,
SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { computeInsights, heatmapStart } from '../analytics/insights';
import { WriteAccess } from '../commands/write-access';
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
import { FindActivityRepository } from '../ports/find-activity.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
/** Epics a client sees, nearest due date first. */
const EPICS = 8;
/** Only known blocks, each once, in the order they are shown. */
function cleanBlocks(blocks: string[]): SharedBlock[] {
const chosen = new Set(blocks);
return SHARED_BLOCKS.filter((b) => chosen.has(b));
}
/**
* Turns the project's link for clients on (or changes its blocks). With
* `renew`, or the first time, a new secret: the old link stops working.
*/
export class ShareDashboardCommand extends Command<DashboardShareReadModel> {
constructor(
public readonly projectId: string,
public readonly actorId: string,
public readonly blocks: string[],
public readonly renew: boolean,
) {
super();
}
}
/** The project's link for clients stops working. */
export class UnshareDashboardCommand extends Command<void> {
constructor(
public readonly projectId: string,
public readonly actorId: string,
) {
super();
}
}
/** The project's link for clients, for who manages it. */
export class GetDashboardShareQuery extends Query<DashboardShareReadModel | null> {
constructor(
public readonly projectId: string,
public readonly viewerId: string,
) {
super();
}
}
/** What a link shows, read now; null for an unknown token. */
export class GetSharedDashboardQuery extends Query<SharedDashboardReadModel | null> {
constructor(public readonly token: string) {
super();
}
}
@CommandHandler(ShareDashboardCommand)
export class ShareDashboardCommandHandler
implements ICommandHandler<ShareDashboardCommand>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly access: WriteAccess,
) {}
async execute(
command: ShareDashboardCommand,
): Promise<DashboardShareReadModel> {
(await this.access.project(command.projectId)).assertCanManage(
command.actorId,
);
const blocks = cleanBlocks(command.blocks);
assertThat(blocks.length > 0, 'Pick at least one block');
const current = await this.shares.findByProject(command.projectId);
const fresh = !current || command.renew;
const share: DashboardShareReadModel = {
projectId: command.projectId,
// 32 random bytes: nobody guesses it.
token: fresh ? randomBytes(32).toString('base64url') : current.token,
blocks,
createdAt: fresh ? new Date().toISOString() : current.createdAt,
};
await this.shares.save(share);
return share;
}
}
@CommandHandler(UnshareDashboardCommand)
export class UnshareDashboardCommandHandler
implements ICommandHandler<UnshareDashboardCommand>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly access: WriteAccess,
) {}
async execute(command: UnshareDashboardCommand): Promise<void> {
(await this.access.project(command.projectId)).assertManages(
command.actorId,
);
await this.shares.remove(command.projectId);
}
}
@QueryHandler(GetDashboardShareQuery)
export class GetDashboardShareQueryHandler
implements
IQueryHandler<GetDashboardShareQuery, DashboardShareReadModel | null>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly access: WriteAccess,
) {}
async execute(
query: GetDashboardShareQuery,
): Promise<DashboardShareReadModel | null> {
(await this.access.project(query.projectId)).assertManages(query.viewerId);
return this.shares.findByProject(query.projectId);
}
}
/**
* The chosen blocks of a project, for anyone with the link: computed like
* the dashboard, then cut down to what outsiders may see.
*/
@QueryHandler(GetSharedDashboardQuery)
export class GetSharedDashboardQueryHandler
implements
IQueryHandler<GetSharedDashboardQuery, SharedDashboardReadModel | null>
{
constructor(
private readonly shares: DashboardSharesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly findSprints: FindSprintsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findActivity: FindActivityRepository,
) {}
async execute(
query: GetSharedDashboardQuery,
): Promise<SharedDashboardReadModel | null> {
const share = await this.shares.findByToken(query.token);
if (!share) return null;
const project = await this.findProjects.findById(share.projectId);
if (!project) return null;
const has = (block: SharedBlock) => share.blocks.includes(block);
const now = new Date();
const [issues, timeline, sprints, releases, epics, activity] =
await Promise.all([
this.findIssues.findByProject(project.id),
this.findTimeline.findByProject(project.id),
this.findSprints.findByProject(project.id),
this.findReleases.findByProject(project.id),
has('epics') ? this.findIssues.epics(project.id) : [],
has('activity')
? this.findActivity.dailyCounts(
[project.id],
heatmapStart(now.getTime()),
)
: [],
]);
const settings = project.settings ?? DEFAULT_PROJECT_SETTINGS;
const reading = computeInsights({
issues,
timeline,
settings,
activeSprint:
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
sprints,
releases,
members: project.members,
since: null,
mentions: 0,
activity,
now,
});
const live = issues.filter((i) => !i.trashedAt);
return {
projectKey: project.key,
projectName: project.name,
generatedAt: now.toISOString(),
blocks: share.blocks,
flow: has('flow')
? settings.statuses
.filter((s) => !s.archived)
.map((s) => ({
status: s.id,
name: s.name,
color: s.color,
count: live.filter((i) => i.status === s.id).length,
}))
: null,
sprint: has('sprint') ? reading.sprint : null,
releases: has('releases') ? reading.releases : null,
epics: has('epics')
? epics
.filter(
(e) =>
!FINISHED.includes(e.issue.statusCategory) &&
!e.issue.trashedAt,
)
.sort((a, b) =>
(a.issue.dueDate ?? '9999').localeCompare(
b.issue.dueDate ?? '9999',
),
)
.slice(0, EPICS)
.map((e) => ({
key: e.issue.key,
title: e.issue.title,
dueDate: e.issue.dueDate,
done: e.progress.finished,
total: e.progress.total,
percent: e.progress.percent,
}))
: null,
activity: has('activity') ? reading.heatmap : null,
};
}
}
export const SHARING_HANDLERS = [
ShareDashboardCommandHandler,
UnshareDashboardCommandHandler,
GetDashboardShareQueryHandler,
GetSharedDashboardQueryHandler,
];

View File

@@ -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,

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { DashboardSharesRepository } from '@task-stream/api/tracker/application';
import {
DashboardShareReadModel,
SharedBlock,
} from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { DashboardShareDoc, ReadModels } from './read-model.schemas';
@Injectable()
export class MongoDashboardSharesRepository extends DashboardSharesRepository {
constructor(
@InjectModel(ReadModels.DashboardShare)
private readonly shares: Model<DashboardShareDoc>,
) {
super();
}
async findByProject(
projectId: string,
): Promise<DashboardShareReadModel | null> {
const doc = await this.shares
.findOne({ id: projectId })
.lean<DashboardShareDoc>();
return doc ? toShare(doc) : null;
}
async findByToken(token: string): Promise<DashboardShareReadModel | null> {
const doc = await this.shares.findOne({ token }).lean<DashboardShareDoc>();
return doc ? toShare(doc) : null;
}
async save(share: DashboardShareReadModel): Promise<void> {
await this.shares.updateOne(
{ id: share.projectId },
{
$set: {
token: share.token,
blocks: share.blocks,
createdAt: share.createdAt,
},
},
{ upsert: true },
);
}
async remove(projectId: string): Promise<void> {
await this.shares.deleteOne({ id: projectId });
}
}
function toShare(doc: DashboardShareDoc): DashboardShareReadModel {
return {
projectId: doc.id,
token: doc.token,
blocks: (doc.blocks ?? []) as SharedBlock[],
createdAt: doc.createdAt,
};
}

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

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

View File

@@ -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,
],
})