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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user