feat: archive a project instead of deleting it

The owner archives a finished project (archiveProject, POST
/projects/{project}/archive) and brings it back (unarchive). While
archived it stays readable, managers still read its rules and webhooks,
but no command changes it: contributing and managing checks refuse with
422, votes and public proposals too, and automations do not run. The web
shows a read-only notice on every page of it (with «Desarchivar» for the
owner), hides what edits, marks it on the home page and lists it last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:46:22 +02:00
parent e598f76e1c
commit 4acf629106
42 changed files with 1057 additions and 14 deletions

View File

@@ -1567,4 +1567,78 @@ describe('task-stream (e2e)', () => {
).status, ).status,
).toBe(200); ).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);
});
}); });

View File

@@ -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": { "/api/projects/{project}/settings": {
"put": { "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.", "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", "type": "string",
"format": "date-time" "format": "date-time"
}, },
"archivedAt": {
"type": "string",
"nullable": true,
"format": "date-time",
"description": "Archived since then (read-only); null while active"
},
"updatedAt": { "updatedAt": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@@ -11061,6 +11270,7 @@
"members", "members",
"settings", "settings",
"createdAt", "createdAt",
"archivedAt",
"updatedAt", "updatedAt",
"version" "version"
] ]

View File

@@ -792,6 +792,11 @@ type Mutation {
"""Add a Pireon user to the project""" """Add a Pireon user to the project"""
addProjectMember(input: ProjectMemberInput!): CommandResult! addProjectMember(input: ProjectMemberInput!): CommandResult!
"""
Archive the project (owner only): everything stays readable, nothing changes until it is unarchived
"""
archiveProject(projectId: ID!): CommandResult!
archiveRelease( archiveRelease(
""" """
Open release of the same project that takes its unfinished tickets; none: they leave it 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 To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
""" """
trashIssue(issueId: ID!): CommandResult! trashIssue(issueId: ID!): CommandResult!
"""Bring an archived project back (owner only)"""
unarchiveProject(projectId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Push off on this device""" """Push off on this device"""
@@ -1071,6 +1079,9 @@ type Project {
""" """
analytics(days: Int! = 90): ProjectAnalytics! analytics(days: Int! = 90): ProjectAnalytics!
"""Archived since then (read-only); null while active"""
archivedAt: String
"""Kanban board, one column per status""" """Kanban board, one column per status"""
board( board(
area: String area: String

View File

@@ -211,6 +211,43 @@ export interface paths {
patch: operations["ProjectsController_update"]; patch: operations["ProjectsController_update"];
trace?: never; 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": { "/api/projects/{project}/settings": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1770,6 +1807,11 @@ export interface components {
settings: components["schemas"]["ProjectSettingsDto"]; settings: components["schemas"]["ProjectSettingsDto"];
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/**
* Format: date-time
* @description Archived since then (read-only); null while active
*/
archivedAt: string | null;
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
/** @description Version of the aggregate (event count) */ /** @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: { ProjectsController_updateSettings: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -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 [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. 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 ### Configuración por proyecto
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket**, **áreas** y **campos personalizados** 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 | | Recurso | Rutas |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | 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` | | 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` | | 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` | | 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` |

View File

@@ -44,7 +44,6 @@ Nada: la tanda está hecha (ver 10.5).
- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos). - **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. - **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. - **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 ### 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». 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 - 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). 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.

Binary file not shown.

Binary file not shown.

View File

@@ -96,9 +96,12 @@ async function managedProject(
access: WriteAccess, access: WriteAccess,
projectId: string, projectId: string,
actorId: string, actorId: string,
/** Only to read the rules: an archived project still shows them. */
reading = false,
): Promise<Project> { ): Promise<Project> {
const project = await access.project(projectId); const project = await access.project(projectId);
project.assertCanManage(actorId); if (reading) project.assertManages(actorId);
else project.assertCanManage(actorId);
return project; return project;
} }
@@ -231,6 +234,7 @@ export class RunAutomationCommandHandler
? await this.access.live(issue.projectId, Project) ? await this.access.live(issue.projectId, Project)
: null; : null;
if (!issue || !project) return 'The ticket is gone'; if (!issue || !project) return 'The ticket is gone';
if (project.archivedAt) return 'The project is archived';
const { kind, value } = command.automation.action; const { kind, value } = command.automation.action;
const actor = SYSTEM_ACTOR_ID; const actor = SYSTEM_ACTOR_ID;
switch (kind) { switch (kind) {
@@ -297,7 +301,7 @@ export class GetAutomationsQueryHandler
) {} ) {}
async execute(query: GetAutomationsQuery): Promise<Automation[]> { async execute(query: GetAutomationsQuery): Promise<Automation[]> {
await managedProject(this.access, query.projectId, query.viewerId); await managedProject(this.access, query.projectId, query.viewerId, true);
return this.automations.findByProject(query.projectId); return this.automations.findByProject(query.projectId);
} }
} }

View File

@@ -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<ArchiveProjectCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: ArchiveProjectCommand): Promise<CommandResult> {
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);
}
}

View File

@@ -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<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly archived: boolean,
) {
super();
}
}

View File

@@ -21,6 +21,7 @@ import { UpdateReleaseCommandHandler } from './update-release.command-handler';
import { PublishReleaseCommandHandler } from './publish-release.command-handler'; import { PublishReleaseCommandHandler } from './publish-release.command-handler';
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler'; import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
import { DeleteProjectCommandHandler } from './delete-project.command-handler'; import { DeleteProjectCommandHandler } from './delete-project.command-handler';
import { ArchiveProjectCommandHandler } from './archive-project.command-handler';
import { DeleteIssueCommandHandler } from './delete-issue.command-handler'; import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
import { DeleteReleaseCommandHandler } from './delete-release.command-handler'; import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
import { PurgeCommentCommandHandler } from './purge-comment.command-handler'; import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
@@ -66,6 +67,7 @@ export const COMMAND_HANDLERS = [
PublishReleaseCommandHandler, PublishReleaseCommandHandler,
ArchiveReleaseCommandHandler, ArchiveReleaseCommandHandler,
DeleteProjectCommandHandler, DeleteProjectCommandHandler,
ArchiveProjectCommandHandler,
// Emitted by sagas only (cascades and reactions to other aggregates). // Emitted by sagas only (cascades and reactions to other aggregates).
DeleteIssueCommandHandler, DeleteIssueCommandHandler,
DeleteReleaseCommandHandler, DeleteReleaseCommandHandler,

View File

@@ -24,6 +24,7 @@ export class VoteIssueCommandHandler
throw new ForbiddenError( throw new ForbiddenError(
`You are not a member of project ${project.key}`, `You are not a member of project ${project.key}`,
); );
project.assertNotArchived();
if (issue.trashedAt) if (issue.trashedAt)
throw new BusinessRuleError(`${issue.key} is in the trash`); throw new BusinessRuleError(`${issue.key} is in the trash`);
issue.vote(command.actorId, command.on); issue.vote(command.actorId, command.on);

View File

@@ -26,6 +26,8 @@ import {
ProjectRole, ProjectRole,
ProjectSettingsChangedEvent, ProjectSettingsChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectWorkflow, ProjectWorkflow,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent, ReleaseNotesWrittenEvent,
@@ -109,6 +111,10 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`; return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent) if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`; 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) if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`; return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) { if (event instanceof ProjectMemberAddedEvent) {

View File

@@ -25,6 +25,8 @@ import {
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent, ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ReleaseArchivedEvent, ReleaseArchivedEvent,
ReleaseNotesWrittenEvent, ReleaseNotesWrittenEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
@@ -57,6 +59,8 @@ import { versionOf } from './read-model-notifier';
@EventsHandler( @EventsHandler(
ProjectCreatedEvent, ProjectCreatedEvent,
ProjectUpdatedEvent, ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectSettingsChangedEvent, ProjectSettingsChangedEvent,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
ProjectMemberRemovedEvent, ProjectMemberRemovedEvent,

View File

@@ -16,6 +16,7 @@ import { ProjectMemberAddedEventHandler } from './project-member-added.event-han
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler'; import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler'; import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
import { ProjectUpdatedEventHandler } from './project-updated.event-handler'; import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
import { ProjectArchivedEventHandler } from './project-archived.event-handler';
import { ReleaseArchivedEventHandler } from './release-archived.event-handler'; import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
import { ReleaseCreatedEventHandler } from './release-created.event-handler'; import { ReleaseCreatedEventHandler } from './release-created.event-handler';
import { ReleaseShippedEventHandler } from './release-shipped.event-handler'; import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
@@ -63,6 +64,7 @@ export const EVENT_HANDLERS = [
ProjectMemberRemovedEventHandler, ProjectMemberRemovedEventHandler,
ProjectMemberRoleChangedEventHandler, ProjectMemberRoleChangedEventHandler,
ProjectUpdatedEventHandler, ProjectUpdatedEventHandler,
ProjectArchivedEventHandler,
ProjectSettingsChangedEventHandler, ProjectSettingsChangedEventHandler,
ReleaseArchivedEventHandler, ReleaseArchivedEventHandler,
ReleaseCreatedEventHandler, ReleaseCreatedEventHandler,

View File

@@ -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<ProjectArchivedEvent | ProjectUnarchivedEvent>
{
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);
}
}

View File

@@ -29,6 +29,7 @@ import { ChangeProjectMemberRoleCommand } from './commands/change-project-member
import { CommandResult } from './commands/command-result'; import { CommandResult } from './commands/command-result';
import { CreateProjectCommand } from './commands/create-project.command'; import { CreateProjectCommand } from './commands/create-project.command';
import { DeleteProjectCommand } from './commands/delete-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 { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
import { UpdateProjectCommand } from './commands/update-project.command'; import { UpdateProjectCommand } from './commands/update-project.command';
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command'; import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
@@ -75,7 +76,7 @@ export class ProjectsService {
(await this.findProjects.findById(projectIdOrKey)) ?? (await this.findProjects.findById(projectIdOrKey)) ??
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase())); (await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey); 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`); throw new ForbiddenError(`${found.key} does not take proposals`);
const cleanTitle = title.trim(); const cleanTitle = title.trim();
@@ -170,7 +171,8 @@ export class ProjectsService {
automations: Automation[]; automations: Automation[];
}> { }> {
const project = await this.access.project(projectId); 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( const automations: Automation[] = await this.queryBus.execute(
new GetAutomationsQuery(actorId, projectId), new GetAutomationsQuery(actorId, projectId),
); );
@@ -241,6 +243,17 @@ export class ProjectsService {
} }
/** Statuses, transitions, ticket types and areas (owners and admins). */ /** 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<CommandResult> {
return this.commands.execute(
new ArchiveProjectCommand(actorId, projectId, archived),
);
}
updateSettings( updateSettings(
actorId: string, actorId: string,
projectId: string, projectId: string,

View File

@@ -327,9 +327,7 @@ export class GetWebhooksQueryHandler
) {} ) {}
async execute(query: GetWebhooksQuery): Promise<WebhookView[]> { async execute(query: GetWebhooksQuery): Promise<WebhookView[]> {
(await this.access.project(query.projectId)).assertCanManage( (await this.access.project(query.projectId)).assertManages(query.viewerId);
query.viewerId,
);
return (await this.webhooks.findByProject(query.projectId)).map(view); return (await this.webhooks.findByProject(query.projectId)).map(view);
} }
} }

View File

@@ -21,6 +21,7 @@ export * from './release-updated.event';
export * from './release-shipped.event'; export * from './release-shipped.event';
export * from './release-archived.event'; export * from './release-archived.event';
export * from './project-deleted.event'; export * from './project-deleted.event';
export * from './project-archived.event';
export * from './issue-deleted.event'; export * from './issue-deleted.event';
export * from './release-deleted.event'; export * from './release-deleted.event';
export * from './issue-attachment-added.event'; export * from './issue-attachment-added.event';

View File

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

View File

@@ -106,4 +106,28 @@ describe('Deleting a project', () => {
).toHaveLength(2); ).toHaveLength(2);
expect(source.links.map((l) => l.targetIssueId)).toEqual(['o']); 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();
});
}); });

View File

@@ -4,6 +4,10 @@ import {
VersionedAggregateRoot, VersionedAggregateRoot,
assertThat, assertThat,
} from '@task-stream/api/shared/domain'; } from '@task-stream/api/shared/domain';
import {
ProjectArchivedEvent,
ProjectUnarchivedEvent,
} from '../events/project-archived.event';
import { ProjectCreatedEvent } from '../events/project-created.event'; import { ProjectCreatedEvent } from '../events/project-created.event';
import { ProjectDeletedEvent } from '../events/project-deleted.event'; import { ProjectDeletedEvent } from '../events/project-deleted.event';
import { ProjectMemberAddedEvent } from '../events/project-member-added.event'; import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
@@ -62,6 +66,8 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
public updatedAt!: string; public updatedAt!: string;
/** Set once deleted: the project can no longer be loaded for commands. */ /** Set once deleted: the project can no longer be loaded for commands. */
public deleted = false; public deleted = false;
/** Archived since then: read-only until its owner brings it back. */
public archivedAt: string | null = null;
constructor(id: string) { constructor(id: string) {
super(); super();
@@ -96,10 +102,20 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
if (this.roleOf(userId) === ProjectRole.VIEWER) { if (this.roleOf(userId) === ProjectRole.VIEWER) {
throw new ForbiddenError(`Viewers cannot modify project ${this.key}`); 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 { 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); const role = this.roleOf(userId);
if (role !== ProjectRole.OWNER && role !== ProjectRole.ADMIN) { if (role !== ProjectRole.OWNER && role !== ProjectRole.ADMIN) {
throw new ForbiddenError( 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( updateDetails(
actorId: string, actorId: string,
changes: { changes: {
@@ -249,6 +295,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.deleted = true; this.deleted = true;
} }
onProjectArchivedEvent(event: ProjectArchivedEvent): void {
this.archivedAt = event.occurredAt;
}
onProjectUnarchivedEvent(): void {
this.archivedAt = null;
}
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void { onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
this.settings = structuredClone(event.settings); this.settings = structuredClone(event.settings);
this.updatedAt = event.occurredAt; this.updatedAt = event.occurredAt;

View File

@@ -18,6 +18,8 @@ export class ProjectReadModel {
acceptsProposals!: boolean; acceptsProposals!: boolean;
owner!: UserRefReadModel; owner!: UserRefReadModel;
members!: ProjectMemberReadModel[]; members!: ProjectMemberReadModel[];
/** Archived since then (read-only); null while active. */
archivedAt!: string | null;
/** Statuses, transitions, ticket types and areas. */ /** Statuses, transitions, ticket types and areas. */
settings!: ProjectSettings; settings!: ProjectSettings;
createdAt!: string; createdAt!: string;

View File

@@ -66,6 +66,7 @@ function toProject(doc: ProjectDoc): ProjectReadModel {
isPublic: rest.isPublic ?? false, isPublic: rest.isPublic ?? false,
acceptsProposals: rest.acceptsProposals ?? false, acceptsProposals: rest.acceptsProposals ?? false,
settings: rest.settings ?? defaultProjectSettings(), settings: rest.settings ?? defaultProjectSettings(),
archivedAt: rest.archivedAt ?? null,
}; };
} }

View File

@@ -219,6 +219,27 @@ export class ProjectResolver {
return this.projects.delete(me.id, projectId, confirmKey); 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, { @Mutation(() => CommandResultGql, {
description: 'Add a Pireon user to the project', description: 'Add a Pireon user to the project',
}) })

View File

@@ -137,6 +137,11 @@ export class ProjectGql {
@Field(() => UserRefGql) owner!: UserRefGql; @Field(() => UserRefGql) owner!: UserRefGql;
@Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[]; @Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[];
@Field() createdAt!: string; @Field() createdAt!: string;
@Field(() => String, {
nullable: true,
description: 'Archived since then (read-only); null while active',
})
archivedAt!: string | null;
@Field() updatedAt!: string; @Field() updatedAt!: string;
@Field(() => Int) version!: number; @Field(() => Int) version!: number;
} }

View File

@@ -128,6 +128,13 @@ export class ProjectDto {
@ApiProperty({ type: ProjectSettingsDto }) @ApiProperty({ type: ProjectSettingsDto })
settings!: ProjectSettingsDto; settings!: ProjectSettingsDto;
@ApiProperty({ format: 'date-time' }) createdAt!: string; @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({ format: 'date-time' }) updatedAt!: string;
@ApiProperty({ description: 'Version of the aggregate (event count)' }) @ApiProperty({ description: 'Version of the aggregate (event count)' })
version!: number; version!: number;

View File

@@ -5,6 +5,7 @@ import {
Get, Get,
Param, Param,
Patch, Patch,
HttpCode,
Post, Post,
Put, Put,
Query, 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') @Put(':project/settings')
@PROJECT @PROJECT
@ApiOperation({ @ApiOperation({

View File

@@ -51,6 +51,7 @@ export const projectPageEvents = eventGroup({
analyticsRequested: type<{ projectId: string; days: number }>(), analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(), burnupRequested: type<{ releaseId: string }>(),
visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(), visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(),
archiveRequested: type<{ projectId: string; archived: boolean }>(),
proposalsChangeRequested: type<{ proposalsChangeRequested: type<{
projectId: string; projectId: string;
acceptsProposals: boolean; acceptsProposals: boolean;

View File

@@ -162,6 +162,12 @@ export class ProjectFacade {
this.dispatch.visibilityChangeRequested({ projectId, isPublic }); 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. */ /** Take bug reports and ideas from anyone (while public). Owners and admins. */
setAcceptsProposals(acceptsProposals: boolean): void { setAcceptsProposals(acceptsProposals: boolean): void {
const projectId = this.projectId(); const projectId = this.projectId();

View File

@@ -100,11 +100,17 @@ export const ProjectStore = signalStore(
); );
return { return {
myRole, myRole,
/** Archived projects are read-only for everyone. */
canManage: computed( canManage: computed(
() => myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN, () =>
!project()?.archivedAt &&
(myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN),
), ),
canContribute: computed( canContribute: computed(
() => !!myRole() && myRole() !== ProjectRole.VIEWER, () =>
!project()?.archivedAt &&
!!myRole() &&
myRole() !== ProjectRole.VIEWER,
), ),
members: computed(() => project()?.members ?? []), members: computed(() => project()?.members ?? []),
/** Statuses, types and areas of the project, with lookups. */ /** 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 events
.on(projectPageEvents.proposalsChangeRequested) .on(projectPageEvents.proposalsChangeRequested)
.pipe( .pipe(

View File

@@ -39,8 +39,13 @@ export const ProjectsStore = signalStore(
error: null, error: null,
}), }),
withComputed(({ projects }) => ({ withComputed(({ projects }) => ({
/** By name, archived ones last. */
sorted: computed(() => 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( withReducer(

View File

@@ -31,6 +31,8 @@ export interface Project {
owner: UserRef; owner: UserRef;
members: ProjectMember[]; members: ProjectMember[];
settings: ProjectSettings; settings: ProjectSettings;
/** Archived since then (read-only); null while active. */
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
} }

View File

@@ -71,6 +71,11 @@ export abstract class ProjectsGateway {
projectId: string, projectId: string,
since: string | null, since: string | null,
): Observable<ProjectInsights>; ): Observable<ProjectInsights>;
/** Archives the project (read-only) or brings it back. Owner only. */
abstract setArchived(
projectId: string,
archived: boolean,
): Observable<CommandResult>;
/** Statistics of the last `days` days. */ /** Statistics of the last `days` days. */
abstract analytics( abstract analytics(
projectId: string, projectId: string,

View File

@@ -17,6 +17,8 @@ import {
ChangeProjectMemberRoleDocument, ChangeProjectMemberRoleDocument,
CreateProjectDocument, CreateProjectDocument,
DeleteProjectDocument, DeleteProjectDocument,
ArchiveProjectDocument,
UnarchiveProjectDocument,
MyProjectsDocument, MyProjectsDocument,
ProjectAnalyticsDocument, ProjectAnalyticsDocument,
ProjectInsightsDocument, ProjectInsightsDocument,
@@ -102,6 +104,16 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toCommandResult(d.updateProject))); .pipe(map((d) => toCommandResult(d.updateProject)));
} }
setArchived(projectId: string, archived: boolean): Observable<CommandResult> {
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<CommandResult> { delete(projectId: string, confirmKey: string): Observable<CommandResult> {
return this.gql return this.gql
.mutate(DeleteProjectDocument, { projectId, confirmKey }) .mutate(DeleteProjectDocument, { projectId, confirmKey })

View File

@@ -10,6 +10,7 @@ fragment ProjectFields on Project {
description description
isPublic isPublic
acceptsProposals acceptsProposals
archivedAt
updatedAt updatedAt
version version
owner { owner {

View File

@@ -527,6 +527,7 @@ export type ProjectFieldsFragment = {
description: string | null; description: string | null;
isPublic: boolean; isPublic: boolean;
acceptsProposals: boolean; acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
owner: { id: string; name: string }; owner: { id: string; name: string };
@@ -1522,6 +1523,7 @@ export type MyProjectsQuery = {
description: string | null; description: string | null;
isPublic: boolean; isPublic: boolean;
acceptsProposals: boolean; acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
owner: { id: string; name: string }; owner: { id: string; name: string };
@@ -1573,6 +1575,7 @@ export type ProjectQuery = {
description: string | null; description: string | null;
isPublic: boolean; isPublic: boolean;
acceptsProposals: boolean; acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
owner: { id: string; name: string }; owner: { id: string; name: string };
@@ -1734,6 +1737,7 @@ export type PublicProjectsQuery = {
description: string | null; description: string | null;
isPublic: boolean; isPublic: boolean;
acceptsProposals: boolean; acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
owner: { id: string; name: string }; owner: { id: string; name: string };
@@ -1781,6 +1785,22 @@ export type UpdateProjectMutation = {
updateProject: { id: string; key: string | null; version: number }; 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<{ export type DeleteProjectMutationVariables = Exact<{
projectId: string; projectId: string;
confirmKey: string; confirmKey: string;
@@ -2410,6 +2430,7 @@ export type ProjectChangedSubscription = {
description: string | null; description: string | null;
isPublic: boolean; isPublic: boolean;
acceptsProposals: boolean; acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
owner: { id: string; name: string }; owner: { id: string; name: string };
@@ -2464,6 +2485,7 @@ export type MyProjectsChangedSubscription = {
description: string | null; description: string | null;
isPublic: boolean; isPublic: boolean;
acceptsProposals: boolean; acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string; updatedAt: string;
version: number; version: number;
owner: { id: string; name: string }; 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, { 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: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } }, { 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, { 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: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } }, { 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, { 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: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } }, { 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, { 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: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } },
{ {
@@ -11057,6 +11083,146 @@ export const UpdateProjectDocument = {
UpdateProjectMutation, UpdateProjectMutation,
UpdateProjectMutationVariables 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 = { export const DeleteProjectDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -14206,6 +14372,7 @@ export const ProjectChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, { 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: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } }, { 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: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } }, { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } }, { 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: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } }, { kind: 'Field', name: { kind: 'Name', value: 'version' } },
{ {

View File

@@ -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!) { mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
deleteProject(projectId: $projectId, confirmKey: $confirmKey) { deleteProject(projectId: $projectId, confirmKey: $confirmKey) {
...CommandResultFields ...CommandResultFields

View File

@@ -101,12 +101,27 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a> <a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
<a routerLink="activity" routerLinkActive="is-active">Actividad</a> <a routerLink="activity" routerLinkActive="is-active">Actividad</a>
<a routerLink="members" routerLinkActive="is-active">Miembros</a> <a routerLink="members" routerLinkActive="is-active">Miembros</a>
@if (facade.canManage()) { @if (facade.canManage() || facade.isOwner()) {
<a routerLink="settings" routerLinkActive="is-active" <a routerLink="settings" routerLinkActive="is-active"
>Configuración</a >Configuración</a
> >
} }
</nav> </nav>
@if (project.archivedAt) {
<p class="ts-archived" role="status">
<strong>Proyecto archivado</strong>
Se puede ver todo, pero es de solo lectura.
@if (facade.isOwner()) {
<button
type="button"
class="ts-plain-link"
(click)="facade.setArchived(false)"
>
Desarchivar
</button>
}
</p>
}
<router-outlet /> <router-outlet />
} @else if (facade.error(); as error) { } @else if (facade.error(); as error) {
<pi-note <pi-note
@@ -142,6 +157,19 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
} }
`, `,
styles: ` styles: `
.ts-archived {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
padding: 0.625rem 0.875rem;
border: 1px solid var(--ts-line);
border-radius: 0.625rem;
background: var(--ts-sunken);
color: var(--ts-ink-soft);
font-size: 0.875rem;
}
.ts-eyebrow .ts-pill { .ts-eyebrow .ts-pill {
margin-left: 0.5rem; margin-left: 0.5rem;
letter-spacing: 0; letter-spacing: 0;

View File

@@ -693,6 +693,32 @@ function idFrom(name: string): string {
} }
@if (facade.isOwner() && facade.project(); as project) { @if (facade.isOwner() && facade.project(); as project) {
<section
class="ts-panel ts-block ts-visibility"
aria-labelledby="archive-title"
>
<div>
<h3 id="archive-title">Archivar el proyecto</h3>
<p class="ts-muted">
@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>
</div>
<button
type="button"
class="ts-secondary"
(click)="facade.setArchived(!project.archivedAt)"
>
{{ project.archivedAt ? 'Desarchivar' : 'Archivar' }}
</button>
</section>
<section <section
class="ts-panel ts-block ts-danger" class="ts-panel ts-block ts-danger"
aria-labelledby="danger-title" aria-labelledby="danger-title"

View File

@@ -25,6 +25,9 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
@if (project().isPublic) { @if (project().isPublic) {
<span class="ts-pill" [style.--tone]="publicTone">Público</span> <span class="ts-pill" [style.--tone]="publicTone">Público</span>
} }
@if (project().archivedAt) {
<span class="ts-pill">Archivado</span>
}
<span class="ts-project__updated ts-mono">{{ <span class="ts-project__updated ts-mono">{{
ago(project().updatedAt) ago(project().updatedAt)
}}</span> }}</span>