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,
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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.
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ fragment ProjectFields on Project {
|
|||||||
description
|
description
|
||||||
isPublic
|
isPublic
|
||||||
acceptsProposals
|
acceptsProposals
|
||||||
|
archivedAt
|
||||||
updatedAt
|
updatedAt
|
||||||
version
|
version
|
||||||
owner {
|
owner {
|
||||||
|
|||||||
@@ -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' } },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user