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:
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
55
libs/api/tracker/application/src/lib/sharing.service.ts
Normal file
55
libs/api/tracker/application/src/lib/sharing.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
255
libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
Normal file
255
libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
Normal 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,
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user