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:
2026-10-20 20:57:40 +02:00
parent 3a46a236e4
commit 8ed16f133e
27 changed files with 2158 additions and 6 deletions

View File

@@ -51,6 +51,8 @@ export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service';
export * from './lib/commands/reviews';
export * from './lib/commands/polls';
export * from './lib/sharing/release-notes-shares';
export * from './lib/ports/release-note-shares.repository';
export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store';
export * from './lib/presence.service';

View File

@@ -0,0 +1,16 @@
/** A release's public notes page: whoever has the link reads them. */
export interface ReleaseNotesShare {
releaseId: string;
projectId: string;
/** Random and long; kept as it is so the link can be copied again. */
token: string;
createdAt: string;
}
export abstract class ReleaseNoteSharesRepository {
abstract findByRelease(releaseId: string): Promise<ReleaseNotesShare | null>;
abstract findByToken(token: string): Promise<ReleaseNotesShare | null>;
/** Replaces the release's link, if any. */
abstract save(share: ReleaseNotesShare): Promise<void>;
abstract remove(releaseId: string): Promise<void>;
}

View File

@@ -11,8 +11,16 @@ import {
ShareDashboardCommand,
UnshareDashboardCommand,
} from './sharing/dashboard-shares';
import { ReleaseNotesShare } from './ports/release-note-shares.repository';
import {
GetPublicReleaseNotesQuery,
GetReleaseNotesShareQuery,
PublicReleaseNotes,
ShareReleaseNotesCommand,
UnshareReleaseNotesCommand,
} from './sharing/release-notes-shares';
/** The project's dashboard for clients, behind a secret link. */
/** The project's dashboard and its releases' notes, behind secret links. */
@Injectable()
export class SharingService {
constructor(
@@ -48,6 +56,38 @@ export class SharingService {
);
}
/** A release's public notes link, if any (owners and admins). */
releaseNotesShare(
viewerId: string,
releaseId: string,
): Promise<ReleaseNotesShare | null> {
return this.queryBus.execute(
new GetReleaseNotesShareQuery(viewerId, releaseId),
);
}
/** Turns a release's public notes on; `renew` makes a new secret. */
shareReleaseNotes(
actorId: string,
releaseId: string,
renew = false,
): Promise<ReleaseNotesShare> {
return this.commands.execute(
new ShareReleaseNotesCommand(actorId, releaseId, renew),
);
}
unshareReleaseNotes(actorId: string, releaseId: string): Promise<void> {
return this.commands.execute(
new UnshareReleaseNotesCommand(actorId, releaseId),
);
}
/** A release's notes for anyone with the link, or null. */
publicReleaseNotes(token: string): Promise<PublicReleaseNotes | null> {
return this.queryBus.execute(new GetPublicReleaseNotesQuery(token));
}
/** 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));

View File

@@ -0,0 +1,160 @@
import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import { ForbiddenError } from '@task-stream/api/shared/domain';
import {
DEFAULT_PROJECT_SETTINGS,
IssueReadModel,
ReleaseReadModel,
ReleaseStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import {
ReleaseNoteSharesRepository,
ReleaseNotesShare,
} from '../ports/release-note-shares.repository';
import {
GetPublicReleaseNotesQuery,
RELEASE_NOTES_SHARES,
ShareReleaseNotesCommand,
UnshareReleaseNotesCommand,
} from './release-notes-shares';
class InMemoryShares extends ReleaseNoteSharesRepository {
rows = new Map<string, ReleaseNotesShare>();
async findByRelease(releaseId: string) {
return this.rows.get(releaseId) ?? null;
}
async findByToken(token: string) {
return [...this.rows.values()].find((s) => s.token === token) ?? null;
}
async save(share: ReleaseNotesShare) {
this.rows.set(share.releaseId, share);
}
async remove(releaseId: string) {
this.rows.delete(releaseId);
}
}
const release: ReleaseReadModel = {
id: 'r1',
projectId: 'p1',
name: 'v2.0',
description: 'Pagos nuevos',
targetDate: '2026-11-15',
status: ReleaseStatus.PLANNED,
releasedAt: null,
notes: null,
createdAt: '',
version: 1,
};
async function setup() {
release.notes = null;
const shares = new InMemoryShares();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...RELEASE_NOTES_SHARES,
{ provide: ReleaseNoteSharesRepository, useValue: shares },
{
provide: FindReleasesRepository,
useValue: {
findById: async (id: string) => (id === 'r1' ? release : null),
},
},
{
provide: FindProjectsRepository,
useValue: {
findById: async () => ({
id: 'p1',
key: 'WEB',
name: 'Web',
settings: DEFAULT_PROJECT_SETTINGS,
}),
},
},
{
provide: FindIssuesRepository,
useValue: {
search: async () => ({
items: [
{
key: 'WEB-1',
number: 1,
title: 'Pago con Bizum',
type: 'FEATURE',
statusCategory: StatusCategory.DONE,
} as IssueReadModel,
],
total: 1,
}),
},
},
{
provide: WriteAccess,
useValue: {
project: async () => {
const assertManages = (userId: string) => {
if (userId !== 'owner') throw new ForbiddenError('No');
};
return { assertManages, assertCanManage: assertManages };
},
},
},
],
}).compile();
await moduleRef.init();
return {
commands: moduleRef.get(CommandBus),
queries: moduleRef.get(QueryBus),
};
}
describe("a release's public notes", () => {
it('opens for whoever manages the project, and keeps its link until renewed', async () => {
const { commands } = await setup();
await expect(
commands.execute(new ShareReleaseNotesCommand('dev', 'r1')),
).rejects.toBeInstanceOf(ForbiddenError);
const first = await commands.execute(
new ShareReleaseNotesCommand('owner', 'r1'),
);
expect(first.token.length).toBeGreaterThan(30);
expect(
(await commands.execute(new ShareReleaseNotesCommand('owner', 'r1')))
.token,
).toBe(first.token);
const renewed = await commands.execute(
new ShareReleaseNotesCommand('owner', 'r1', true),
);
expect(renewed.token).not.toBe(first.token);
});
it('shows the notes written, or the draft without repeating the title', async () => {
const { commands, queries } = await setup();
const { token } = await commands.execute(
new ShareReleaseNotesCommand('owner', 'r1'),
);
const drafted = await queries.execute(
new GetPublicReleaseNotesQuery(token),
);
expect(drafted).toMatchObject({ releaseName: 'v2.0', written: false });
expect(drafted?.notes).toContain('Pago con Bizum (WEB-1)');
expect(drafted?.notes).not.toContain('## v2.0');
expect(drafted?.notes).not.toContain('Pagos nuevos');
release.notes = '## Novedades\n- Bizum';
expect(
await queries.execute(new GetPublicReleaseNotesQuery(token)),
).toMatchObject({ notes: '## Novedades\n- Bizum', written: true });
await commands.execute(new UnshareReleaseNotesCommand('owner', 'r1'));
expect(
await queries.execute(new GetPublicReleaseNotesQuery(token)),
).toBeNull();
});
});

View File

@@ -0,0 +1,235 @@
import { randomBytes } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
ReleaseStatus,
} from '@task-stream/api/tracker/domain';
import { WriteAccess } from '../commands/write-access';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import {
ReleaseNoteSharesRepository,
ReleaseNotesShare,
} from '../ports/release-note-shares.repository';
import { draftReleaseNotes } from '../releases/release-notes';
/*
* A release's notes as a public page, for clients: a secret link (also for
* private projects) that shows the notes written, or the ones drawn from
* its finished tickets while there are none.
*/
/** What the public page shows. */
export interface PublicReleaseNotes {
projectName: string;
projectKey: string;
releaseName: string;
description: string | null;
status: ReleaseStatus;
targetDate: string | null;
releasedAt: string | null;
/** Markdown. */
notes: string;
/** False: drawn from its finished tickets (nobody wrote them yet). */
written: boolean;
}
/** Turns the link on; with `renew` (or the first time) a new secret. */
export class ShareReleaseNotesCommand extends Command<ReleaseNotesShare> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
public readonly renew = false,
) {
super();
}
}
export class UnshareReleaseNotesCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
) {
super();
}
}
/** The release's link, for whoever manages the project. */
export class GetReleaseNotesShareQuery extends Query<ReleaseNotesShare | null> {
constructor(
public readonly viewerId: string,
public readonly releaseId: string,
) {
super();
}
}
/** The page, for anyone with the link; null for an unknown one. */
export class GetPublicReleaseNotesQuery extends Query<PublicReleaseNotes | null> {
constructor(public readonly token: string) {
super();
}
}
async function releaseOf(
findReleases: FindReleasesRepository,
releaseId: string,
) {
const release = await findReleases.findById(releaseId);
if (!release) throw new NotFoundError('Release', releaseId);
return release;
}
@CommandHandler(ShareReleaseNotesCommand)
export class ShareReleaseNotesCommandHandler
implements ICommandHandler<ShareReleaseNotesCommand>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly access: WriteAccess,
) {}
async execute(command: ShareReleaseNotesCommand): Promise<ReleaseNotesShare> {
const release = await releaseOf(this.findReleases, command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
const current = await this.shares.findByRelease(release.id);
if (current && !command.renew) return current;
const share: ReleaseNotesShare = {
releaseId: release.id,
projectId: release.projectId,
// 32 random bytes: nobody guesses it.
token: randomBytes(32).toString('base64url'),
createdAt: new Date().toISOString(),
};
await this.shares.save(share);
return share;
}
}
@CommandHandler(UnshareReleaseNotesCommand)
export class UnshareReleaseNotesCommandHandler
implements ICommandHandler<UnshareReleaseNotesCommand>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly access: WriteAccess,
) {}
async execute(command: UnshareReleaseNotesCommand): Promise<void> {
const release = await releaseOf(this.findReleases, command.releaseId);
(await this.access.project(release.projectId)).assertManages(
command.actorId,
);
await this.shares.remove(release.id);
}
}
@QueryHandler(GetReleaseNotesShareQuery)
export class GetReleaseNotesShareQueryHandler
implements IQueryHandler<GetReleaseNotesShareQuery, ReleaseNotesShare | null>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly access: WriteAccess,
) {}
async execute(
query: GetReleaseNotesShareQuery,
): Promise<ReleaseNotesShare | null> {
const release = await releaseOf(this.findReleases, query.releaseId);
(await this.access.project(release.projectId)).assertManages(
query.viewerId,
);
return this.shares.findByRelease(release.id);
}
}
@QueryHandler(GetPublicReleaseNotesQuery)
export class GetPublicReleaseNotesQueryHandler
implements
IQueryHandler<GetPublicReleaseNotesQuery, PublicReleaseNotes | null>
{
constructor(
private readonly shares: ReleaseNoteSharesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(
query: GetPublicReleaseNotesQuery,
): Promise<PublicReleaseNotes | null> {
const share = await this.shares.findByToken(query.token);
if (!share) return null;
const [release, project] = await Promise.all([
this.findReleases.findById(share.releaseId),
this.findProjects.findById(share.projectId),
]);
// The release or the project was deleted: the link went with them.
if (!release || !project) return null;
const written = !!release.notes?.trim();
const notes = written
? (release.notes as string)
: withoutHeading(
draftReleaseNotes({
release,
issues: (
await this.findIssues.search({
projectId: release.projectId,
releaseId: release.id,
limit: 500,
offset: 0,
})
).items,
workflow: new ProjectWorkflow(
project.settings ?? DEFAULT_PROJECT_SETTINGS,
),
}),
release.description,
);
return {
projectName: project.name,
projectKey: project.key,
releaseName: release.name,
description: release.description,
status: release.status,
targetDate: release.targetDate,
releasedAt: release.releasedAt,
notes,
written,
};
}
}
/**
* The draft without its title line (and description): the page shows the
* release's name, date and description above it already.
*/
function withoutHeading(draft: string, description: string | null): string {
let lines = draft.split('\n').slice(1);
if (description && lines[0] === '' && lines[1] === description)
lines = lines.slice(2);
return lines.join('\n').trim() + '\n';
}
export const RELEASE_NOTES_SHARES = [
ShareReleaseNotesCommandHandler,
UnshareReleaseNotesCommandHandler,
GetReleaseNotesShareQueryHandler,
GetPublicReleaseNotesQueryHandler,
];

View File

@@ -40,6 +40,7 @@ import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows';
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
import { MY_DAY } from './my-day/my-day';
import { RELEASE_NOTES_SHARES } from './sharing/release-notes-shares';
import { MyDayService } from './my-day.service';
import { IssueRemindersService } from './issue-reminders.service';
import { TreeFollowsService } from './tree-follows.service';
@@ -128,6 +129,7 @@ const SERVICES = [
...TREE_FOLLOWS,
...ISSUE_REMINDERS,
...MY_DAY,
...RELEASE_NOTES_SHARES,
...GIT_HANDLERS,
...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS,

View File

@@ -112,6 +112,7 @@ export const ReadModels = {
TreeFollow: 'TreeFollow',
IssueReminder: 'IssueReminder',
MyDay: 'MyDay',
ReleaseNotesShare: 'ReleaseNotesShare',
Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook',
@@ -395,6 +396,15 @@ class MyDayDocument {
export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument);
MyDaySchema.index({ userId: 1, day: -1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'release_note_shares' })
class ReleaseNotesShareDocument {
@Prop({ type: String, required: true, unique: true }) releaseId!: string;
@Prop({ type: String, required: true, unique: true }) token!: string;
}
export const ReleaseNotesShareSchema = SchemaFactory.createForClass(
ReleaseNotesShareDocument,
);
@Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument {
/** The project's id. */
@@ -503,6 +513,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.MyDay, schema: MyDaySchema },
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
ReleaseNoteSharesRepository,
ReleaseNotesShare,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
type Stored = ReleaseNotesShare & { _id?: unknown };
const strip = ({ _id: _ignored, ...share }: Stored): ReleaseNotesShare => share;
@Injectable()
export class MongoReleaseNoteSharesRepository extends ReleaseNoteSharesRepository {
constructor(
@InjectModel(ReadModels.ReleaseNotesShare)
private readonly shares: Model<Stored>,
) {
super();
}
async findByRelease(releaseId: string): Promise<ReleaseNotesShare | null> {
const doc = await this.shares.findOne({ releaseId }).lean<Stored>();
return doc ? strip(doc) : null;
}
async findByToken(token: string): Promise<ReleaseNotesShare | null> {
const doc = await this.shares.findOne({ token }).lean<Stored>();
return doc ? strip(doc) : null;
}
async save(share: ReleaseNotesShare): Promise<void> {
await this.shares.updateOne(
{ releaseId: share.releaseId },
{ $set: share },
{ upsert: true },
);
}
async remove(releaseId: string): Promise<void> {
await this.shares.deleteOne({ releaseId });
}
}

View File

@@ -18,6 +18,7 @@ import {
TreeFollowsRepository,
IssueRemindersRepository,
MyDaysRepository,
ReleaseNoteSharesRepository,
GitIntegrationsRepository,
OutgoingWebhooksRepository,
AutomationsRepository,
@@ -88,6 +89,7 @@ import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.rep
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoMyDaysRepository } from './mongo/my-days.repository';
import { MongoReleaseNoteSharesRepository } from './mongo/release-note-shares.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -175,6 +177,10 @@ const PORTS = [
useClass: MongoIssueRemindersRepository,
},
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository },
{
provide: ReleaseNoteSharesRepository,
useClass: MongoReleaseNoteSharesRepository,
},
{
provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository,

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -39,3 +39,26 @@ export interface SharedDashboard {
| null;
activity: { day: string; count: number }[] | null;
}
/** A release's public notes link (owners and admins). */
export interface ReleaseNotesShare {
releaseId: string;
/** The secret of the link: /notes/{token}. */
token: string;
createdAt: string;
}
/** A release's notes, for anyone with its link. */
export interface PublicReleaseNotes {
projectName: string;
projectKey: string;
releaseName: string;
description: string | null;
status: string;
targetDate: string | null;
releasedAt: string | null;
/** Markdown. */
notes: string;
/** False: drawn from its finished tickets (none written yet). */
written: boolean;
}

View File

@@ -4,6 +4,8 @@ import { Home } from '../models/home';
import { PortfolioProject, ProjectInsights } from '../models/insights';
import {
DashboardShare,
PublicReleaseNotes,
ReleaseNotesShare,
SharedBlock,
SharedDashboard,
} from '../models/sharing';
@@ -474,6 +476,20 @@ export abstract class SharingGateway {
abstract remove(projectId: string): Observable<void>;
/** What a link shows, for anyone (null: no such link). */
abstract shared(token: string): Observable<SharedDashboard | null>;
/** A release's public notes link, if any (owners and admins). */
abstract releaseNotesShare(
releaseId: string,
): Observable<ReleaseNotesShare | null>;
/** Turns it on; `renew` makes a new link. */
abstract shareReleaseNotes(
releaseId: string,
renew: boolean,
): Observable<ReleaseNotesShare>;
abstract unshareReleaseNotes(releaseId: string): Observable<void>;
/** A release's notes for anyone with the link (null: no such link). */
abstract publicReleaseNotes(
token: string,
): Observable<PublicReleaseNotes | null>;
}
export abstract class NotificationsGateway {

View File

@@ -1,6 +1,8 @@
import { inject, Injectable } from '@angular/core';
import {
DashboardShare,
PublicReleaseNotes,
ReleaseNotesShare,
SharedBlock,
SharedDashboard,
SharingGateway,
@@ -8,7 +10,11 @@ import {
import { map, Observable } from 'rxjs';
import {
DashboardShareDocument,
PublicReleaseNotesDocument,
ReleaseNotesShareDocument,
ShareDashboardDocument,
ShareReleaseNotesDocument,
UnshareReleaseNotesDocument,
SharedDashboardDocument,
UnshareDashboardDocument,
} from '../graphql/generated';
@@ -45,4 +51,33 @@ export class GraphqlSharingGateway extends SharingGateway {
.query(SharedDashboardDocument, { token })
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
}
releaseNotesShare(releaseId: string): Observable<ReleaseNotesShare | null> {
return this.gql
.query(ReleaseNotesShareDocument, { releaseId })
.pipe(map((d) => d.releaseNotesShare ?? null));
}
shareReleaseNotes(
releaseId: string,
renew: boolean,
): Observable<ReleaseNotesShare> {
return this.gql
.mutate(ShareReleaseNotesDocument, { releaseId, renew })
.pipe(map((d) => d.shareReleaseNotes));
}
unshareReleaseNotes(releaseId: string): Observable<void> {
return this.gql
.mutate(UnshareReleaseNotesDocument, { releaseId })
.pipe(map(() => undefined));
}
publicReleaseNotes(token: string): Observable<PublicReleaseNotes | null> {
return this.gql
.query(PublicReleaseNotesDocument, { token })
.pipe(
map((d) => (d.publicReleaseNotes as PublicReleaseNotes | null) ?? null),
);
}
}

View File

@@ -3010,6 +3010,57 @@ export type SharedDashboardQuery = {
} | null;
};
export type ReleaseNotesShareFieldsFragment = {
releaseId: string;
token: string;
createdAt: string;
};
export type ReleaseNotesShareQueryVariables = Exact<{
releaseId: string;
}>;
export type ReleaseNotesShareQuery = {
releaseNotesShare: {
releaseId: string;
token: string;
createdAt: string;
} | null;
};
export type ShareReleaseNotesMutationVariables = Exact<{
releaseId: string;
renew: boolean;
}>;
export type ShareReleaseNotesMutation = {
shareReleaseNotes: { releaseId: string; token: string; createdAt: string };
};
export type UnshareReleaseNotesMutationVariables = Exact<{
releaseId: string;
}>;
export type UnshareReleaseNotesMutation = { unshareReleaseNotes: boolean };
export type PublicReleaseNotesQueryVariables = Exact<{
token: string;
}>;
export type PublicReleaseNotesQuery = {
publicReleaseNotes: {
projectName: string;
projectKey: string;
releaseName: string;
description: string | null;
status: ReleaseStatus;
targetDate: string | null;
releasedAt: string | null;
notes: string;
written: boolean;
} | null;
};
export type SprintFieldsFragment = {
id: string;
projectId: string;
@@ -4824,6 +4875,27 @@ export const DashboardShareFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>;
export const ReleaseNotesShareFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ReleaseNotesShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<ReleaseNotesShareFieldsFragment, unknown>;
export const SprintFieldsFragmentDoc = {
kind: 'Document',
definitions: [
@@ -18184,6 +18256,275 @@ export const SharedDashboardDocument = {
SharedDashboardQuery,
SharedDashboardQueryVariables
>;
export const ReleaseNotesShareDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'ReleaseNotesShare' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseNotesShare' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'releaseId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ReleaseNotesShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
ReleaseNotesShareQuery,
ReleaseNotesShareQueryVariables
>;
export const ShareReleaseNotesDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ShareReleaseNotes' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'renew' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'shareReleaseNotes' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'releaseId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'renew' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'renew' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'ReleaseNotesShare' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
],
},
},
],
} as unknown as DocumentNode<
ShareReleaseNotesMutation,
ShareReleaseNotesMutationVariables
>;
export const UnshareReleaseNotesDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnshareReleaseNotes' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unshareReleaseNotes' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'releaseId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'releaseId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
UnshareReleaseNotesMutation,
UnshareReleaseNotesMutationVariables
>;
export const PublicReleaseNotesDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'PublicReleaseNotes' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'token' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'publicReleaseNotes' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'token' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'token' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectName' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releaseName' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
{ kind: 'Field', name: { kind: 'Name', value: 'written' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<
PublicReleaseNotesQuery,
PublicReleaseNotesQueryVariables
>;
export const SprintsDocument = {
kind: 'Document',
definitions: [

View File

@@ -72,3 +72,39 @@ query SharedDashboard($token: String!) {
}
}
}
fragment ReleaseNotesShareFields on ReleaseNotesShare {
releaseId
token
createdAt
}
query ReleaseNotesShare($releaseId: ID!) {
releaseNotesShare(releaseId: $releaseId) {
...ReleaseNotesShareFields
}
}
mutation ShareReleaseNotes($releaseId: ID!, $renew: Boolean!) {
shareReleaseNotes(releaseId: $releaseId, renew: $renew) {
...ReleaseNotesShareFields
}
}
mutation UnshareReleaseNotes($releaseId: ID!) {
unshareReleaseNotes(releaseId: $releaseId)
}
query PublicReleaseNotes($token: String!) {
publicReleaseNotes(token: $token) {
projectName
projectKey
releaseName
description
status
targetDate
releasedAt
notes
written
}
}

View File

@@ -0,0 +1,133 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui';
import {
PublicReleaseNotes,
SharingGateway,
} from '@task-stream/web/tracker/domain';
import { MarkdownComponent } from '../ui/markdown';
/**
* /notes/:token — a release's notes for its clients, without an account:
* the notes written, or the ones drawn from its finished tickets.
*/
@Component({
selector: 'ts-public-release-notes-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiEmptyStateComponent, PiSkeletonComponent, MarkdownComponent],
template: `
@if (missing()) {
<pi-empty-state
icon="lock"
title="Este enlace ya no funciona"
description="Puede que lo hayan renovado o retirado. Pide uno nuevo a quien te lo dio."
/>
} @else if (notes(); as release) {
<article class="ts-notes ts-panel">
<header>
<p class="ts-eyebrow">{{ release.projectName }}</p>
<h1>{{ release.releaseName }}</h1>
<p class="ts-muted">{{ when(release) }}</p>
@if (release.description) {
<p class="ts-notes__description">{{ release.description }}</p>
}
</header>
<ts-markdown [text]="release.notes" />
</article>
<p class="ts-notes__footer">Hecho con TaskStream</p>
} @else if (failed()) {
<pi-empty-state
icon="exclamation"
title="No se pudo cargar"
description="Prueba de nuevo dentro de un rato."
/>
} @else {
<pi-skeleton shape="block" height="16rem" />
}
`,
styles: `
:host {
display: grid;
gap: 1.25rem;
max-width: 46rem;
margin: 0 auto;
}
.ts-notes {
padding: clamp(1.25rem, 4vw, 2.5rem);
header {
margin-bottom: 1.5rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--ts-line);
}
h1 {
margin: 0.25rem 0;
font-size: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
font-weight: 750;
line-height: 1.1;
}
header p {
margin: 0;
}
}
.ts-notes__description {
margin-top: 0.75rem !important;
font-size: 1.0625rem;
}
.ts-notes__footer {
margin: 0;
color: var(--ts-muted);
font-size: 0.75rem;
text-align: center;
}
`,
})
export class PublicReleaseNotesPage {
/** Route param (`withComponentInputBinding`). */
readonly token = input.required<string>();
private readonly sharing = inject(SharingGateway);
private readonly destroyRef = inject(DestroyRef);
protected readonly notes = signal<PublicReleaseNotes | null>(null);
protected readonly missing = signal(false);
protected readonly failed = signal(false);
constructor() {
effect(() => {
const token = this.token();
untracked(() =>
this.sharing
.publicReleaseNotes(token)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (notes) => {
this.notes.set(notes);
this.missing.set(!notes);
},
error: () => this.failed.set(true),
}),
);
});
}
protected when(release: PublicReleaseNotes): string {
const date = (day: string) =>
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
if (release.releasedAt) return `Publicada el ${date(release.releasedAt)}`;
if (release.targetDate)
return `Prevista para el ${date(release.targetDate)}`;
return 'En preparación';
}
}

View File

@@ -22,7 +22,12 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
import {
Release,
ReleaseNotesShare,
ReleaseStatus,
SharingGateway,
} from '@task-stream/web/tracker/domain';
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
/** The «no release» choice of a select (an empty value). */
@@ -130,6 +135,15 @@ const NO_RELEASE = '';
(execute)="copyNotes(release)"
/>
}
@if (facade.canManage()) {
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Página pública"
(execute)="openPublicNotes(release)"
/>
}
@if (facade.canManage() && release.status !== 'ARCHIVED') {
<pi-action-button
size="small"
@@ -166,6 +180,65 @@ const NO_RELEASE = '';
}
</ol>
<ng-template #publicNotesForm>
<div class="ts-form">
<p class="ts-muted">
Una página con las notas de esta versión para tus clientes, sin
cuenta. Enseña las notas escritas o, mientras no las haya, las que
salen de sus tickets terminados.
</p>
@if (publicShare(); as share) {
<input
class="ts-input ts-public-notes__url"
readonly
aria-label="Enlace de la página pública"
[value]="publicUrl(share.token)"
(focus)="$any($event.target).select()"
/>
<div class="ts-public-notes__actions">
<pi-action-button
size="small"
color="primary"
text="Copiar"
(execute)="copyLink(publicUrl(share.token))"
/>
<a
class="ts-release__link"
[href]="publicUrl(share.token)"
target="_blank"
rel="noopener"
>Abrir</a
>
<span class="ts-spacer"></span>
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Renovar"
(execute)="sharePublicNotes(true)"
/>
<pi-action-button
size="small"
appearance="ghost"
color="grey"
text="Retirar"
(execute)="unsharePublicNotes()"
/>
</div>
<p class="ts-muted">
Renovar hace un enlace nuevo y el anterior deja de funcionar.
</p>
} @else if (publicLoaded()) {
<pi-action-button
size="w100"
color="primary"
text="Crear el enlace"
(execute)="sharePublicNotes(false)"
/>
}
</div>
</ng-template>
<ng-template #notesForm>
<form
class="ts-form"
@@ -244,6 +317,16 @@ const NO_RELEASE = '';
</ng-template>
`,
styles: `
.ts-public-notes__url {
width: 100%;
font-family: var(--ts-font-mono);
font-size: 0.8125rem;
}
.ts-public-notes__actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.ts-release__notes {
margin-top: 0.25rem;
summary {
@@ -392,6 +475,13 @@ export class ReleasesPage {
viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('notesForm');
private readonly publicNotesForm =
viewChild.required<TemplateRef<unknown>>('publicNotesForm');
private readonly sharing = inject(SharingGateway);
/** The release whose public page is open in the drawer, and its link. */
private sharingRelease: Release | null = null;
protected readonly publicShare = signal<ReleaseNotesShare | null>(null);
protected readonly publicLoaded = signal(false);
private readonly archiveForm =
viewChild.required<TemplateRef<unknown>>('archiveForm');
private readonly toast = inject(ToastService);
@@ -491,6 +581,68 @@ export class ReleasesPage {
this.drawer.closeAll();
}
protected openPublicNotes(release: Release): void {
this.sharingRelease = release;
this.publicShare.set(null);
this.publicLoaded.set(false);
this.sharing
.releaseNotesShare(release.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (share) => {
this.publicShare.set(share);
this.publicLoaded.set(true);
},
error: () => this.toast.error('No se pudo leer el enlace'),
});
this.drawer.open(
`Página pública de ${release.name}`,
this.publicNotesForm(),
);
}
protected sharePublicNotes(renew: boolean): void {
const release = this.sharingRelease;
if (!release) return;
this.sharing
.shareReleaseNotes(release.id, renew)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (share) => {
this.publicShare.set(share);
this.toast.success(renew ? 'Enlace renovado' : 'Enlace creado');
},
error: () => this.toast.error('No se pudo crear el enlace'),
});
}
protected unsharePublicNotes(): void {
const release = this.sharingRelease;
if (!release) return;
this.sharing
.unshareReleaseNotes(release.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.publicShare.set(null);
this.toast.success('Enlace retirado');
},
error: () => this.toast.error('No se pudo retirar'),
});
}
/** The web app and the API share the origin. */
protected publicUrl(token: string): string {
return `${location.origin}/notes/${token}`;
}
protected copyLink(url: string): void {
navigator.clipboard
?.writeText(url)
.then(() => this.toast.success('Enlace copiado'))
.catch(() => this.toast.error('No se pudo copiar'));
}
protected copyNotes(release: Release): void {
navigator.clipboard
?.writeText(release.notes ?? '')

View File

@@ -54,6 +54,15 @@ export const trackerRoutes: Routes = [
(m) => m.SharedDashboardPage,
),
},
{
// A release's notes for its clients: the token is the access.
path: 'notes/:token',
title: 'Notas de la versión · TaskStream',
loadComponent: () =>
import('./pages/public-release-notes.page').then(
(m) => m.PublicReleaseNotesPage,
),
},
// The Spanish paths, from links shared before.
{ path: 'cartera', redirectTo: 'portfolio' },
{ path: 'explorar', redirectTo: 'explore' },