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:
@@ -33,6 +33,7 @@ import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
|
||||
import { TRASH_HANDLERS } from './trash';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
@@ -77,6 +78,7 @@ export const COMMAND_HANDLERS = [
|
||||
WriteReleaseNotesCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
BulkUpdateIssuesCommandHandler,
|
||||
...TRASH_HANDLERS,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
|
||||
@@ -78,6 +78,7 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
|
||||
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
|
||||
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
@@ -666,6 +667,24 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(/Nothing to change/);
|
||||
});
|
||||
|
||||
it('sends tickets to the trash and back; trashed ones cannot change', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
await expect(run(new TrashIssueCommand('dev', issue.id))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await run(new TrashIssueCommand('qa', issue.id));
|
||||
await expect(
|
||||
run(new AssignIssueCommand('owner', issue.id, 'dev')),
|
||||
).rejects.toThrow(/trash/);
|
||||
await run(new RestoreIssueCommand('owner', issue.id));
|
||||
await run(new AssignIssueCommand('owner', issue.id, 'dev'));
|
||||
expect((await rehydrator.rehydrate(issue.id, Issue)).trashedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -36,3 +36,4 @@ export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
export * from './write-release-notes.command';
|
||||
export * from './bulk-update-issues';
|
||||
export * from './trash';
|
||||
|
||||
103
libs/api/tracker/application/src/lib/commands/trash.ts
Normal file
103
libs/api/tracker/application/src/lib/commands/trash.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DeleteIssueCommand } from './delete-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** Days a ticket stays in the trash before it is deleted for good. */
|
||||
export const TRASH_DAYS = 30;
|
||||
|
||||
export class TrashIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RestoreIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduler only: deletes for good what went to the trash before `before`. */
|
||||
export class EmptyTrashCommand extends Command<number> {
|
||||
constructor(public readonly before: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(TrashIssueCommand)
|
||||
export class TrashIssueCommandHandler
|
||||
implements ICommandHandler<TrashIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: TrashIssueCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.trash(command.actorId, role === 'OWNER' || role === 'ADMIN');
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RestoreIssueCommand)
|
||||
export class RestoreIssueCommandHandler
|
||||
implements ICommandHandler<RestoreIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RestoreIssueCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
project.assertCanContribute(command.actorId);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.restore(command.actorId, role === 'OWNER' || role === 'ADMIN');
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each deletion is the usual one: comments purged, links dropped and
|
||||
* subtasks back to the top, by the sagas.
|
||||
*/
|
||||
@CommandHandler(EmptyTrashCommand)
|
||||
export class EmptyTrashCommandHandler
|
||||
implements ICommandHandler<EmptyTrashCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async execute(command: EmptyTrashCommand): Promise<number> {
|
||||
const old = await this.findIssues.findTrashedBefore(command.before);
|
||||
for (const issue of old)
|
||||
await this.commandBus.execute(
|
||||
new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id),
|
||||
);
|
||||
return old.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const TRASH_HANDLERS = [
|
||||
TrashIssueCommandHandler,
|
||||
RestoreIssueCommandHandler,
|
||||
EmptyTrashCommandHandler,
|
||||
];
|
||||
@@ -67,6 +67,10 @@ export class WriteAccess {
|
||||
const issue = await this.issue(issueId);
|
||||
const project = await this.project(issue.projectId);
|
||||
project.assertCanContribute(actorId);
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${issue.key} is in the trash: restore it to change it`,
|
||||
);
|
||||
return { issue, project };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueLinkType,
|
||||
@@ -163,6 +165,10 @@ export function describeEvent(
|
||||
? `${actor} metió ${issueKey} en ${sprint}`
|
||||
: `${actor} sacó ${issueKey} de ${sprint}`;
|
||||
}
|
||||
if (event instanceof IssueTrashedEvent)
|
||||
return `${actor} movió ${issueKey} a la papelera`;
|
||||
if (event instanceof IssueRestoredEvent)
|
||||
return `${actor} recuperó ${issueKey} de la papelera`;
|
||||
if (event instanceof IssueDevLinkRecordedEvent) {
|
||||
const { kind, ref, state, author } = event.link;
|
||||
const who = author ? ` (${author})` : '';
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
@@ -70,6 +72,8 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
import { SprintEventHandler } from './sprint.event-handler';
|
||||
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
|
||||
import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler';
|
||||
import { IssueTrashEventHandler } from './issue-trash.event-handler';
|
||||
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
@@ -73,6 +74,7 @@ export const EVENT_HANDLERS = [
|
||||
SprintEventHandler,
|
||||
ReleaseNotesWrittenEventHandler,
|
||||
IssueDevLinkRecordedEventHandler,
|
||||
IssueTrashEventHandler,
|
||||
IssueSprintChangedEventHandler,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueRestoredEvent,
|
||||
IssueTrashedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueTrashedEvent, IssueRestoredEvent)
|
||||
export class IssueTrashEventHandler
|
||||
implements IEventHandler<IssueTrashedEvent | IssueRestoredEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueTrashedEvent | IssueRestoredEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
trashedAt: event instanceof IssueTrashedEvent ? event.occurredAt : null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
|
||||
import { GetTrashQuery } from './queries/get-trash.query';
|
||||
import {
|
||||
BulkChange,
|
||||
BulkResult,
|
||||
@@ -247,6 +249,22 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** To the trash (whoever opened it, or a manager); back with `restore`. */
|
||||
trash(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
restore(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
trashed(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetTrashQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
addChecklistItem(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
|
||||
@@ -66,6 +66,10 @@ export abstract class FindIssuesRepository {
|
||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
|
||||
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
|
||||
/** The project's tickets in the trash, newest first. */
|
||||
abstract findTrashed(projectId: string): Promise<IssueReadModel[]>;
|
||||
/** Tickets that went to the trash before `before` (ISO), any project. */
|
||||
abstract findTrashedBefore(before: string): Promise<IssueReadModel[]>;
|
||||
/** Every ticket of a project (statistics). */
|
||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||
/** Top-level tickets with others hanging from them, by key. */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
/** A project's tickets in the trash, newest first. */
|
||||
export class GetTrashQuery extends Query<IssueReadModel[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetTrashQuery)
|
||||
export class GetTrashQueryHandler
|
||||
implements IQueryHandler<GetTrashQuery, IssueReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetTrashQuery): Promise<IssueReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
return this.findIssues.findTrashed(query.projectId);
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,4 @@ export * from './get-attachment.query';
|
||||
export * from './get-epics.query';
|
||||
export * from './sprint.queries';
|
||||
export * from './get-release-notes-draft.query';
|
||||
export * from './get-trash.query';
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
||||
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
|
||||
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
|
||||
import { GetTrashQueryHandler } from './get-trash.query';
|
||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||
|
||||
export const QUERY_HANDLERS = [
|
||||
@@ -42,6 +43,7 @@ export const QUERY_HANDLERS = [
|
||||
GetEpicsQueryHandler,
|
||||
...SPRINT_QUERY_HANDLERS,
|
||||
GetReleaseNotesDraftQueryHandler,
|
||||
GetTrashQueryHandler,
|
||||
SearchMyIssuesQueryHandler,
|
||||
GetAttachmentQueryHandler,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user