diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index ad278f1..1d77204 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1567,4 +1567,78 @@ describe('task-stream (e2e)', () => { ).status, ).toBe(200); }); + + it('archives a project: readable, read-only, back on demand', async () => { + const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase(); + await rest('token-ana', 'POST', '/projects', { key, name: key }); + await eventually( + () => rest('token-ana', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + const added = await rest('token-ana', 'POST', `/projects/${key}/members`, { + userId: '102', + }); + expect(added.status).toBe(201); + await eventually( + () => rest('token-bob', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + const created = await rest('token-bob', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Antes de archivar', + }); + expect(created.body).toMatchObject({ key: `${key}-1` }); + const ticket = created.body['key'] as string; + await eventually( + () => rest('token-bob', 'GET', `/issues/${ticket}`), + (r) => r.status === 200, + ); + + expect( + (await rest('token-bob', 'POST', `/projects/${key}/archive`)).status, + ).toBe(403); + expect( + (await rest('token-ana', 'POST', `/projects/${key}/archive`)).status, + ).toBe(200); + const archived = await eventually( + () => rest('token-bob', 'GET', `/projects/${key}`), + (r) => !!r.body['archivedAt'], + ); + expect(archived.status).toBe(200); + // Still readable; nothing changes. + expect((await rest('token-bob', 'GET', `/issues/${ticket}`)).status).toBe( + 200, + ); + const refused = await rest('token-bob', 'PATCH', `/issues/${ticket}`, { + title: 'Después', + }); + expect(refused.status).toBe(422); + expect(refused.body['message']).toMatch(/archivado/); + expect( + ( + await rest('token-bob', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Nuevo', + }) + ).status, + ).toBe(422); + + const { unarchiveProject } = await gql( + 'token-ana', + `mutation($p: ID!) { unarchiveProject(projectId: $p) { version } }`, + { p: archived.body['id'] }, + ); + expect(unarchiveProject.version).toBeGreaterThan(0); + await eventually( + () => rest('token-bob', 'GET', `/projects/${key}`), + (r) => r.body['archivedAt'] === null, + ); + expect( + ( + await rest('token-bob', 'PATCH', `/issues/${ticket}`, { + title: 'Después', + }) + ).status, + ).toBe(200); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7b8ab0a..aee07ae 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -960,6 +960,209 @@ ] } }, + "/api/projects/{project}/archive": { + "post": { + "description": "It stays readable (board, tickets, history, statistics); nothing can change until it is unarchived.", + "operationId": "ProjectsController_archive", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "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": "Archive a project (owner only)", + "tags": [ + "Projects" + ] + } + }, + "/api/projects/{project}/unarchive": { + "post": { + "operationId": "ProjectsController_unarchive", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "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 an archived project back (owner only)", + "tags": [ + "Projects" + ] + } + }, "/api/projects/{project}/settings": { "put": { "description": "Replaces the whole configuration (send what GET /projects/{project} returns, changed). Values can be added, renamed, reordered or archived, never removed: tickets and statistics keep referring to them.", @@ -11041,6 +11244,12 @@ "type": "string", "format": "date-time" }, + "archivedAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Archived since then (read-only); null while active" + }, "updatedAt": { "type": "string", "format": "date-time" @@ -11061,6 +11270,7 @@ "members", "settings", "createdAt", + "archivedAt", "updatedAt", "version" ] diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 901b59c..3c8d33c 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -792,6 +792,11 @@ type Mutation { """Add a Pireon user to the project""" addProjectMember(input: ProjectMemberInput!): CommandResult! + + """ + Archive the project (owner only): everything stays readable, nothing changes until it is unarchived + """ + archiveProject(projectId: ID!): CommandResult! archiveRelease( """ Open release of the same project that takes its unfinished tickets; none: they leave it @@ -923,6 +928,9 @@ type Mutation { To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days """ trashIssue(issueId: ID!): CommandResult! + + """Bring an archived project back (owner only)""" + unarchiveProject(projectId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Push off on this device""" @@ -1071,6 +1079,9 @@ type Project { """ analytics(days: Int! = 90): ProjectAnalytics! + """Archived since then (read-only); null while active""" + archivedAt: String + """Kanban board, one column per status""" board( area: String diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index fd4e0d6..0293bd4 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -211,6 +211,43 @@ export interface paths { patch: operations["ProjectsController_update"]; trace?: never; }; + "/api/projects/{project}/archive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Archive a project (owner only) + * @description It stays readable (board, tickets, history, statistics); nothing can change until it is unarchived. + */ + post: operations["ProjectsController_archive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project}/unarchive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bring an archived project back (owner only) */ + post: operations["ProjectsController_unarchive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/projects/{project}/settings": { parameters: { query?: never; @@ -1770,6 +1807,11 @@ export interface components { settings: components["schemas"]["ProjectSettingsDto"]; /** Format: date-time */ createdAt: string; + /** + * Format: date-time + * @description Archived since then (read-only); null while active + */ + archivedAt: string | null; /** Format: date-time */ updatedAt: string; /** @description Version of the aggregate (event count) */ @@ -3876,6 +3918,158 @@ export interface operations { }; }; }; + ProjectsController_archive: { + 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"]["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"]; + }; + }; + }; + }; + ProjectsController_unarchive: { + 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"]["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"]; + }; + }; + }; + }; ProjectsController_updateSettings: { parameters: { query?: never; diff --git a/docs/03-api.md b/docs/03-api.md index 4973508..2df1dd4 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -80,6 +80,15 @@ sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros [Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event store como auditoría, pero desde la API no se puede deshacer. +### Archivar un proyecto + +El propietario archiva un proyecto terminado (`archiveProject` / `POST /api/projects/{project}/archive`) en lugar de +borrarlo: se sigue viendo todo (tablero, tickets, historia, estadísticas, y quien lo administra sigue viendo sus +reglas y webhooks), pero ninguna orden lo cambia (`422`, «está archivado»): ni tickets, ni comentarios, ni votos, +ni ajustes, ni miembros; las automatizaciones no se ejecutan y el formulario público no acepta propuestas. Seguir un +ticket sí se puede, porque solo atañe a quien lo sigue. `unarchiveProject` / `POST …/unarchive` lo devuelve. +`Project.archivedAt` dice desde cuándo. + ### Configuración por proyecto Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados** @@ -643,7 +652,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | Recurso | Rutas | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Tickets | `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | diff --git a/docs/10-futuro.md b/docs/10-futuro.md index a74e09b..fc86a76 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -44,7 +44,6 @@ Nada: la tanda está hecha (ver 10.5). - **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos). - **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. - **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página. -- **Archivar un proyecto**: queda en solo lectura en lugar de borrarlo. ### Otras ideas apuntadas, sin decidir @@ -120,3 +119,4 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi prioridades, solo bugs, vencidos, texto), en una barra encima de «Mi trabajo». - Definición de «Hecho» por proyecto: con ella activada, un ticket no se cierra con la checklist a medias (el propietario y los admins sí pueden). +- Archivar un proyecto: queda en solo lectura (se ve todo, no cambia nada) hasta que su propietario lo desarchiva. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index c8c76f8..4ca5d35 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 9d50cea..3a17f7a 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/automations/automations.ts b/libs/api/tracker/application/src/lib/automations/automations.ts index 9f3944d..55499e8 100644 --- a/libs/api/tracker/application/src/lib/automations/automations.ts +++ b/libs/api/tracker/application/src/lib/automations/automations.ts @@ -96,9 +96,12 @@ async function managedProject( access: WriteAccess, projectId: string, actorId: string, + /** Only to read the rules: an archived project still shows them. */ + reading = false, ): Promise { const project = await access.project(projectId); - project.assertCanManage(actorId); + if (reading) project.assertManages(actorId); + else project.assertCanManage(actorId); return project; } @@ -231,6 +234,7 @@ export class RunAutomationCommandHandler ? await this.access.live(issue.projectId, Project) : null; if (!issue || !project) return 'The ticket is gone'; + if (project.archivedAt) return 'The project is archived'; const { kind, value } = command.automation.action; const actor = SYSTEM_ACTOR_ID; switch (kind) { @@ -297,7 +301,7 @@ export class GetAutomationsQueryHandler ) {} async execute(query: GetAutomationsQuery): Promise { - await managedProject(this.access, query.projectId, query.viewerId); + await managedProject(this.access, query.projectId, query.viewerId, true); return this.automations.findByProject(query.projectId); } } diff --git a/libs/api/tracker/application/src/lib/commands/archive-project.command-handler.ts b/libs/api/tracker/application/src/lib/commands/archive-project.command-handler.ts new file mode 100644 index 0000000..d218331 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/archive-project.command-handler.ts @@ -0,0 +1,19 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ArchiveProjectCommand } from './archive-project.command'; +import { CommandResult, ack } from './command-result'; +import { WriteAccess } from './write-access'; + +@CommandHandler(ArchiveProjectCommand) +export class ArchiveProjectCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: ArchiveProjectCommand): Promise { + const project = await this.access.project(command.projectId); + if (command.archived) project.archive(command.actorId); + else project.unarchive(command.actorId); + await project.commitAndWait(); + return ack(project); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/archive-project.command.ts b/libs/api/tracker/application/src/lib/commands/archive-project.command.ts new file mode 100644 index 0000000..99d338d --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/archive-project.command.ts @@ -0,0 +1,13 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** The owner archives the project (read-only) or, with `archived` false, brings it back. */ +export class ArchiveProjectCommand extends Command { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly archived: boolean, + ) { + super(); + } +} 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 eadf1cc..550e908 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -21,6 +21,7 @@ import { UpdateReleaseCommandHandler } from './update-release.command-handler'; import { PublishReleaseCommandHandler } from './publish-release.command-handler'; import { ArchiveReleaseCommandHandler } from './archive-release.command-handler'; import { DeleteProjectCommandHandler } from './delete-project.command-handler'; +import { ArchiveProjectCommandHandler } from './archive-project.command-handler'; import { DeleteIssueCommandHandler } from './delete-issue.command-handler'; import { DeleteReleaseCommandHandler } from './delete-release.command-handler'; import { PurgeCommentCommandHandler } from './purge-comment.command-handler'; @@ -66,6 +67,7 @@ export const COMMAND_HANDLERS = [ PublishReleaseCommandHandler, ArchiveReleaseCommandHandler, DeleteProjectCommandHandler, + ArchiveProjectCommandHandler, // Emitted by sagas only (cascades and reactions to other aggregates). DeleteIssueCommandHandler, DeleteReleaseCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts index d1f1873..7d83fa9 100644 --- a/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts @@ -24,6 +24,7 @@ export class VoteIssueCommandHandler throw new ForbiddenError( `You are not a member of project ${project.key}`, ); + project.assertNotArchived(); if (issue.trashedAt) throw new BusinessRuleError(`${issue.key} is in the trash`); issue.vote(command.actorId, command.on); 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 1dc68b8..ba61351 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 @@ -26,6 +26,8 @@ import { ProjectRole, ProjectSettingsChangedEvent, ProjectUpdatedEvent, + ProjectArchivedEvent, + ProjectUnarchivedEvent, ProjectWorkflow, ReleaseArchivedEvent, ReleaseNotesWrittenEvent, @@ -109,6 +111,10 @@ export function describeEvent( return `${actor} creó el proyecto ${event.project.key}`; if (event instanceof ProjectUpdatedEvent) return `${actor} actualizó el proyecto ${ctx.projectKey}`; + if (event instanceof ProjectArchivedEvent) + return `${actor} archivó el proyecto ${ctx.projectKey}`; + if (event instanceof ProjectUnarchivedEvent) + return `${actor} desarchivó el proyecto ${ctx.projectKey}`; if (event instanceof ProjectSettingsChangedEvent) return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`; if (event instanceof ProjectMemberAddedEvent) { 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 ec56dbd..57045af 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 @@ -25,6 +25,8 @@ import { ProjectMemberRemovedEvent, ProjectMemberRoleChangedEvent, ProjectUpdatedEvent, + ProjectArchivedEvent, + ProjectUnarchivedEvent, ReleaseArchivedEvent, ReleaseNotesWrittenEvent, IssueSprintChangedEvent, @@ -57,6 +59,8 @@ import { versionOf } from './read-model-notifier'; @EventsHandler( ProjectCreatedEvent, ProjectUpdatedEvent, + ProjectArchivedEvent, + ProjectUnarchivedEvent, ProjectSettingsChangedEvent, ProjectMemberAddedEvent, ProjectMemberRemovedEvent, 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 ae84746..47b1bbc 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 @@ -16,6 +16,7 @@ import { ProjectMemberAddedEventHandler } from './project-member-added.event-han import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler'; import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler'; import { ProjectUpdatedEventHandler } from './project-updated.event-handler'; +import { ProjectArchivedEventHandler } from './project-archived.event-handler'; import { ReleaseArchivedEventHandler } from './release-archived.event-handler'; import { ReleaseCreatedEventHandler } from './release-created.event-handler'; import { ReleaseShippedEventHandler } from './release-shipped.event-handler'; @@ -63,6 +64,7 @@ export const EVENT_HANDLERS = [ ProjectMemberRemovedEventHandler, ProjectMemberRoleChangedEventHandler, ProjectUpdatedEventHandler, + ProjectArchivedEventHandler, ProjectSettingsChangedEventHandler, ReleaseArchivedEventHandler, ReleaseCreatedEventHandler, diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-archived.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-archived.event-handler.ts new file mode 100644 index 0000000..40d61a1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/project-archived.event-handler.ts @@ -0,0 +1,28 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + ProjectArchivedEvent, + ProjectUnarchivedEvent, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(ProjectArchivedEvent, ProjectUnarchivedEvent) +export class ProjectArchivedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: ProjectArchivedEvent | ProjectUnarchivedEvent) { + await this.upsertMaterializedProjectRepository.upsert({ + id: event.projectId, + archivedAt: + event instanceof ProjectArchivedEvent ? event.occurredAt : null, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.notifier.project(event); + } +} diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts index 5c95379..8e3c6f8 100644 --- a/libs/api/tracker/application/src/lib/projects.service.ts +++ b/libs/api/tracker/application/src/lib/projects.service.ts @@ -29,6 +29,7 @@ import { ChangeProjectMemberRoleCommand } from './commands/change-project-member import { CommandResult } from './commands/command-result'; import { CreateProjectCommand } from './commands/create-project.command'; import { DeleteProjectCommand } from './commands/delete-project.command'; +import { ArchiveProjectCommand } from './commands/archive-project.command'; import { RemoveProjectMemberCommand } from './commands/remove-project-member.command'; import { UpdateProjectCommand } from './commands/update-project.command'; import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command'; @@ -75,7 +76,7 @@ export class ProjectsService { (await this.findProjects.findById(projectIdOrKey)) ?? (await this.findProjects.findByKey(projectIdOrKey.toUpperCase())); if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey); - if (!found.acceptsProposals) + if (!found.acceptsProposals || found.archivedAt) throw new ForbiddenError(`${found.key} does not take proposals`); const cleanTitle = title.trim(); @@ -170,7 +171,8 @@ export class ProjectsService { automations: Automation[]; }> { const project = await this.access.project(projectId); - project.assertCanManage(actorId); + // Copying only reads it: an archived project makes a fine template. + project.assertManages(actorId); const automations: Automation[] = await this.queryBus.execute( new GetAutomationsQuery(actorId, projectId), ); @@ -241,6 +243,17 @@ export class ProjectsService { } /** Statuses, transitions, ticket types and areas (owners and admins). */ + /** Archives the project (read-only) or brings it back; its owner only. */ + setArchived( + actorId: string, + projectId: string, + archived: boolean, + ): Promise { + return this.commands.execute( + new ArchiveProjectCommand(actorId, projectId, archived), + ); + } + updateSettings( actorId: string, projectId: string, diff --git a/libs/api/tracker/application/src/lib/webhooks/webhooks.ts b/libs/api/tracker/application/src/lib/webhooks/webhooks.ts index 1fc29ac..e3bc7f2 100644 --- a/libs/api/tracker/application/src/lib/webhooks/webhooks.ts +++ b/libs/api/tracker/application/src/lib/webhooks/webhooks.ts @@ -327,9 +327,7 @@ export class GetWebhooksQueryHandler ) {} async execute(query: GetWebhooksQuery): Promise { - (await this.access.project(query.projectId)).assertCanManage( - query.viewerId, - ); + (await this.access.project(query.projectId)).assertManages(query.viewerId); return (await this.webhooks.findByProject(query.projectId)).map(view); } } diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index c8b8789..6625255 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -21,6 +21,7 @@ export * from './release-updated.event'; export * from './release-shipped.event'; export * from './release-archived.event'; export * from './project-deleted.event'; +export * from './project-archived.event'; export * from './issue-deleted.event'; export * from './release-deleted.event'; export * from './issue-attachment-added.event'; diff --git a/libs/api/tracker/domain/src/lib/events/project-archived.event.ts b/libs/api/tracker/domain/src/lib/events/project-archived.event.ts new file mode 100644 index 0000000..38567f8 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/project-archived.event.ts @@ -0,0 +1,18 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** The owner archived the project: read-only until it is unarchived. */ +@AutowiredEvent +export class ProjectArchivedEvent extends TrackerEvent { + constructor(actorId: string, projectId: string) { + super(actorId, projectId, projectId); + } +} + +/** The owner brought an archived project back. */ +@AutowiredEvent +export class ProjectUnarchivedEvent extends TrackerEvent { + constructor(actorId: string, projectId: string) { + super(actorId, projectId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts b/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts index 25f50cc..68ea56a 100644 --- a/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts +++ b/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts @@ -106,4 +106,28 @@ describe('Deleting a project', () => { ).toHaveLength(2); expect(source.links.map((l) => l.targetIssueId)).toEqual(['o']); }); + + it('archives a project read-only, and only its owner brings it back', () => { + const project = newProject(); + expect(() => project.archive('admin')).toThrow(ForbiddenError); + project.archive('owner'); + expect(project.archivedAt).not.toBeNull(); + // Idempotent. + const events = project.getUncommittedEvents().length; + project.archive('owner'); + expect(project.getUncommittedEvents()).toHaveLength(events); + + // Nothing changes, but who manages it can still read what only they see. + expect(() => project.assertCanContribute('admin')).toThrow(/archivado/); + expect(() => project.updateDetails('owner', { name: 'Otra' })).toThrow( + BusinessRuleError, + ); + expect(() => project.assertManages('admin')).not.toThrow(); + expect(() => project.unarchive('admin')).toThrow(ForbiddenError); + + project.unarchive('owner'); + expect(project.archivedAt).toBeNull(); + expect(() => project.assertCanContribute('admin')).not.toThrow(); + expect(replay(project, Project).rebuilt.archivedAt).toBeNull(); + }); }); diff --git a/libs/api/tracker/domain/src/lib/project/project.ts b/libs/api/tracker/domain/src/lib/project/project.ts index ddb4977..6af6af5 100644 --- a/libs/api/tracker/domain/src/lib/project/project.ts +++ b/libs/api/tracker/domain/src/lib/project/project.ts @@ -4,6 +4,10 @@ import { VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; +import { + ProjectArchivedEvent, + ProjectUnarchivedEvent, +} from '../events/project-archived.event'; import { ProjectCreatedEvent } from '../events/project-created.event'; import { ProjectDeletedEvent } from '../events/project-deleted.event'; import { ProjectMemberAddedEvent } from '../events/project-member-added.event'; @@ -62,6 +66,8 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { public updatedAt!: string; /** Set once deleted: the project can no longer be loaded for commands. */ public deleted = false; + /** Archived since then: read-only until its owner brings it back. */ + public archivedAt: string | null = null; constructor(id: string) { super(); @@ -96,10 +102,20 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { if (this.roleOf(userId) === ProjectRole.VIEWER) { throw new ForbiddenError(`Viewers cannot modify project ${this.key}`); } + this.assertNotArchived(); } - /** Can manage members, releases and project settings. */ + /** Can change members, releases, settings and rules (owners and admins). */ assertCanManage(userId: string): void { + this.assertManages(userId); + this.assertNotArchived(); + } + + /** + * Is an owner or an admin: enough to read what only they see (rules, + * webhooks…), archived or not. To change anything, assertCanManage. + */ + assertManages(userId: string): void { const role = this.roleOf(userId); if (role !== ProjectRole.OWNER && role !== ProjectRole.ADMIN) { throw new ForbiddenError( @@ -108,6 +124,36 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { } } + /** An archived project only changes back to active (by its owner). */ + assertNotArchived(): void { + if (this.archivedAt) + throw new BusinessRuleError( + `${this.key} está archivado: es de solo lectura hasta que su propietario lo desarchive`, + ); + } + + /** + * Only the owner archives a project: everything stays readable, nothing + * changes until they unarchive it. Idempotent. + */ + archive(actorId: string): void { + if (actorId !== this.ownerId) + throw new ForbiddenError( + `Only the owner can archive project ${this.key}`, + ); + if (this.archivedAt) return; + this.apply(new ProjectArchivedEvent(actorId, this.id)); + } + + unarchive(actorId: string): void { + if (actorId !== this.ownerId) + throw new ForbiddenError( + `Only the owner can unarchive project ${this.key}`, + ); + if (!this.archivedAt) return; + this.apply(new ProjectUnarchivedEvent(actorId, this.id)); + } + updateDetails( actorId: string, changes: { @@ -249,6 +295,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { this.deleted = true; } + onProjectArchivedEvent(event: ProjectArchivedEvent): void { + this.archivedAt = event.occurredAt; + } + + onProjectUnarchivedEvent(): void { + this.archivedAt = null; + } + onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void { this.settings = structuredClone(event.settings); this.updatedAt = event.occurredAt; diff --git a/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts index dd77a55..b719422 100644 --- a/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts @@ -18,6 +18,8 @@ export class ProjectReadModel { acceptsProposals!: boolean; owner!: UserRefReadModel; members!: ProjectMemberReadModel[]; + /** Archived since then (read-only); null while active. */ + archivedAt!: string | null; /** Statuses, transitions, ticket types and areas. */ settings!: ProjectSettings; createdAt!: 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 eb2a32f..6d21049 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -66,6 +66,7 @@ function toProject(doc: ProjectDoc): ProjectReadModel { isPublic: rest.isPublic ?? false, acceptsProposals: rest.acceptsProposals ?? false, settings: rest.settings ?? defaultProjectSettings(), + archivedAt: rest.archivedAt ?? null, }; } diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index f89c7ff..a4b8e32 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -219,6 +219,27 @@ export class ProjectResolver { return this.projects.delete(me.id, projectId, confirmKey); } + @Mutation(() => CommandResultGql, { + description: + 'Archive the project (owner only): everything stays readable, nothing changes until it is unarchived', + }) + archiveProject( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.projects.setArchived(me.id, projectId, true); + } + + @Mutation(() => CommandResultGql, { + description: 'Bring an archived project back (owner only)', + }) + unarchiveProject( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.projects.setArchived(me.id, projectId, false); + } + @Mutation(() => CommandResultGql, { description: 'Add a Pireon user to the project', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index c7d1949..af31998 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -137,6 +137,11 @@ export class ProjectGql { @Field(() => UserRefGql) owner!: UserRefGql; @Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[]; @Field() createdAt!: string; + @Field(() => String, { + nullable: true, + description: 'Archived since then (read-only); null while active', + }) + archivedAt!: string | null; @Field() updatedAt!: string; @Field(() => Int) version!: number; } 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 1c80878..c33aef2 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 @@ -128,6 +128,13 @@ export class ProjectDto { @ApiProperty({ type: ProjectSettingsDto }) settings!: ProjectSettingsDto; @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty({ + type: String, + nullable: true, + format: 'date-time', + description: 'Archived since then (read-only); null while active', + }) + archivedAt!: string | null; @ApiProperty({ format: 'date-time' }) updatedAt!: string; @ApiProperty({ description: 'Version of the aggregate (event count)' }) version!: number; diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index 24d2097..447605c 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -5,6 +5,7 @@ import { Get, Param, Patch, + HttpCode, Post, Put, Query, @@ -148,6 +149,38 @@ export class ProjectsController { ); } + @Post(':project/archive') + @HttpCode(200) + @PROJECT + @ApiOperation({ + summary: 'Archive a project (owner only)', + description: + 'It stays readable (board, tickets, history, statistics); nothing can change until it is unarchived.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async archive(@CurrentUser() me: User, @Param('project') project: string) { + return this.projects.setArchived( + me.id, + await this.refs.projectId(me.id, project), + true, + ); + } + + @Post(':project/unarchive') + @HttpCode(200) + @PROJECT + @ApiOperation({ summary: 'Bring an archived project back (owner only)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async unarchive(@CurrentUser() me: User, @Param('project') project: string) { + return this.projects.setArchived( + me.id, + await this.refs.projectId(me.id, project), + false, + ); + } + @Put(':project/settings') @PROJECT @ApiOperation({ diff --git a/libs/web/tracker/application/src/lib/events/project.events.ts b/libs/web/tracker/application/src/lib/events/project.events.ts index 5b1f52f..be7bc15 100644 --- a/libs/web/tracker/application/src/lib/events/project.events.ts +++ b/libs/web/tracker/application/src/lib/events/project.events.ts @@ -51,6 +51,7 @@ export const projectPageEvents = eventGroup({ analyticsRequested: type<{ projectId: string; days: number }>(), burnupRequested: type<{ releaseId: string }>(), visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(), + archiveRequested: type<{ projectId: string; archived: boolean }>(), proposalsChangeRequested: type<{ projectId: string; acceptsProposals: boolean; diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts index 6b9adca..0d19304 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -162,6 +162,12 @@ export class ProjectFacade { this.dispatch.visibilityChangeRequested({ projectId, isPublic }); } + /** Archives the project (read-only for everyone) or brings it back. Owner only. */ + setArchived(archived: boolean): void { + const projectId = this.projectId(); + if (projectId) this.dispatch.archiveRequested({ projectId, archived }); + } + /** Take bug reports and ideas from anyone (while public). Owners and admins. */ setAcceptsProposals(acceptsProposals: boolean): void { const projectId = this.projectId(); diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts index a3642d3..476a569 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -100,11 +100,17 @@ export const ProjectStore = signalStore( ); return { myRole, + /** Archived projects are read-only for everyone. */ canManage: computed( - () => myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN, + () => + !project()?.archivedAt && + (myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN), ), canContribute: computed( - () => !!myRole() && myRole() !== ProjectRole.VIEWER, + () => + !project()?.archivedAt && + !!myRole() && + myRole() !== ProjectRole.VIEWER, ), members: computed(() => project()?.members ?? []), /** Statuses, types and areas of the project, with lookups. */ @@ -433,6 +439,20 @@ export const ProjectStore = signalStore( ), ), ), + events + .on(projectPageEvents.archiveRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + projects.setArchived(payload.projectId, payload.archived), + payload.archived + ? 'Proyecto archivado: queda en solo lectura' + : 'Proyecto desarchivado', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), events .on(projectPageEvents.proposalsChangeRequested) .pipe( diff --git a/libs/web/tracker/application/src/lib/stores/projects.store.ts b/libs/web/tracker/application/src/lib/stores/projects.store.ts index 83e08a5..91bc9f6 100644 --- a/libs/web/tracker/application/src/lib/stores/projects.store.ts +++ b/libs/web/tracker/application/src/lib/stores/projects.store.ts @@ -39,8 +39,13 @@ export const ProjectsStore = signalStore( error: null, }), withComputed(({ projects }) => ({ + /** By name, archived ones last. */ sorted: computed(() => - [...projects()].sort((a, b) => a.name.localeCompare(b.name)), + [...projects()].sort( + (a, b) => + Number(!!a.archivedAt) - Number(!!b.archivedAt) || + a.name.localeCompare(b.name), + ), ), })), withReducer( diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index eb897ac..4a3e384 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -31,6 +31,8 @@ export interface Project { owner: UserRef; members: ProjectMember[]; settings: ProjectSettings; + /** Archived since then (read-only); null while active. */ + archivedAt: string | null; 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 b6d9c66..4905a1e 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -71,6 +71,11 @@ export abstract class ProjectsGateway { projectId: string, since: string | null, ): Observable; + /** Archives the project (read-only) or brings it back. Owner only. */ + abstract setArchived( + projectId: string, + archived: boolean, + ): Observable; /** Statistics of the last `days` days. */ abstract analytics( projectId: string, diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts index 8121b62..0053a91 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts @@ -17,6 +17,8 @@ import { ChangeProjectMemberRoleDocument, CreateProjectDocument, DeleteProjectDocument, + ArchiveProjectDocument, + UnarchiveProjectDocument, MyProjectsDocument, ProjectAnalyticsDocument, ProjectInsightsDocument, @@ -102,6 +104,16 @@ export class GraphqlProjectsGateway extends ProjectsGateway { .pipe(map((d) => toCommandResult(d.updateProject))); } + setArchived(projectId: string, archived: boolean): Observable { + return archived + ? this.gql + .mutate(ArchiveProjectDocument, { projectId }) + .pipe(map((d) => toCommandResult(d.archiveProject))) + : this.gql + .mutate(UnarchiveProjectDocument, { projectId }) + .pipe(map((d) => toCommandResult(d.unarchiveProject))); + } + delete(projectId: string, confirmKey: string): Observable { return this.gql .mutate(DeleteProjectDocument, { projectId, confirmKey }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 4fca5f4..1f3c3e4 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -10,6 +10,7 @@ fragment ProjectFields on Project { description isPublic acceptsProposals + archivedAt updatedAt version owner { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index f6fa162..7c56ddb 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -527,6 +527,7 @@ export type ProjectFieldsFragment = { description: string | null; isPublic: boolean; acceptsProposals: boolean; + archivedAt: string | null; updatedAt: string; version: number; owner: { id: string; name: string }; @@ -1522,6 +1523,7 @@ export type MyProjectsQuery = { description: string | null; isPublic: boolean; acceptsProposals: boolean; + archivedAt: string | null; updatedAt: string; version: number; owner: { id: string; name: string }; @@ -1573,6 +1575,7 @@ export type ProjectQuery = { description: string | null; isPublic: boolean; acceptsProposals: boolean; + archivedAt: string | null; updatedAt: string; version: number; owner: { id: string; name: string }; @@ -1734,6 +1737,7 @@ export type PublicProjectsQuery = { description: string | null; isPublic: boolean; acceptsProposals: boolean; + archivedAt: string | null; updatedAt: string; version: number; owner: { id: string; name: string }; @@ -1781,6 +1785,22 @@ export type UpdateProjectMutation = { updateProject: { id: string; key: string | null; version: number }; }; +export type ArchiveProjectMutationVariables = Exact<{ + projectId: string; +}>; + +export type ArchiveProjectMutation = { + archiveProject: { id: string; key: string | null; version: number }; +}; + +export type UnarchiveProjectMutationVariables = Exact<{ + projectId: string; +}>; + +export type UnarchiveProjectMutation = { + unarchiveProject: { id: string; key: string | null; version: number }; +}; + export type DeleteProjectMutationVariables = Exact<{ projectId: string; confirmKey: string; @@ -2410,6 +2430,7 @@ export type ProjectChangedSubscription = { description: string | null; isPublic: boolean; acceptsProposals: boolean; + archivedAt: string | null; updatedAt: string; version: number; owner: { id: string; name: string }; @@ -2464,6 +2485,7 @@ export type MyProjectsChangedSubscription = { description: string | null; isPublic: boolean; acceptsProposals: boolean; + archivedAt: string | null; updatedAt: string; version: number; owner: { id: string; name: string }; @@ -2730,6 +2752,7 @@ export const ProjectFieldsFragmentDoc = { { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, + { kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } }, { @@ -9475,6 +9498,7 @@ export const MyProjectsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, + { kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } }, { @@ -9722,6 +9746,7 @@ export const ProjectDocument = { { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, + { kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } }, { @@ -10820,6 +10845,7 @@ export const PublicProjectsDocument = { { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, + { kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } }, { @@ -11057,6 +11083,146 @@ export const UpdateProjectDocument = { UpdateProjectMutation, UpdateProjectMutationVariables >; +export const ArchiveProjectDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'ArchiveProject' }, + 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: 'archiveProject' }, + 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: '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< + ArchiveProjectMutation, + ArchiveProjectMutationVariables +>; +export const UnarchiveProjectDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UnarchiveProject' }, + 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: 'unarchiveProject' }, + 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: '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< + UnarchiveProjectMutation, + UnarchiveProjectMutationVariables +>; export const DeleteProjectDocument = { kind: 'Document', definitions: [ @@ -14206,6 +14372,7 @@ export const ProjectChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, + { kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } }, { @@ -14440,6 +14607,7 @@ export const MyProjectsChangedDocument = { { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, + { kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } }, { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql index 8586262..393039b 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql @@ -139,6 +139,18 @@ mutation UpdateProject($input: UpdateProjectInput!) { } } +mutation ArchiveProject($projectId: ID!) { + archiveProject(projectId: $projectId) { + ...CommandResultFields + } +} + +mutation UnarchiveProject($projectId: ID!) { + unarchiveProject(projectId: $projectId) { + ...CommandResultFields + } +} + mutation DeleteProject($projectId: ID!, $confirmKey: String!) { deleteProject(projectId: $projectId, confirmKey: $confirmKey) { ...CommandResultFields diff --git a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts index aa306a6..477406d 100644 --- a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts @@ -101,12 +101,27 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy'; Estadísticas Actividad Miembros - @if (facade.canManage()) { + @if (facade.canManage() || facade.isOwner()) { Configuración } + @if (project.archivedAt) { +

+ Proyecto archivado + Se puede ver todo, pero es de solo lectura. + @if (facade.isOwner()) { + + } +

+ } } @else if (facade.error(); as error) { +
+

Archivar el proyecto

+

+ @if (project.archivedAt) { + Archivado: se puede ver todo, pero nadie puede cambiar nada + hasta que lo desarchives. + } @else { + Para un proyecto terminado: queda todo a la vista (tablero, + tickets, historia, estadísticas) y en solo lectura. Se puede + desarchivar cuando quieras. + } +

+
+ + +
Público } + @if (project().archivedAt) { + Archivado + } {{ ago(project().updatedAt) }}