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

@@ -39,3 +39,5 @@ export * from './sprint-deleted.event';
export * from './issue-sprint-changed.event';
export * from './release-notes-written.event';
export * from './issue-dev-link-recorded.event';
export * from './issue-trashed.event';
export * from './issue-restored.event';

View File

@@ -0,0 +1,10 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket came back from the trash. */
@AutowiredEvent
export class IssueRestoredEvent extends TrackerEvent {
constructor(actorId: string, issueId: string, projectId: string) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,10 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket went to the trash (it can come back for a while). */
@AutowiredEvent
export class IssueTrashedEvent extends TrackerEvent {
constructor(actorId: string, issueId: string, projectId: string) {
super(actorId, issueId, projectId);
}
}

View File

@@ -279,6 +279,19 @@ describe('Issue', () => {
});
});
describe('trash', () => {
it('goes to the trash and back, by its reporter or a manager', () => {
const issue = newIssue();
expect(() => issue.trash('u-dev', false)).toThrow(ForbiddenError);
issue.trash('u-qa', false);
issue.trash('u-qa', false);
expect(replay(issue, Issue).rebuilt.trashedAt).not.toBeNull();
issue.restore('u-pm', true);
expect(issue.trashedAt).toBeNull();
expect(issue.getUncommittedEvents()).toHaveLength(3);
});
});
describe('dates', () => {
it('plans a start and a due date, never the wrong way round', () => {
const issue = newIssue();

View File

@@ -15,6 +15,8 @@ import { IssueDeletedEvent } from '../events/issue-deleted.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueDevLinkRecordedEvent } from '../events/issue-dev-link-recorded.event';
import { IssueRestoredEvent } from '../events/issue-restored.event';
import { IssueTrashedEvent } from '../events/issue-trashed.event';
import { IssueRankedEvent } from '../events/issue-ranked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
@@ -122,6 +124,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public sprintId: string | null = null;
/** Commits, branches and pull requests that mention it. */
public devLinks: DevLink[] = [];
/** In the trash since then (null: not in it). */
public trashedAt: string | null = null;
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -454,6 +458,29 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
return item;
}
/**
* Sends it to the trash: hidden everywhere, back with `restore` until the
* trash is emptied. Whoever opened it, or whoever manages the project.
*/
trash(actorId: string, canManage: boolean): void {
if (this.trashedAt) return;
this.assertMayTrash(actorId, canManage);
this.apply(new IssueTrashedEvent(actorId, this.id, this.projectId));
}
restore(actorId: string, canManage: boolean): void {
if (!this.trashedAt) return;
this.assertMayTrash(actorId, canManage);
this.apply(new IssueRestoredEvent(actorId, this.id, this.projectId));
}
private assertMayTrash(actorId: string, canManage: boolean): void {
if (actorId !== this.reporterId && !canManage)
throw new ForbiddenError(
'Only whoever opened a ticket, or a project admin, can delete it',
);
}
/** Deletes the ticket (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
@@ -565,6 +592,14 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.updatedAt = event.occurredAt;
}
onIssueTrashedEvent(event: IssueTrashedEvent): void {
this.trashedAt = event.occurredAt;
}
onIssueRestoredEvent(): void {
this.trashedAt = null;
}
onIssueDeletedEvent(): void {
this.deleted = true;
}

View File

@@ -87,6 +87,8 @@ export class IssueReadModel {
checklist!: ChecklistItemReadModel[];
/** Commits, branches and pull requests that mention it. */
devLinks!: DevLink[];
/** In the trash since then (null: not in it). */
trashedAt!: string | null;
commentCount!: number;
createdAt!: string;
updatedAt!: string;