On the releases page, «Página pública» (owners and admins) opens a
secret link to a page with the release's notes, readable without an
account, also for private projects: the notes written, or the draft
from its finished tickets while there are none (without repeating the
title the page already shows). The link can be copied, renewed (the old
one stops working) or withdrawn. Stored in Mongo (`release_note_shares`).
API: shareReleaseNotes / unshareReleaseNotes / releaseNotesShare,
publicReleaseNotes(token); GET/PUT/DELETE /api/releases/{id}/public-notes,
GET /api/shared/release-notes/{token}. Web: /notes/:token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
5.2 KiB
TypeScript
189 lines
5.2 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
HttpCode,
|
|
NotFoundException,
|
|
Param,
|
|
Put,
|
|
Query,
|
|
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,
|
|
PublicReleaseNotesDto,
|
|
ReleaseNotesShareDto,
|
|
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('releases/:release/public-notes')
|
|
@ApiPireonAuth()
|
|
@ApiParam({ name: 'release', format: 'uuid' })
|
|
@ApiOperation({
|
|
summary: "A release's public notes link, if any (owners and admins)",
|
|
})
|
|
@ApiOkResponse({ type: ReleaseNotesShareDto })
|
|
@ApiReadErrors()
|
|
async releaseNotesShare(
|
|
@CurrentUser() me: User,
|
|
@Param('release') release: string,
|
|
@Res({ passthrough: true }) res: Response,
|
|
) {
|
|
const share = await this.sharing.releaseNotesShare(me.id, release);
|
|
if (!share) res.status(204);
|
|
return share ?? undefined;
|
|
}
|
|
|
|
@Put('releases/:release/public-notes')
|
|
@ApiPireonAuth()
|
|
@ApiParam({ name: 'release', format: 'uuid' })
|
|
@ApiOperation({
|
|
summary: "Turn a release's public notes page on (owners and admins)",
|
|
description:
|
|
'The page shows the notes written, or the ones drawn from its finished tickets. ?renew=true makes a new link.',
|
|
})
|
|
@ApiOkResponse({ type: ReleaseNotesShareDto })
|
|
@ApiCommandErrors()
|
|
shareReleaseNotes(
|
|
@CurrentUser() me: User,
|
|
@Param('release') release: string,
|
|
@Query('renew') renew?: string,
|
|
) {
|
|
return this.sharing.shareReleaseNotes(me.id, release, renew === 'true');
|
|
}
|
|
|
|
@Delete('releases/:release/public-notes')
|
|
@HttpCode(204)
|
|
@ApiPireonAuth()
|
|
@ApiParam({ name: 'release', format: 'uuid' })
|
|
@ApiOperation({ summary: "A release's public notes link stops working" })
|
|
@ApiNoContentResponse()
|
|
@ApiCommandErrors()
|
|
async unshareReleaseNotes(
|
|
@CurrentUser() me: User,
|
|
@Param('release') release: string,
|
|
) {
|
|
await this.sharing.unshareReleaseNotes(me.id, release);
|
|
}
|
|
|
|
@Get('shared/release-notes/:token')
|
|
@Public()
|
|
@ApiOperation({ summary: "A release's notes, for anyone with its link" })
|
|
@ApiOkResponse({ type: PublicReleaseNotesDto })
|
|
async publicReleaseNotes(@Param('token') token: string) {
|
|
const notes = /^[\w-]{20,100}$/.test(token)
|
|
? await this.sharing.publicReleaseNotes(token)
|
|
: null;
|
|
if (!notes) throw new NotFoundException('Unknown link');
|
|
return notes;
|
|
}
|
|
|
|
@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;
|
|
}
|
|
}
|