feat: a public notes page per release, for clients
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>
This commit is contained in:
@@ -6,7 +6,12 @@ import {
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
import { ShareDashboardInput } from './inputs';
|
||||
import { DashboardShareGql, SharedDashboardGql } from './types';
|
||||
import {
|
||||
DashboardShareGql,
|
||||
PublicReleaseNotesGql,
|
||||
ReleaseNotesShareGql,
|
||||
SharedDashboardGql,
|
||||
} from './types';
|
||||
|
||||
/** The project's dashboard for clients, behind a secret link. */
|
||||
@Resolver(() => DashboardShareGql)
|
||||
@@ -49,6 +54,51 @@ export class SharingResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Query(() => ReleaseNotesShareGql, {
|
||||
nullable: true,
|
||||
description: "A release's public notes link, if any (owners and admins)",
|
||||
})
|
||||
releaseNotesShare(
|
||||
@CurrentUser() me: User,
|
||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||
) {
|
||||
return this.sharing.releaseNotesShare(me.id, releaseId);
|
||||
}
|
||||
|
||||
@Mutation(() => ReleaseNotesShareGql, {
|
||||
description:
|
||||
"Turn a release's public notes page on; renew makes a new link (the old one stops working)",
|
||||
})
|
||||
shareReleaseNotes(
|
||||
@CurrentUser() me: User,
|
||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||
@Args('renew', { defaultValue: false }) renew: boolean,
|
||||
) {
|
||||
return this.sharing.shareReleaseNotes(me.id, releaseId, renew);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: "A release's public notes link stops working",
|
||||
})
|
||||
async unshareReleaseNotes(
|
||||
@CurrentUser() me: User,
|
||||
@Args('releaseId', { type: () => ID }) releaseId: string,
|
||||
) {
|
||||
await this.sharing.unshareReleaseNotes(me.id, releaseId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => PublicReleaseNotesGql, {
|
||||
nullable: true,
|
||||
description: "A release's notes, for anyone with its link",
|
||||
})
|
||||
publicReleaseNotes(@Args('token') token: string) {
|
||||
return /^[\w-]{20,100}$/.test(token)
|
||||
? this.sharing.publicReleaseNotes(token)
|
||||
: null;
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => SharedDashboardGql, {
|
||||
nullable: true,
|
||||
|
||||
@@ -1454,3 +1454,31 @@ export class MyDayGql {
|
||||
@Field(() => MyDayLeftoverGql, { nullable: true })
|
||||
leftover!: MyDayLeftoverGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('ReleaseNotesShare', {
|
||||
description: "A release's public notes link (owners and admins)",
|
||||
})
|
||||
export class ReleaseNotesShareGql {
|
||||
@Field(() => ID) releaseId!: string;
|
||||
@Field({ description: 'The secret of the link: /notes/{token}' })
|
||||
token!: string;
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('PublicReleaseNotes', {
|
||||
description: "A release's notes, for anyone with its link",
|
||||
})
|
||||
export class PublicReleaseNotesGql {
|
||||
@Field() projectName!: string;
|
||||
@Field() projectKey!: string;
|
||||
@Field() releaseName!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field(() => ReleaseStatus) status!: ReleaseStatus;
|
||||
@Field(() => String, { nullable: true }) targetDate!: string | null;
|
||||
@Field(() => String, { nullable: true }) releasedAt!: string | null;
|
||||
@Field({ description: 'Markdown' }) notes!: string;
|
||||
@Field({
|
||||
description: 'False: drawn from its finished tickets (none written yet)',
|
||||
})
|
||||
written!: boolean;
|
||||
}
|
||||
|
||||
@@ -1481,3 +1481,27 @@ export class IssueReviewDto {
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
answeredAt!: string | null;
|
||||
}
|
||||
|
||||
export class ReleaseNotesShareDto {
|
||||
@ApiProperty({ format: 'uuid' }) releaseId!: string;
|
||||
@ApiProperty({ description: 'The secret of the link: /notes/{token}' })
|
||||
token!: string;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
}
|
||||
|
||||
export class PublicReleaseNotesDto {
|
||||
@ApiProperty() projectName!: string;
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty() releaseName!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ enum: ReleaseStatus, enumName: 'ReleaseStatus' })
|
||||
status!: ReleaseStatus;
|
||||
@ApiProperty({ type: String, nullable: true }) targetDate!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
releasedAt!: string | null;
|
||||
@ApiProperty({ description: 'Markdown' }) notes!: string;
|
||||
@ApiProperty({
|
||||
description: 'False: drawn from its finished tickets (none written yet)',
|
||||
})
|
||||
written!: boolean;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -29,7 +30,12 @@ import {
|
||||
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 {
|
||||
DashboardShareDto,
|
||||
PublicReleaseNotesDto,
|
||||
ReleaseNotesShareDto,
|
||||
SharedDashboardDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
@@ -102,6 +108,68 @@ export class SharingController {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user