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

@@ -13702,6 +13702,316 @@
] ]
} }
}, },
"/api/releases/{release}/public-notes": {
"get": {
"operationId": "SharingController_releaseNotesShare",
"parameters": [
{
"name": "release",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseNotesShareDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "A release's public notes link, if any (owners and admins)",
"tags": [
"Projects"
]
},
"put": {
"description": "The page shows the notes written, or the ones drawn from its finished tickets. ?renew=true makes a new link.",
"operationId": "SharingController_shareReleaseNotes",
"parameters": [
{
"name": "release",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "renew",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseNotesShareDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Turn a release's public notes page on (owners and admins)",
"tags": [
"Projects"
]
},
"delete": {
"operationId": "SharingController_unshareReleaseNotes",
"parameters": [
{
"name": "release",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "A release's public notes link stops working",
"tags": [
"Projects"
]
}
},
"/api/shared/release-notes/{token}": {
"get": {
"operationId": "SharingController_publicReleaseNotes",
"parameters": [
{
"name": "token",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicReleaseNotesDto"
}
}
}
}
},
"summary": "A release's notes, for anyone with its link",
"tags": [
"Projects"
],
"security": []
}
},
"/api/shared/dashboards/{token}": { "/api/shared/dashboards/{token}": {
"get": { "get": {
"description": "Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.", "description": "Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.",
@@ -19804,6 +20114,81 @@
"blocks" "blocks"
] ]
}, },
"ReleaseNotesShareDto": {
"type": "object",
"properties": {
"releaseId": {
"type": "string",
"format": "uuid"
},
"token": {
"type": "string",
"description": "The secret of the link: /notes/{token}"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"releaseId",
"token",
"createdAt"
]
},
"PublicReleaseNotesDto": {
"type": "object",
"properties": {
"projectName": {
"type": "string"
},
"projectKey": {
"type": "string"
},
"releaseName": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
},
"status": {
"allOf": [
{
"$ref": "#/components/schemas/ReleaseStatus"
}
]
},
"targetDate": {
"type": "string",
"nullable": true
},
"releasedAt": {
"type": "string",
"nullable": true,
"format": "date-time"
},
"notes": {
"type": "string",
"description": "Markdown"
},
"written": {
"type": "boolean",
"description": "False: drawn from its finished tickets (none written yet)"
}
},
"required": [
"projectName",
"projectKey",
"releaseName",
"description",
"status",
"targetDate",
"releasedAt",
"notes",
"written"
]
},
"SharedFlowDto": { "SharedFlowDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1185,6 +1185,11 @@ type Mutation {
"""Turn the link on or change its blocks; renew for a new secret""" """Turn the link on or change its blocks; renew for a new secret"""
shareDashboard(input: ShareDashboardInput!): DashboardShare! shareDashboard(input: ShareDashboardInput!): DashboardShare!
"""
Turn a release's public notes page on; renew makes a new link (the old one stops working)
"""
shareReleaseNotes(releaseId: ID!, renew: Boolean! = false): ReleaseNotesShare!
"""Start a planned sprint (only one under way per project)""" """Start a planned sprint (only one under way per project)"""
startSprint(sprintId: ID!): CommandResult! startSprint(sprintId: ID!): CommandResult!
@@ -1227,6 +1232,9 @@ type Mutation {
"""The link stops working""" """The link stops working"""
unshareDashboard(projectId: ID!): Boolean! unshareDashboard(projectId: ID!): Boolean!
"""A release's public notes link stops working"""
unshareReleaseNotes(releaseId: ID!): Boolean!
"""Push off on this device""" """Push off on this device"""
unsubscribePush(endpoint: String!): Boolean! unsubscribePush(endpoint: String!): Boolean!
@@ -1637,6 +1645,23 @@ type ProposalsFunnel {
waiting: Int! waiting: Int!
} }
"""A release's notes, for anyone with its link"""
type PublicReleaseNotes {
description: String
"""Markdown"""
notes: String!
projectKey: String!
projectName: String!
releaseName: String!
releasedAt: String
status: ReleaseStatus!
targetDate: String
"""False: drawn from its finished tickets (none written yet)"""
written: Boolean!
}
"""A browser's Web Push subscription (PushSubscription.toJSON())""" """A browser's Web Push subscription (PushSubscription.toJSON())"""
input PushSubscriptionInput { input PushSubscriptionInput {
auth: String! auth: String!
@@ -1783,8 +1808,14 @@ type Query {
"""Projects anyone can read, also without signing in""" """Projects anyone can read, also without signing in"""
publicProjects: [Project!]! publicProjects: [Project!]!
"""A release's notes, for anyone with its link"""
publicReleaseNotes(token: String!): PublicReleaseNotes
release(id: ID!): Release! release(id: ID!): Release!
"""A release's public notes link, if any (owners and admins)"""
releaseNotesShare(releaseId: ID!): ReleaseNotesShare
"""My views of the project's board, then the shared ones""" """My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]! savedFilters(projectId: ID!): [SavedFilter!]!
@@ -1882,6 +1913,15 @@ type ReleaseForecast {
verdict: String! verdict: String!
} }
"""A release's public notes link (owners and admins)"""
type ReleaseNotesShare {
createdAt: String!
releaseId: ID!
"""The secret of the link: /notes/{token}"""
token: String!
}
type ReleaseProgress { type ReleaseProgress {
byStatus: [CountByStatus!]! byStatus: [CountByStatus!]!
finished: Int! finished: Int!

View File

@@ -2042,6 +2042,45 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/releases/{release}/public-notes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** A release's public notes link, if any (owners and admins) */
get: operations["SharingController_releaseNotesShare"];
/**
* 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.
*/
put: operations["SharingController_shareReleaseNotes"];
post?: never;
/** A release's public notes link stops working */
delete: operations["SharingController_unshareReleaseNotes"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/shared/release-notes/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** A release's notes, for anyone with its link */
get: operations["SharingController_publicReleaseNotes"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/shared/dashboards/{token}": { "/api/shared/dashboards/{token}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -4025,6 +4064,28 @@ export interface components {
/** @description A new secret: the old link stops working */ /** @description A new secret: the old link stops working */
renew?: boolean; renew?: boolean;
}; };
ReleaseNotesShareDto: {
/** Format: uuid */
releaseId: string;
/** @description The secret of the link: /notes/{token} */
token: string;
/** Format: date-time */
createdAt: string;
};
PublicReleaseNotesDto: {
projectName: string;
projectKey: string;
releaseName: string;
description: string | null;
status: components["schemas"]["ReleaseStatus"];
targetDate: string | null;
/** Format: date-time */
releasedAt: string | null;
/** @description Markdown */
notes: string;
/** @description False: drawn from its finished tickets (none written yet) */
written: boolean;
};
SharedFlowDto: { SharedFlowDto: {
status: string; status: string;
name: string; name: string;
@@ -13705,6 +13766,234 @@ export interface operations {
}; };
}; };
}; };
SharingController_releaseNotesShare: {
parameters: {
query?: never;
header?: never;
path: {
release: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReleaseNotesShareDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_shareReleaseNotes: {
parameters: {
query: {
renew: string;
};
header?: never;
path: {
release: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReleaseNotesShareDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_unshareReleaseNotes: {
parameters: {
query?: never;
header?: never;
path: {
release: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SharingController_publicReleaseNotes: {
parameters: {
query?: never;
header?: never;
path: {
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublicReleaseNotesDto"];
};
};
};
};
SharingController_shared: { SharingController_shared: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -12,7 +12,7 @@
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | | ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | | 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | | 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; y una página pública por release para los clientes, con enlace secreto |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | | 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |

View File

@@ -317,6 +317,12 @@ stateDiagram-v2
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto; uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras la respuesta dice cuáles. La web parte selecciones mayores (la Lista enseña hasta 200) en tandas de 100, una tras
otra, y suma el resultado. otra, y suma el resultado.
- **Notas públicas de una release**: quien gestiona el proyecto abre un enlace secreto con las notas de una release,
para clientes y sin cuenta (también en proyectos privados): `shareReleaseNotes(releaseId, renew)`,
`unshareReleaseNotes`, `releaseNotesShare`; `GET/PUT/DELETE /api/releases/{id}/public-notes`. La página
(`publicReleaseNotes(token)`, `GET /api/shared/release-notes/{token}`, la web en `/notes/{token}`) enseña las notas
escritas o, mientras no las haya, el borrador de sus tickets terminados (`written: false`). Renovar hace un enlace
nuevo; retirarlo, o borrar la release, lo deja sin efecto. Vive en MongoDB (`release_note_shares`).
- **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`). - **Archivar una release**: `archiveRelease(releaseId, moveTo)` / `POST /api/releases/{id}/archive` (`{ moveTo }`).
Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release; Sus tickets sin terminar pasan a `moveTo` (otra release abierta del mismo proyecto) o, sin él, se quedan sin release;
los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`. los terminados se quedan, como registro de lo que salió. Igual que al cerrar un sprint con `carryOverTo`.

View File

@@ -309,7 +309,7 @@ visualización de la plataforma:
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
@@ -318,6 +318,7 @@ visualización de la plataforma:
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | | `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | | `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/notes/:token` | Notas de una release para sus clientes, sin cuenta: las escritas o las de sus tickets terminados |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | | `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien

View File

@@ -51,6 +51,8 @@ export * from './lib/ports/my-days.repository';
export * from './lib/my-day.service'; export * from './lib/my-day.service';
export * from './lib/commands/reviews'; export * from './lib/commands/reviews';
export * from './lib/commands/polls'; 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/tracker-application.module';
export * from './lib/ports/presence.store'; export * from './lib/ports/presence.store';
export * from './lib/presence.service'; 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, ShareDashboardCommand,
UnshareDashboardCommand, UnshareDashboardCommand,
} from './sharing/dashboard-shares'; } 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() @Injectable()
export class SharingService { export class SharingService {
constructor( 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. */ /** What a link shows now, or null if no project has it. */
shared(token: string): Promise<SharedDashboardReadModel | null> { shared(token: string): Promise<SharedDashboardReadModel | null> {
return this.queryBus.execute(new GetSharedDashboardQuery(token)); 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 { TREE_FOLLOWS } from './follows/tree-follows';
import { ISSUE_REMINDERS } from './reminders/issue-reminders'; import { ISSUE_REMINDERS } from './reminders/issue-reminders';
import { MY_DAY } from './my-day/my-day'; import { MY_DAY } from './my-day/my-day';
import { RELEASE_NOTES_SHARES } from './sharing/release-notes-shares';
import { MyDayService } from './my-day.service'; import { MyDayService } from './my-day.service';
import { IssueRemindersService } from './issue-reminders.service'; import { IssueRemindersService } from './issue-reminders.service';
import { TreeFollowsService } from './tree-follows.service'; import { TreeFollowsService } from './tree-follows.service';
@@ -128,6 +129,7 @@ const SERVICES = [
...TREE_FOLLOWS, ...TREE_FOLLOWS,
...ISSUE_REMINDERS, ...ISSUE_REMINDERS,
...MY_DAY, ...MY_DAY,
...RELEASE_NOTES_SHARES,
...GIT_HANDLERS, ...GIT_HANDLERS,
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,

View File

@@ -112,6 +112,7 @@ export const ReadModels = {
TreeFollow: 'TreeFollow', TreeFollow: 'TreeFollow',
IssueReminder: 'IssueReminder', IssueReminder: 'IssueReminder',
MyDay: 'MyDay', MyDay: 'MyDay',
ReleaseNotesShare: 'ReleaseNotesShare',
Sprint: 'SprintReadModel', Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration', GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook', OutgoingWebhook: 'OutgoingWebhook',
@@ -395,6 +396,15 @@ class MyDayDocument {
export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument); export const MyDaySchema = SchemaFactory.createForClass(MyDayDocument);
MyDaySchema.index({ userId: 1, day: -1 }, { unique: true }); 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' }) @Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument { class GitIntegrationDocument {
/** The project's id. */ /** The project's id. */
@@ -503,6 +513,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema }, { name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema }, { name: ReadModels.IssueReminder, schema: IssueReminderSchema },
{ name: ReadModels.MyDay, schema: MyDaySchema }, { name: ReadModels.MyDay, schema: MyDaySchema },
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { 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, TreeFollowsRepository,
IssueRemindersRepository, IssueRemindersRepository,
MyDaysRepository, MyDaysRepository,
ReleaseNoteSharesRepository,
GitIntegrationsRepository, GitIntegrationsRepository,
OutgoingWebhooksRepository, OutgoingWebhooksRepository,
AutomationsRepository, AutomationsRepository,
@@ -88,6 +89,7 @@ import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.rep
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository'; import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository'; import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
import { MongoMyDaysRepository } from './mongo/my-days.repository'; import { MongoMyDaysRepository } from './mongo/my-days.repository';
import { MongoReleaseNoteSharesRepository } from './mongo/release-note-shares.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -175,6 +177,10 @@ const PORTS = [
useClass: MongoIssueRemindersRepository, useClass: MongoIssueRemindersRepository,
}, },
{ provide: MyDaysRepository, useClass: MongoMyDaysRepository }, { provide: MyDaysRepository, useClass: MongoMyDaysRepository },
{
provide: ReleaseNoteSharesRepository,
useClass: MongoReleaseNoteSharesRepository,
},
{ {
provide: GitIntegrationsRepository, provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository, useClass: MongoGitIntegrationsRepository,

View File

@@ -6,7 +6,12 @@ import {
} from '@task-stream/api/identity/presenters'; } from '@task-stream/api/identity/presenters';
import { SharingService } from '@task-stream/api/tracker/application'; import { SharingService } from '@task-stream/api/tracker/application';
import { ShareDashboardInput } from './inputs'; 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. */ /** The project's dashboard for clients, behind a secret link. */
@Resolver(() => DashboardShareGql) @Resolver(() => DashboardShareGql)
@@ -49,6 +54,51 @@ export class SharingResolver {
return true; 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() @OptionalAuth()
@Query(() => SharedDashboardGql, { @Query(() => SharedDashboardGql, {
nullable: true, nullable: true,

View File

@@ -1454,3 +1454,31 @@ export class MyDayGql {
@Field(() => MyDayLeftoverGql, { nullable: true }) @Field(() => MyDayLeftoverGql, { nullable: true })
leftover!: MyDayLeftoverGql | null; 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' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
answeredAt!: string | null; 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, NotFoundException,
Param, Param,
Put, Put,
Query,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@@ -29,7 +30,12 @@ import {
import { SharingService } from '@task-stream/api/tracker/application'; import { SharingService } from '@task-stream/api/tracker/application';
import type { Response } from 'express'; import type { Response } from 'express';
import { ShareDashboardDto } from './dto/requests.dto'; 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'; import { Refs } from './refs';
const PROJECT = ApiParam({ const PROJECT = ApiParam({
@@ -102,6 +108,68 @@ export class SharingController {
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project)); 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') @Get('shared/dashboards/:token')
@Public() @Public()
@ApiOperation({ @ApiOperation({

View File

@@ -39,3 +39,26 @@ export interface SharedDashboard {
| null; | null;
activity: { day: string; count: number }[] | 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 { PortfolioProject, ProjectInsights } from '../models/insights';
import { import {
DashboardShare, DashboardShare,
PublicReleaseNotes,
ReleaseNotesShare,
SharedBlock, SharedBlock,
SharedDashboard, SharedDashboard,
} from '../models/sharing'; } from '../models/sharing';
@@ -474,6 +476,20 @@ export abstract class SharingGateway {
abstract remove(projectId: string): Observable<void>; abstract remove(projectId: string): Observable<void>;
/** What a link shows, for anyone (null: no such link). */ /** What a link shows, for anyone (null: no such link). */
abstract shared(token: string): Observable<SharedDashboard | null>; 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 { export abstract class NotificationsGateway {

View File

@@ -1,6 +1,8 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
DashboardShare, DashboardShare,
PublicReleaseNotes,
ReleaseNotesShare,
SharedBlock, SharedBlock,
SharedDashboard, SharedDashboard,
SharingGateway, SharingGateway,
@@ -8,7 +10,11 @@ import {
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { import {
DashboardShareDocument, DashboardShareDocument,
PublicReleaseNotesDocument,
ReleaseNotesShareDocument,
ShareDashboardDocument, ShareDashboardDocument,
ShareReleaseNotesDocument,
UnshareReleaseNotesDocument,
SharedDashboardDocument, SharedDashboardDocument,
UnshareDashboardDocument, UnshareDashboardDocument,
} from '../graphql/generated'; } from '../graphql/generated';
@@ -45,4 +51,33 @@ export class GraphqlSharingGateway extends SharingGateway {
.query(SharedDashboardDocument, { token }) .query(SharedDashboardDocument, { token })
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null)); .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; } | 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 = { export type SprintFieldsFragment = {
id: string; id: string;
projectId: string; projectId: string;
@@ -4824,6 +4875,27 @@ export const DashboardShareFieldsFragmentDoc = {
}, },
], ],
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>; } 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 = { export const SprintFieldsFragmentDoc = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -18184,6 +18256,275 @@ export const SharedDashboardDocument = {
SharedDashboardQuery, SharedDashboardQuery,
SharedDashboardQueryVariables 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 = { export const SprintsDocument = {
kind: 'Document', kind: 'Document',
definitions: [ 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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui'; import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application'; 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'; import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
/** The «no release» choice of a select (an empty value). */ /** The «no release» choice of a select (an empty value). */
@@ -130,6 +135,15 @@ const NO_RELEASE = '';
(execute)="copyNotes(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') { @if (facade.canManage() && release.status !== 'ARCHIVED') {
<pi-action-button <pi-action-button
size="small" size="small"
@@ -166,6 +180,65 @@ const NO_RELEASE = '';
} }
</ol> </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> <ng-template #notesForm>
<form <form
class="ts-form" class="ts-form"
@@ -244,6 +317,16 @@ const NO_RELEASE = '';
</ng-template> </ng-template>
`, `,
styles: ` 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 { .ts-release__notes {
margin-top: 0.25rem; margin-top: 0.25rem;
summary { summary {
@@ -392,6 +475,13 @@ export class ReleasesPage {
viewChild.required<TemplateRef<unknown>>('createForm'); viewChild.required<TemplateRef<unknown>>('createForm');
private readonly notesForm = private readonly notesForm =
viewChild.required<TemplateRef<unknown>>('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 = private readonly archiveForm =
viewChild.required<TemplateRef<unknown>>('archiveForm'); viewChild.required<TemplateRef<unknown>>('archiveForm');
private readonly toast = inject(ToastService); private readonly toast = inject(ToastService);
@@ -491,6 +581,68 @@ export class ReleasesPage {
this.drawer.closeAll(); 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 { protected copyNotes(release: Release): void {
navigator.clipboard navigator.clipboard
?.writeText(release.notes ?? '') ?.writeText(release.notes ?? '')

View File

@@ -54,6 +54,15 @@ export const trackerRoutes: Routes = [
(m) => m.SharedDashboardPage, (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. // The Spanish paths, from links shared before.
{ path: 'cartera', redirectTo: 'portfolio' }, { path: 'cartera', redirectTo: 'portfolio' },
{ path: 'explorar', redirectTo: 'explore' }, { path: 'explorar', redirectTo: 'explore' },