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