diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 2b91aa0..0bcf62e 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2749,6 +2749,106 @@ "tags": [ "Issues" ] + }, + "delete": { + "description": "Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.", + "operationId": "IssuesController_trash", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Move the ticket to the trash", + "tags": [ + "Issues" + ] } }, "/api/issues/{issue}/status": { @@ -3085,6 +3185,192 @@ ] } }, + "/api/issues/{issue}/restore": { + "post": { + "operationId": "IssuesController_restore", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Bring the ticket back from the trash", + "tags": [ + "Issues" + ] + } + }, + "/api/projects/{project}/trash": { + "get": { + "operationId": "IssuesController_trashed", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueDto" + } + } + } + } + }, + "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": "The project's tickets in the trash, newest first", + "tags": [ + "Issues" + ] + } + }, "/api/issues/bulk": { "post": { "description": "Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.", @@ -10025,6 +10311,12 @@ "$ref": "#/components/schemas/ChecklistItemDto" } }, + "trashedAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "In the trash since then; null when it is not" + }, "devLinks": { "description": "Commits, branches and pull requests that mention it", "type": "array", @@ -10076,6 +10368,7 @@ "parent", "rank", "checklist", + "trashedAt", "devLinks", "commentCount", "createdAt", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 2e688ca..1949be5 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -439,6 +439,9 @@ type Issue { storyPoints: Int title: String! + """In the trash since then; null when it is not""" + trashedAt: String + """A ticket type id of the project's settings""" type: String! updatedAt: String! @@ -656,6 +659,9 @@ type Mutation { removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! + """Back from the trash""" + restoreIssue(issueId: ID!): CommandResult! + """Save the current board filters under a name (members of the project)""" saveFilter(input: SaveFilterInput!): ID! @@ -675,6 +681,11 @@ type Mutation { """Send a ping now and tell how it went""" testWebhook(webhookId: ID!): WebhookDelivery! + + """ + To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days + """ + trashIssue(issueId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Stop following a ticket""" @@ -937,6 +948,9 @@ type Query { """ sprints(projectId: ID!): [Sprint!]! + """A project's tickets in the trash, newest first""" + trash(projectId: ID!): [Issue!]! + """Planned releases of all my projects, soonest target date first""" upcomingReleases(limit: Int! = 10): [Release!]! user(id: String!): User diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index a85d885..394fa99 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -394,7 +394,11 @@ export interface paths { get: operations["IssuesController_findOne"]; put?: never; post?: never; - delete?: never; + /** + * Move the ticket to the trash + * @description Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good. + */ + delete: operations["IssuesController_trash"]; options?: never; head?: never; /** Edit title, description, type, priority, area, labels… */ @@ -455,6 +459,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/{issue}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bring the ticket back from the trash */ + post: operations["IssuesController_restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project}/trash": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The project's tickets in the trash, newest first */ + get: operations["IssuesController_trashed"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/bulk": { parameters: { query?: never; @@ -1674,6 +1712,11 @@ export interface components { /** @description Place in the backlog and its board column (ascending, as text) */ rank: string; checklist: components["schemas"]["ChecklistItemDto"][]; + /** + * Format: date-time + * @description In the trash since then; null when it is not + */ + trashedAt: string | null; /** @description Commits, branches and pull requests that mention it */ devLinks: components["schemas"]["DevLinkDto"][]; commentCount: number; @@ -4060,6 +4103,82 @@ export interface operations { }; }; }; + IssuesController_trash: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; IssuesController_update: { parameters: { query?: never; @@ -4380,6 +4499,140 @@ export interface operations { }; }; }; + IssuesController_restore: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + IssuesController_trashed: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssueDto"][]; + }; + }; + /** @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"]; + }; + }; + }; + }; IssuesController_bulkUpdate: { parameters: { query?: never; diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 88d143d..42f1b52 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -32,6 +32,7 @@ describe('appRoutes', () => { 'sprints', 'roadmap', 'epics', + 'trash', 'releases', 'dashboard', 'analytics', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index eef56a3..3ddb481 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -20,6 +20,7 @@ | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | | ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | | ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | | 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias | diff --git a/docs/03-api.md b/docs/03-api.md index 7c9cf52..3ba4a39 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -167,6 +167,11 @@ stateDiagram-v2 actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido, integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el event store. +- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/ + admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede + cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` / + `GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de + siempre: comentarios, enlaces y subtareas de vuelta al primer nivel. - **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o `unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto; diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index b13ad4d..06bbd00 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -101,6 +101,7 @@ donde los lee Compose. | `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) | | `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido | | `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios | +| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo | La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`). diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index e1e8ad6..7015e2c 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -33,6 +33,7 @@ import { RankIssueCommandHandler } from './rank-issue.command-handler'; import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler'; import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers'; import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues'; +import { TRASH_HANDLERS } from './trash'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { AddChecklistItemCommandHandler, @@ -77,6 +78,7 @@ export const COMMAND_HANDLERS = [ WriteReleaseNotesCommandHandler, ...SPRINT_COMMAND_HANDLERS, BulkUpdateIssuesCommandHandler, + ...TRASH_HANDLERS, AddChecklistItemCommandHandler, UpdateChecklistItemCommandHandler, RemoveChecklistItemCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 7076fd5..24a107e 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -78,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command'; import { PublishReleaseCommand } from './publish-release.command'; import { SetIssueReleaseCommand } from './set-issue-release.command'; import { WriteAccess } from './write-access'; +import { RestoreIssueCommand, TrashIssueCommand } from './trash'; import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues'; import { UpdateProjectSettingsCommand } from './update-project-settings.command'; import { ReadAccess } from '../queries/read-access'; @@ -666,6 +667,24 @@ describe('tracker commands (event sourced)', () => { ).rejects.toThrow(/Nothing to change/); }); + it('sends tickets to the trash and back; trashed ones cannot change', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}), + ); + await expect(run(new TrashIssueCommand('dev', issue.id))).rejects.toThrow( + ForbiddenError, + ); + await run(new TrashIssueCommand('qa', issue.id)); + await expect( + run(new AssignIssueCommand('owner', issue.id, 'dev')), + ).rejects.toThrow(/trash/); + await run(new RestoreIssueCommand('owner', issue.id)); + await run(new AssignIssueCommand('owner', issue.id, 'dev')); + expect((await rehydrator.rehydrate(issue.id, Issue)).trashedAt).toBeNull(); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 410fded..f997645 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -36,3 +36,4 @@ export * from './rank-issue.command'; export * from './sprint.commands'; export * from './write-release-notes.command'; export * from './bulk-update-issues'; +export * from './trash'; diff --git a/libs/api/tracker/application/src/lib/commands/trash.ts b/libs/api/tracker/application/src/lib/commands/trash.ts new file mode 100644 index 0000000..c65f9fa --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/trash.ts @@ -0,0 +1,103 @@ +import { + Command, + CommandBus, + CommandHandler, + ICommandHandler, +} from '@nestjs/cqrs'; +import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { CommandResult, ack } from './command-result'; +import { DeleteIssueCommand } from './delete-issue.command'; +import { WriteAccess } from './write-access'; + +/** Days a ticket stays in the trash before it is deleted for good. */ +export const TRASH_DAYS = 30; + +export class TrashIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + ) { + super(); + } +} + +export class RestoreIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + ) { + super(); + } +} + +/** Scheduler only: deletes for good what went to the trash before `before`. */ +export class EmptyTrashCommand extends Command { + constructor(public readonly before: string) { + super(); + } +} + +@CommandHandler(TrashIssueCommand) +export class TrashIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: TrashIssueCommand): Promise { + const { issue, project } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + const role = project.roleOf(command.actorId); + issue.trash(command.actorId, role === 'OWNER' || role === 'ADMIN'); + await issue.commitAndWait(); + return ack(issue); + } +} + +@CommandHandler(RestoreIssueCommand) +export class RestoreIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: RestoreIssueCommand): Promise { + const issue = await this.access.issue(command.issueId); + const project = await this.access.project(issue.projectId); + project.assertCanContribute(command.actorId); + const role = project.roleOf(command.actorId); + issue.restore(command.actorId, role === 'OWNER' || role === 'ADMIN'); + await issue.commitAndWait(); + return ack(issue); + } +} + +/** + * Each deletion is the usual one: comments purged, links dropped and + * subtasks back to the top, by the sagas. + */ +@CommandHandler(EmptyTrashCommand) +export class EmptyTrashCommandHandler + implements ICommandHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly commandBus: CommandBus, + ) {} + + async execute(command: EmptyTrashCommand): Promise { + const old = await this.findIssues.findTrashedBefore(command.before); + for (const issue of old) + await this.commandBus.execute( + new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id), + ); + return old.length; + } +} + +export const TRASH_HANDLERS = [ + TrashIssueCommandHandler, + RestoreIssueCommandHandler, + EmptyTrashCommandHandler, +]; diff --git a/libs/api/tracker/application/src/lib/commands/write-access.ts b/libs/api/tracker/application/src/lib/commands/write-access.ts index 21a9d56..43902c9 100644 --- a/libs/api/tracker/application/src/lib/commands/write-access.ts +++ b/libs/api/tracker/application/src/lib/commands/write-access.ts @@ -67,6 +67,10 @@ export class WriteAccess { const issue = await this.issue(issueId); const project = await this.project(issue.projectId); project.assertCanContribute(actorId); + if (issue.trashedAt) + throw new BusinessRuleError( + `${issue.key} is in the trash: restore it to change it`, + ); return { issue, project }; } diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index d933e6d..e4e8921 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -10,6 +10,8 @@ import { IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, IssueDevLinkRecordedEvent, + IssueTrashedEvent, + IssueRestoredEvent, IssueCreatedEvent, IssueLinkedEvent, IssueLinkType, @@ -163,6 +165,10 @@ export function describeEvent( ? `${actor} metió ${issueKey} en ${sprint}` : `${actor} sacó ${issueKey} de ${sprint}`; } + if (event instanceof IssueTrashedEvent) + return `${actor} movió ${issueKey} a la papelera`; + if (event instanceof IssueRestoredEvent) + return `${actor} recuperó ${issueKey} de la papelera`; if (event instanceof IssueDevLinkRecordedEvent) { const { kind, ref, state, author } = event.link; const who = author ? ` (${author})` : ''; diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index 6d45ccf..ec56dbd 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -12,6 +12,8 @@ import { IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, IssueDevLinkRecordedEvent, + IssueTrashedEvent, + IssueRestoredEvent, IssueCreatedEvent, IssueLinkedEvent, IssueReleaseChangedEvent, @@ -70,6 +72,8 @@ import { versionOf } from './read-model-notifier'; IssueAttachmentRemovedEvent, IssueParentChangedEvent, IssueDevLinkRecordedEvent, + IssueTrashedEvent, + IssueRestoredEvent, IssueChecklistItemAddedEvent, IssueChecklistItemUpdatedEvent, IssueChecklistItemRemovedEvent, diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 3e96a52..82ad89a 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -34,6 +34,7 @@ import { IssueRankedEventHandler } from './issue-ranked.event-handler'; import { SprintEventHandler } from './sprint.event-handler'; import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler'; import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler'; +import { IssueTrashEventHandler } from './issue-trash.event-handler'; import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ @@ -73,6 +74,7 @@ export const EVENT_HANDLERS = [ SprintEventHandler, ReleaseNotesWrittenEventHandler, IssueDevLinkRecordedEventHandler, + IssueTrashEventHandler, IssueSprintChangedEventHandler, ]; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts new file mode 100644 index 0000000..2510260 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-trash.event-handler.ts @@ -0,0 +1,27 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + IssueRestoredEvent, + IssueTrashedEvent, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueTrashedEvent, IssueRestoredEvent) +export class IssueTrashEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueTrashedEvent | IssueRestoredEvent) { + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + trashedAt: event instanceof IssueTrashedEvent ? event.occurredAt : null, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index f8bd2ac..bc205d3 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -16,6 +16,8 @@ import { DetachFileCommand } from './commands/detach-file.command'; import { WatchIssueCommand } from './commands/watch-issue.command'; import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { RankIssueCommand } from './commands/rank-issue.command'; +import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash'; +import { GetTrashQuery } from './queries/get-trash.query'; import { BulkChange, BulkResult, @@ -247,6 +249,22 @@ export class IssuesService { ); } + /** To the trash (whoever opened it, or a manager); back with `restore`. */ + trash(actorId: string, issueId: string): Promise { + return this.commandBus.execute(new TrashIssueCommand(actorId, issueId)); + } + + restore(actorId: string, issueId: string): Promise { + return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId)); + } + + trashed( + viewerId: string | null, + projectId: string, + ): Promise { + return this.queryBus.execute(new GetTrashQuery(viewerId, projectId)); + } + addChecklistItem( actorId: string, issueId: string, diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 9bb90cc..2ad2839 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -66,6 +66,10 @@ export abstract class FindIssuesRepository { abstract findOpenAssignedTo(userId: string): Promise; /** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */ abstract findDueBy(day: string): Promise; + /** The project's tickets in the trash, newest first. */ + abstract findTrashed(projectId: string): Promise; + /** Tickets that went to the trash before `before` (ISO), any project. */ + abstract findTrashedBefore(before: string): Promise; /** Every ticket of a project (statistics). */ abstract findByProject(projectId: string): Promise; /** Top-level tickets with others hanging from them, by key. */ diff --git a/libs/api/tracker/application/src/lib/queries/get-trash.query.ts b/libs/api/tracker/application/src/lib/queries/get-trash.query.ts new file mode 100644 index 0000000..5ec982f --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-trash.query.ts @@ -0,0 +1,29 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { IssueReadModel } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { ReadAccess } from './read-access'; + +/** A project's tickets in the trash, newest first. */ +export class GetTrashQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} + +@QueryHandler(GetTrashQuery) +export class GetTrashQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetTrashQuery): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + return this.findIssues.findTrashed(query.projectId); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index acf447f..73b1b45 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -20,3 +20,4 @@ export * from './get-attachment.query'; export * from './get-epics.query'; export * from './sprint.queries'; export * from './get-release-notes-draft.query'; +export * from './get-trash.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index c50508c..858b559 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -19,6 +19,7 @@ import { SearchIssuesQueryHandler } from './search-issues.query-handler'; import { GetEpicsQueryHandler } from './get-epics.query-handler'; import { SPRINT_QUERY_HANDLERS } from './sprint.queries'; import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query'; +import { GetTrashQueryHandler } from './get-trash.query'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ @@ -42,6 +43,7 @@ export const QUERY_HANDLERS = [ GetEpicsQueryHandler, ...SPRINT_QUERY_HANDLERS, GetReleaseNotesDraftQueryHandler, + GetTrashQueryHandler, SearchMyIssuesQueryHandler, GetAttachmentQueryHandler, ]; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 2d28c22..924528c 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -39,3 +39,5 @@ export * from './sprint-deleted.event'; export * from './issue-sprint-changed.event'; export * from './release-notes-written.event'; export * from './issue-dev-link-recorded.event'; +export * from './issue-trashed.event'; +export * from './issue-restored.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-restored.event.ts b/libs/api/tracker/domain/src/lib/events/issue-restored.event.ts new file mode 100644 index 0000000..58ad74e --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-restored.event.ts @@ -0,0 +1,10 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket came back from the trash. */ +@AutowiredEvent +export class IssueRestoredEvent extends TrackerEvent { + constructor(actorId: string, issueId: string, projectId: string) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts new file mode 100644 index 0000000..b45a87a --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-trashed.event.ts @@ -0,0 +1,10 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket went to the trash (it can come back for a while). */ +@AutowiredEvent +export class IssueTrashedEvent extends TrackerEvent { + constructor(actorId: string, issueId: string, projectId: string) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index 8be9403..dc173b2 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -279,6 +279,19 @@ describe('Issue', () => { }); }); + describe('trash', () => { + it('goes to the trash and back, by its reporter or a manager', () => { + const issue = newIssue(); + expect(() => issue.trash('u-dev', false)).toThrow(ForbiddenError); + issue.trash('u-qa', false); + issue.trash('u-qa', false); + expect(replay(issue, Issue).rebuilt.trashedAt).not.toBeNull(); + issue.restore('u-pm', true); + expect(issue.trashedAt).toBeNull(); + expect(issue.getUncommittedEvents()).toHaveLength(3); + }); + }); + describe('dates', () => { it('plans a start and a due date, never the wrong way round', () => { const issue = newIssue(); diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index e0f3ab5..41f4775 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -15,6 +15,8 @@ import { IssueDeletedEvent } from '../events/issue-deleted.event'; import { IssueLinkedEvent } from '../events/issue-linked.event'; import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; import { IssueDevLinkRecordedEvent } from '../events/issue-dev-link-recorded.event'; +import { IssueRestoredEvent } from '../events/issue-restored.event'; +import { IssueTrashedEvent } from '../events/issue-trashed.event'; import { IssueRankedEvent } from '../events/issue-ranked.event'; import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'; import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event'; @@ -122,6 +124,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public sprintId: string | null = null; /** Commits, branches and pull requests that mention it. */ public devLinks: DevLink[] = []; + /** In the trash since then (null: not in it). */ + public trashedAt: string | null = null; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -454,6 +458,29 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { return item; } + /** + * Sends it to the trash: hidden everywhere, back with `restore` until the + * trash is emptied. Whoever opened it, or whoever manages the project. + */ + trash(actorId: string, canManage: boolean): void { + if (this.trashedAt) return; + this.assertMayTrash(actorId, canManage); + this.apply(new IssueTrashedEvent(actorId, this.id, this.projectId)); + } + + restore(actorId: string, canManage: boolean): void { + if (!this.trashedAt) return; + this.assertMayTrash(actorId, canManage); + this.apply(new IssueRestoredEvent(actorId, this.id, this.projectId)); + } + + private assertMayTrash(actorId: string, canManage: boolean): void { + if (actorId !== this.reporterId && !canManage) + throw new ForbiddenError( + 'Only whoever opened a ticket, or a project admin, can delete it', + ); + } + /** Deletes the ticket (its project was deleted). Idempotent. */ delete(actorId: string): void { if (this.deleted) return; @@ -565,6 +592,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.updatedAt = event.occurredAt; } + onIssueTrashedEvent(event: IssueTrashedEvent): void { + this.trashedAt = event.occurredAt; + } + + onIssueRestoredEvent(): void { + this.trashedAt = null; + } + onIssueDeletedEvent(): void { this.deleted = true; } diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 0c564a4..6425de6 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -87,6 +87,8 @@ export class IssueReadModel { checklist!: ChecklistItemReadModel[]; /** Commits, branches and pull requests that mention it. */ devLinks!: DevLink[]; + /** In the trash since then (null: not in it). */ + trashedAt!: string | null; commentCount!: number; createdAt!: string; updatedAt!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 4db8de0..f5241c9 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -156,6 +156,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { if (filter.projectIds.length === 0) return { items: [], total: 0 }; const query: Record = { projectId: { $in: filter.projectIds }, + trashedAt: null, }; if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; @@ -195,7 +196,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { byArea: { _id: string | null; count: number }[]; byAssignee: { _id: UserRefReadModel | null; count: number }[]; }>([ - { $match: { projectId } }, + { $match: { projectId, trashedAt: null } }, { $facet: { total: [{ $count: 'count' }], @@ -233,7 +234,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { async findOpenAssignedTo(userId: string): Promise { const docs = await this.issues - .find({ 'assignee.id': userId, statusCategory: { $nin: FINISHED } }) + .find({ + 'assignee.id': userId, + statusCategory: { $nin: FINISHED }, + trashedAt: null, + }) .lean(); return docs.map(issueFromDoc); } @@ -242,6 +247,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { const docs = await this.issues .find({ dueDate: { $ne: null, $lte: `${day}\uffff` }, + trashedAt: null, statusCategory: { $nin: FINISHED }, $or: [{ assignee: { $ne: null } }, { 'watchers.0': { $exists: true } }], }) @@ -251,7 +257,26 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { } async findByProject(projectId: string): Promise { - const docs = await this.issues.find({ projectId }).lean(); + const docs = await this.issues + .find({ projectId, trashedAt: null }) + .lean(); + return docs.map(issueFromDoc); + } + + async findTrashed(projectId: string): Promise { + const docs = await this.issues + .find({ projectId, trashedAt: { $ne: null } }) + .sort({ trashedAt: -1 }) + .limit(500) + .lean(); + return docs.map(issueFromDoc); + } + + async findTrashedBefore(before: string): Promise { + const docs = await this.issues + .find({ trashedAt: { $ne: null, $lt: before } }) + .limit(1000) + .lean(); return docs.map(issueFromDoc); } @@ -265,7 +290,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { storyPoints: number; finishedStoryPoints: number; }>([ - { $match: { projectId, 'parent.id': { $type: 'string' } } }, + { + $match: { + projectId, + 'parent.id': { $type: 'string' }, + trashedAt: null, + }, + }, { $group: { _id: '$parent.id', @@ -280,7 +311,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { const progress = new Map(rows.map((row) => [row._id, row])); // Only the top of each tree: a ticket with subtasks under an epic is not one. const docs = await this.issues - .find({ _id: { $in: [...progress.keys()] }, 'parent.id': null }) + .find({ + _id: { $in: [...progress.keys()] }, + 'parent.id': null, + trashedAt: null, + }) .sort({ number: 1 }) .lean(); return docs.map((doc) => { @@ -311,7 +346,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { storyPoints: number; finishedStoryPoints: number; }>([ - { $match: { 'sprint.id': sprintId } }, + { $match: { 'sprint.id': sprintId, trashedAt: null } }, { $group: { _id: null, @@ -338,7 +373,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { _id: { status: string; category: StatusCategory }; count: number; }>([ - { $match: { 'release.id': releaseId } }, + { $match: { 'release.id': releaseId, trashedAt: null } }, { $group: { _id: { status: '$status', category: '$statusCategory' }, @@ -363,7 +398,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { } function toMongoFilter(filter: IssueFilter): FilterQuery { - const query: FilterQuery = { projectId: filter.projectId }; + // Tickets in the trash are only listed by the trash itself. + const query: FilterQuery = { + projectId: filter.projectId, + trashedAt: null, + }; if (filter.statuses?.length) query.status = { $in: filter.statuses }; if (filter.categories?.length) query['statusCategory'] = { $in: filter.categories }; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 80cd2cb..ab51ac1 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -133,6 +133,7 @@ IssueSchema.index({ 'parent.id': 1 }); IssueSchema.index({ projectId: 1, rank: 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ 'sprint.id': 1 }); +IssueSchema.index({ trashedAt: 1 }, { sparse: true }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); export const CommentSchema = new Schema( @@ -285,6 +286,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { startDate: rest.startDate ?? null, sprint: rest.sprint?.id ? rest.sprint : null, devLinks: rest.devLinks ?? [], + trashedAt: rest.trashedAt ?? null, checklist: rest.checklist ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts b/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts new file mode 100644 index 0000000..86dabe0 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/notifications/trash.scheduler.ts @@ -0,0 +1,60 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CommandBus } from '@nestjs/cqrs'; +import { + EmptyTrashCommand, + TRASH_DAYS, +} from '@task-stream/api/tracker/application'; + +const HOUR = 3_600_000; + +/** + * Deletes for good what has been in the trash longer than TRASH_DAYS (30 + * by default), a few minutes after start and then every six hours. + */ +@Injectable() +export class TrashScheduler + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private readonly logger = new Logger(TrashScheduler.name); + private timers: ReturnType[] = []; + + constructor( + private readonly commandBus: CommandBus, + private readonly config: ConfigService, + ) {} + + onApplicationBootstrap(): void { + const first = setTimeout(() => void this.run(), 5 * 60_000); + const every = setInterval(() => void this.run(), 6 * HOUR); + first.unref?.(); + every.unref?.(); + this.timers = [first, every]; + } + + onApplicationShutdown(): void { + this.timers.forEach((timer) => clearTimeout(timer)); + } + + private async run(): Promise { + const days = + Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS; + const before = new Date(Date.now() - days * 24 * HOUR).toISOString(); + try { + const deleted: number = await this.commandBus.execute( + new EmptyTrashCommand(before), + ); + if (deleted) + this.logger.log(`Deleted ${deleted} ticket(s) from the trash`); + } catch (error) { + this.logger.warn( + `Emptying the trash failed: ${(error as Error).message}`, + ); + } + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 1fa7367..e794cf2 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -54,6 +54,7 @@ import { import { StatusCategoryBackfill } from './mongo/status-category.backfill'; import { RankBackfill } from './mongo/rank.backfill'; import { ReminderScheduler } from './notifications/reminder.scheduler'; +import { TrashScheduler } from './notifications/trash.scheduler'; import { LOOKUP_ENTITIES } from './postgres/lookup.entities'; import { PostgresIssueNumbersRepository, @@ -159,6 +160,7 @@ const PORTS = [ StatusCategoryBackfill, RankBackfill, ReminderScheduler, + TrashScheduler, ], exports: PORTS.map((port) => port.provide), }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index c462809..bbe98a4 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -290,6 +290,36 @@ export class IssueResolver { ); } + @Mutation(() => CommandResultGql, { + description: + 'To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days', + }) + trashIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.issuesService.trash(me.id, issueId); + } + + @Mutation(() => CommandResultGql, { description: 'Back from the trash' }) + restoreIssue( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + ) { + return this.issuesService.restore(me.id, issueId); + } + + @OptionalAuth() + @Query(() => [IssueGql], { + description: "A project's tickets in the trash, newest first", + }) + trash( + @CurrentUser() me: User | undefined, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.issuesService.trashed(viewerOf(me), projectId); + } + @Mutation(() => BulkResultGql, { description: 'The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own', diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 113c9aa..ddf753e 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -342,6 +342,11 @@ export class IssueGql { }) rank!: string; @Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[]; + @Field(() => String, { + nullable: true, + description: 'In the trash since then; null when it is not', + }) + trashedAt!: string | null; @Field(() => [DevLinkGql], { description: 'Commits, branches and pull requests that mention it', }) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 166dc99..05c8b04 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -309,6 +309,13 @@ export class IssueDto { }) rank!: string; @ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[]; + @ApiProperty({ + type: String, + nullable: true, + format: 'date-time', + description: 'In the trash since then; null when it is not', + }) + trashedAt!: string | null; @ApiProperty({ type: [DevLinkDto], description: 'Commits, branches and pull requests that mention it', diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 00f8929..d2db009 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -311,6 +311,47 @@ export class IssuesController { ); } + @Delete('issues/:issue') + @ISSUE + @ApiOperation({ + summary: 'Move the ticket to the trash', + description: + 'Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async trash(@CurrentUser() me: User, @Param('issue') issue: string) { + return this.issues.trash(me.id, await this.refs.issueId(me.id, issue)); + } + + @Post('issues/:issue/restore') + @HttpCode(200) + @ISSUE + @ApiOperation({ summary: 'Bring the ticket back from the trash' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async restore(@CurrentUser() me: User, @Param('issue') issue: string) { + return this.issues.restore(me.id, await this.refs.issueId(me.id, issue)); + } + + @Get('projects/:project/trash') + @ApiOptionalPireonAuth() + @PROJECT + @ApiOperation({ summary: "The project's tickets in the trash, newest first" }) + @ApiOkResponse({ type: [IssueDto] }) + @ApiReadErrors() + async trashed( + @CurrentUser() me: User | undefined, + @Param('project') project: string, + ) { + return this.refs.issueDtos( + await this.issues.trashed( + viewerOf(me), + await this.refs.projectId(viewerOf(me), project), + ), + ); + } + @Post('issues/bulk') @HttpCode(200) @ApiOperation({ diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index 47ab353..e65183f 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -41,6 +41,7 @@ export const issuePageEvents = eventGroup({ files?: File[]; }>(), watchRequested: type<{ issueId: string; watching: boolean }>(), + trashRequested: type<{ issueId: string; trashed: boolean }>(), /** Place the ticket under another one by its key (e.g. WEB-3); null: out. */ parentRequested: type<{ issueId: string; parentKey: string | null }>(), subtaskCreateRequested: type<{ diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index 7565362..5aa9762 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -99,6 +99,13 @@ export class IssueFacade { ); } + /** To the trash (true) or back from it (false). */ + setTrashed(trashed: boolean): void { + this.withIssue((issueId) => + this.dispatch.trashRequested({ issueId, trashed }), + ); + } + /** Follow the ticket (or stop): be told when it moves or gets a comment. */ setWatching(watching: boolean): void { this.withIssue((issueId) => diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 34d0d08..73a5fb9 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -280,6 +280,22 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.trashRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + payload.trashed + ? issues.trash(payload.issueId) + : issues.restore(payload.issueId), + payload.trashed + ? 'Ticket en la papelera' + : 'Ticket recuperado', + done, + failed, + ), + ), + ), events .on(issuePageEvents.watchRequested) .pipe( diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 7d5064f..52a3966 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -119,6 +119,8 @@ export interface IssueSummary { /** Planned start and end (`YYYY-MM-DD`), for the roadmap. */ startDate: string | null; dueDate: string | null; + /** In the trash since then (null: not in it). */ + trashedAt: string | null; commentCount: number; updatedAt: string; version: number; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 52b3e67..5b3f2f3 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -115,6 +115,11 @@ export abstract class IssuesGateway { parentId: string | null, ): Observable; abstract epics(projectId: string): Observable; + /** To the trash (whoever opened it, or a manager). */ + abstract trash(issueId: string): Observable; + abstract restore(issueId: string): Observable; + /** The project's tickets in the trash, newest first. */ + abstract trashed(projectId: string): Observable; /** The same change on several tickets; each may refuse on its own. */ abstract bulkUpdate( issueIds: string[], diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index 6316158..e8aa18b 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -32,6 +32,9 @@ import { MyIssuesDocument, RankIssueDocument, RemoveChecklistItemDocument, + RestoreIssueDocument, + TrashDocument, + TrashIssueDocument, SearchIssuesDocument, SetIssueParentDocument, SetIssueReleaseDocument, @@ -180,6 +183,24 @@ export class GraphqlIssuesGateway extends IssuesGateway { ); } + trash(issueId: string): Observable { + return this.gql + .mutate(TrashIssueDocument, { issueId }) + .pipe(map((d) => toCommandResult(d.trashIssue))); + } + + restore(issueId: string): Observable { + return this.gql + .mutate(RestoreIssueDocument, { issueId }) + .pipe(map((d) => toCommandResult(d.restoreIssue))); + } + + trashed(projectId: string): Observable { + return this.gql + .query(TrashDocument, { projectId }) + .pipe(map((d) => d.trash.map(toIssueSummary))); + } + bulkUpdate(issueIds: string[], change: BulkChange): Observable { return this.gql .mutate(BulkUpdateIssuesDocument, { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index dfb5969..2f09486 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -87,6 +87,7 @@ fragment IssueSummaryFields on Issue { storyPoints startDate dueDate + trashedAt } fragment IssueFields on Issue { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 5b6c2a6..dce6e2a 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -481,6 +481,7 @@ export type IssueSummaryFieldsFragment = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -509,6 +510,7 @@ export type IssueFieldsFragment = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -652,6 +654,7 @@ export type BoardQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -688,6 +691,7 @@ export type IssueQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -747,6 +751,7 @@ export type SearchIssuesQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -903,6 +908,7 @@ export type MyIssuesQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -935,6 +941,7 @@ export type IssueChildrenQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -967,6 +974,7 @@ export type EpicsQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -1041,6 +1049,7 @@ export type BacklogQuery = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -1068,6 +1077,53 @@ export type BulkUpdateIssuesMutation = { }; }; +export type TrashIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type TrashIssueMutation = { + trashIssue: { id: string; key: string | null; version: number }; +}; + +export type RestoreIssueMutationVariables = Exact<{ + issueId: string; +}>; + +export type RestoreIssueMutation = { + restoreIssue: { id: string; key: string | null; version: number }; +}; + +export type TrashQueryVariables = Exact<{ + projectId: string; +}>; + +export type TrashQuery = { + trash: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + rank: string; + storyPoints: number | null; + startDate: string | null; + dueDate: string | null; + trashedAt: string | null; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + }>; +}; + export type NotificationFieldsFragment = { id: string; kind: NotificationKind; @@ -1714,6 +1770,7 @@ export type IssueChangedSubscription = { storyPoints: number | null; startDate: string | null; dueDate: string | null; + trashedAt: string | null; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; sprint: { id: string; name: string } | null; @@ -2323,6 +2380,7 @@ export const IssueSummaryFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -2555,6 +2613,7 @@ export const IssueFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -4010,6 +4069,7 @@ export const BoardDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -4156,6 +4216,7 @@ export const IssueDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -4436,6 +4497,7 @@ export const SearchIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -5682,6 +5744,7 @@ export const MyIssuesDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -5842,6 +5905,7 @@ export const IssueChildrenDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -6020,6 +6084,7 @@ export const EpicsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -6502,6 +6567,7 @@ export const BacklogDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, @@ -6646,6 +6712,287 @@ export const BulkUpdateIssuesDocument = { BulkUpdateIssuesMutation, BulkUpdateIssuesMutationVariables >; +export const TrashIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'TrashIssue' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'trashIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const RestoreIssueDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RestoreIssue' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'restoreIssue' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + RestoreIssueMutation, + RestoreIssueMutationVariables +>; +export const TrashDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Trash' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'trash' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'rank' } }, + { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const MyNotificationsDocument = { kind: 'Document', definitions: [ @@ -10421,6 +10768,7 @@ export const IssueChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } }, { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, { kind: 'Field', name: { kind: 'Name', value: 'dueDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index acda925..8d73545 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -221,3 +221,21 @@ mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) { } } } + +mutation TrashIssue($issueId: ID!) { + trashIssue(issueId: $issueId) { + ...CommandResultFields + } +} + +mutation RestoreIssue($issueId: ID!) { + restoreIssue(issueId: $issueId) { + ...CommandResultFields + } +} + +query Trash($projectId: ID!) { + trash(projectId: $projectId) { + ...IssueSummaryFields + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts index d4bfb63..4fb8f84 100644 --- a/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/backlog.page.ts @@ -223,6 +223,9 @@ const NOBODY = '__none__'; description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge." /> } +

+ Papelera +

`, styles: ` .ts-backlog { @@ -275,6 +278,11 @@ const NOBODY = '__none__'; align-items: center; font-size: 0.875rem; } + .ts-backlog__trash { + margin: 1rem 0 0; + font-size: 0.875rem; + text-align: right; + } .ts-backlog__pos { color: var(--ts-muted); font-size: 0.75rem; diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index 82792f8..e3982e8 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -520,3 +520,14 @@ a.ts-key { .ts-dev__when { font-size: 0.8125rem; } + +.ts-trashed { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent); +} diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 9d1c6a1..cd608de 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -94,6 +94,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; ← Tablero @if (facade.issue(); as issue) { + @if (issue.trashedAt) { +
+ En la papelera desde {{ ago(issue.trashedAt) }}: no aparece en el + tablero ni en las búsquedas, y se borrará del todo a los 30 días. + @if (canTrash(issue)) { + + } +
+ }
@@ -698,6 +712,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; Área + @if (!issue.trashedAt && canTrash(issue)) { + + }
Reportado por
{{ issue.reporter.name }}
@@ -1122,6 +1145,23 @@ export class IssuePage { } /** A screenshot pasted anywhere on the ticket is attached to it. */ + /** Whoever opened it, or whoever manages the project. */ + protected canTrash(issue: { reporter: { id: string } }): boolean { + return ( + this.project.canContribute() && + (issue.reporter.id === this.facade.me() || this.project.canManage()) + ); + } + + protected trash(): void { + if ( + confirm( + '¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.', + ) + ) + this.facade.setTrashed(true); + } + /** `e`: edit the ticket (not while typing, nor with a drawer open). */ @HostListener('document:keydown', ['$event']) protected onKey(event: KeyboardEvent): void { diff --git a/libs/web/tracker/presenters/src/lib/pages/trash.page.ts b/libs/web/tracker/presenters/src/lib/pages/trash.page.ts new file mode 100644 index 0000000..eb33911 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/trash.page.ts @@ -0,0 +1,130 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { PiActionButtonComponent, PiEmptyStateComponent } from '@pi/ui'; +import { ToastService } from '@task-stream/web/shared/ui'; +import { ProjectFacade } from '@task-stream/web/tracker/application'; +import { IssueSummary, IssuesGateway } from '@task-stream/web/tracker/domain'; +import { timeAgo } from '../ui/copy'; + +/** /p/:key/trash — tickets deleted in the last 30 days, to bring back. */ +@Component({ + selector: 'ts-trash-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, PiActionButtonComponent, PiEmptyStateComponent], + template: ` +
+

+ Tickets borrados en los últimos 30 días. Después se borran del todo, con + sus comentarios. +

+
+ @if (issues().length) { +
    + @for (issue of issues(); track issue.id) { +
  • + {{ + issue.key + }} + {{ issue.title }} + {{ + issue.trashedAt ? ago(issue.trashedAt) : '' + }} + @if (project.canContribute()) { + + } +
  • + } +
+ } @else if (loaded()) { + + } + `, + styles: ` + .ts-trash { + margin: 0; + padding: 0.25rem 0; + list-style: none; + } + .ts-trash__row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.875rem; + border-top: 1px solid var(--ts-line); + &:first-child { + border-top: 0; + } + } + .ts-trash__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `, +}) +export class TrashPage { + protected readonly project = inject(ProjectFacade); + private readonly gateway = inject(IssuesGateway); + private readonly toast = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); + protected readonly issues = signal([]); + protected readonly loaded = signal(false); + protected readonly ago = timeAgo; + + constructor() { + effect(() => { + const projectId = this.project.project()?.id; + if (projectId) untracked(() => this.reload(projectId)); + }); + } + + protected restore(issue: IssueSummary): void { + this.gateway + .restore(issue.id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.toast.success(`${issue.key} recuperado`); + this.issues.update((list) => list.filter((i) => i.id !== issue.id)); + }, + error: (error) => + this.toast.error( + (error as { message?: string })?.message?.split('\n')[0] || + 'No se pudo recuperar', + ), + }); + } + + private reload(projectId: string): void { + this.gateway + .trashed(projectId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (issues) => { + this.issues.set(issues); + this.loaded.set(true); + }, + error: () => this.loaded.set(true), + }); + } +} diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts index dd28b2f..2ae1475 100644 --- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts +++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts @@ -62,6 +62,12 @@ export const trackerRoutes: Routes = [ loadComponent: () => import('./pages/epics.page').then((m) => m.EpicsPage), }, + { + path: 'trash', + title: 'Papelera · TaskStream', + loadComponent: () => + import('./pages/trash.page').then((m) => m.TrashPage), + }, { path: 'releases', title: 'Releases · TaskStream',