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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user