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