feat: a trash for tickets

Deleting a ticket now sends it to the trash, for whoever opened it or an
admin: it disappears from boards, searches and statistics, cannot change,
and comes back from the ticket page or the trash page for 30 days. After
that it is deleted for good with the usual cascade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:47:30 +02:00
parent 71b807b94b
commit bec39da46d
49 changed files with 1671 additions and 9 deletions

View File

@@ -2749,6 +2749,106 @@
"tags": [
"Issues"
]
},
"delete": {
"description": "Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.",
"operationId": "IssuesController_trash",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Move the ticket to the trash",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/status": {
@@ -3085,6 +3185,192 @@
]
}
},
"/api/issues/{issue}/restore": {
"post": {
"operationId": "IssuesController_restore",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CommandResultDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Bring the ticket back from the trash",
"tags": [
"Issues"
]
}
},
"/api/projects/{project}/trash": {
"get": {
"operationId": "IssuesController_trashed",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssueDto"
}
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
},
{}
],
"summary": "The project's tickets in the trash, newest first",
"tags": [
"Issues"
]
}
},
"/api/issues/bulk": {
"post": {
"description": "Each ticket goes through the normal rules (permissions, workflow, open sprints and releases) and may refuse on its own; the answer says which.",
@@ -10025,6 +10311,12 @@
"$ref": "#/components/schemas/ChecklistItemDto"
}
},
"trashedAt": {
"type": "string",
"nullable": true,
"format": "date-time",
"description": "In the trash since then; null when it is not"
},
"devLinks": {
"description": "Commits, branches and pull requests that mention it",
"type": "array",
@@ -10076,6 +10368,7 @@
"parent",
"rank",
"checklist",
"trashedAt",
"devLinks",
"commentCount",
"createdAt",

View File

@@ -439,6 +439,9 @@ type Issue {
storyPoints: Int
title: String!
"""In the trash since then; null when it is not"""
trashedAt: String
"""A ticket type id of the project's settings"""
type: String!
updatedAt: String!
@@ -656,6 +659,9 @@ type Mutation {
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""Back from the trash"""
restoreIssue(issueId: ID!): CommandResult!
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!
@@ -675,6 +681,11 @@ type Mutation {
"""Send a ping now and tell how it went"""
testWebhook(webhookId: ID!): WebhookDelivery!
"""
To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
"""
trashIssue(issueId: ID!): CommandResult!
unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Stop following a ticket"""
@@ -937,6 +948,9 @@ type Query {
"""
sprints(projectId: ID!): [Sprint!]!
"""A project's tickets in the trash, newest first"""
trash(projectId: ID!): [Issue!]!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User

View File

@@ -394,7 +394,11 @@ export interface paths {
get: operations["IssuesController_findOne"];
put?: never;
post?: never;
delete?: never;
/**
* Move the ticket to the trash
* @description Whoever opened it, or an owner/admin. Hidden everywhere; restore it within 30 days or it is deleted for good.
*/
delete: operations["IssuesController_trash"];
options?: never;
head?: never;
/** Edit title, description, type, priority, area, labels… */
@@ -455,6 +459,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/issues/{issue}/restore": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Bring the ticket back from the trash */
post: operations["IssuesController_restore"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/projects/{project}/trash": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** The project's tickets in the trash, newest first */
get: operations["IssuesController_trashed"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/bulk": {
parameters: {
query?: never;
@@ -1674,6 +1712,11 @@ export interface components {
/** @description Place in the backlog and its board column (ascending, as text) */
rank: string;
checklist: components["schemas"]["ChecklistItemDto"][];
/**
* Format: date-time
* @description In the trash since then; null when it is not
*/
trashedAt: string | null;
/** @description Commits, branches and pull requests that mention it */
devLinks: components["schemas"]["DevLinkDto"][];
commentCount: number;
@@ -4060,6 +4103,82 @@ export interface operations {
};
};
};
IssuesController_trash: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_update: {
parameters: {
query?: never;
@@ -4380,6 +4499,140 @@ export interface operations {
};
};
};
IssuesController_restore: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CommandResultDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_trashed: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IssueDto"][];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_bulkUpdate: {
parameters: {
query?: never;

View File

@@ -32,6 +32,7 @@ describe('appRoutes', () => {
'sprints',
'roadmap',
'epics',
'trash',
'releases',
'dashboard',
'analytics',