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

@@ -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,

View File

@@ -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);

View File

@@ -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';

View 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,
];

View File

@@ -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 };
}

View File

@@ -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})` : '';

View File

@@ -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,

View File

@@ -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,
];

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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,
];

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;

View File

@@ -156,6 +156,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
if (filter.projectIds.length === 0) return { items: [], total: 0 };
const query: Record<string, unknown> = {
projectId: { $in: filter.projectIds },
trashedAt: null,
};
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
@@ -195,7 +196,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
byArea: { _id: string | null; count: number }[];
byAssignee: { _id: UserRefReadModel | null; count: number }[];
}>([
{ $match: { projectId } },
{ $match: { projectId, trashedAt: null } },
{
$facet: {
total: [{ $count: 'count' }],
@@ -233,7 +234,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
async findOpenAssignedTo(userId: string): Promise<IssueReadModel[]> {
const docs = await this.issues
.find({ 'assignee.id': userId, statusCategory: { $nin: FINISHED } })
.find({
'assignee.id': userId,
statusCategory: { $nin: FINISHED },
trashedAt: null,
})
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
@@ -242,6 +247,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
const docs = await this.issues
.find({
dueDate: { $ne: null, $lte: `${day}\uffff` },
trashedAt: null,
statusCategory: { $nin: FINISHED },
$or: [{ assignee: { $ne: null } }, { 'watchers.0': { $exists: true } }],
})
@@ -251,7 +257,26 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
}
async findByProject(projectId: string): Promise<IssueReadModel[]> {
const docs = await this.issues.find({ projectId }).lean<IssueDoc[]>();
const docs = await this.issues
.find({ projectId, trashedAt: null })
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
async findTrashed(projectId: string): Promise<IssueReadModel[]> {
const docs = await this.issues
.find({ projectId, trashedAt: { $ne: null } })
.sort({ trashedAt: -1 })
.limit(500)
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
async findTrashedBefore(before: string): Promise<IssueReadModel[]> {
const docs = await this.issues
.find({ trashedAt: { $ne: null, $lt: before } })
.limit(1000)
.lean<IssueDoc[]>();
return docs.map(issueFromDoc);
}
@@ -265,7 +290,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
storyPoints: number;
finishedStoryPoints: number;
}>([
{ $match: { projectId, 'parent.id': { $type: 'string' } } },
{
$match: {
projectId,
'parent.id': { $type: 'string' },
trashedAt: null,
},
},
{
$group: {
_id: '$parent.id',
@@ -280,7 +311,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
const progress = new Map(rows.map((row) => [row._id, row]));
// Only the top of each tree: a ticket with subtasks under an epic is not one.
const docs = await this.issues
.find({ _id: { $in: [...progress.keys()] }, 'parent.id': null })
.find({
_id: { $in: [...progress.keys()] },
'parent.id': null,
trashedAt: null,
})
.sort({ number: 1 })
.lean<IssueDoc[]>();
return docs.map((doc) => {
@@ -311,7 +346,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
storyPoints: number;
finishedStoryPoints: number;
}>([
{ $match: { 'sprint.id': sprintId } },
{ $match: { 'sprint.id': sprintId, trashedAt: null } },
{
$group: {
_id: null,
@@ -338,7 +373,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
_id: { status: string; category: StatusCategory };
count: number;
}>([
{ $match: { 'release.id': releaseId } },
{ $match: { 'release.id': releaseId, trashedAt: null } },
{
$group: {
_id: { status: '$status', category: '$statusCategory' },
@@ -363,7 +398,11 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
}
function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
const query: FilterQuery<IssueDoc> = { projectId: filter.projectId };
// Tickets in the trash are only listed by the trash itself.
const query: FilterQuery<IssueDoc> = {
projectId: filter.projectId,
trashedAt: null,
};
if (filter.statuses?.length) query.status = { $in: filter.statuses };
if (filter.categories?.length)
query['statusCategory'] = { $in: filter.categories };

View File

@@ -133,6 +133,7 @@ IssueSchema.index({ 'parent.id': 1 });
IssueSchema.index({ projectId: 1, rank: 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ trashedAt: 1 }, { sparse: true });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>(
@@ -285,6 +286,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
startDate: rest.startDate ?? null,
sprint: rest.sprint?.id ? rest.sprint : null,
devLinks: rest.devLinks ?? [],
trashedAt: rest.trashedAt ?? null,
checklist: rest.checklist ?? [],
};
}

View File

@@ -0,0 +1,60 @@
import {
Injectable,
Logger,
OnApplicationBootstrap,
OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommandBus } from '@nestjs/cqrs';
import {
EmptyTrashCommand,
TRASH_DAYS,
} from '@task-stream/api/tracker/application';
const HOUR = 3_600_000;
/**
* Deletes for good what has been in the trash longer than TRASH_DAYS (30
* by default), a few minutes after start and then every six hours.
*/
@Injectable()
export class TrashScheduler
implements OnApplicationBootstrap, OnApplicationShutdown
{
private readonly logger = new Logger(TrashScheduler.name);
private timers: ReturnType<typeof setTimeout>[] = [];
constructor(
private readonly commandBus: CommandBus,
private readonly config: ConfigService,
) {}
onApplicationBootstrap(): void {
const first = setTimeout(() => void this.run(), 5 * 60_000);
const every = setInterval(() => void this.run(), 6 * HOUR);
first.unref?.();
every.unref?.();
this.timers = [first, every];
}
onApplicationShutdown(): void {
this.timers.forEach((timer) => clearTimeout(timer));
}
private async run(): Promise<void> {
const days =
Number(this.config.get('TRASH_DAYS') ?? TRASH_DAYS) || TRASH_DAYS;
const before = new Date(Date.now() - days * 24 * HOUR).toISOString();
try {
const deleted: number = await this.commandBus.execute(
new EmptyTrashCommand(before),
);
if (deleted)
this.logger.log(`Deleted ${deleted} ticket(s) from the trash`);
} catch (error) {
this.logger.warn(
`Emptying the trash failed: ${(error as Error).message}`,
);
}
}
}

View File

@@ -54,6 +54,7 @@ import {
import { StatusCategoryBackfill } from './mongo/status-category.backfill';
import { RankBackfill } from './mongo/rank.backfill';
import { ReminderScheduler } from './notifications/reminder.scheduler';
import { TrashScheduler } from './notifications/trash.scheduler';
import { LOOKUP_ENTITIES } from './postgres/lookup.entities';
import {
PostgresIssueNumbersRepository,
@@ -159,6 +160,7 @@ const PORTS = [
StatusCategoryBackfill,
RankBackfill,
ReminderScheduler,
TrashScheduler,
],
exports: PORTS.map((port) => port.provide),
})

View File

@@ -290,6 +290,36 @@ export class IssueResolver {
);
}
@Mutation(() => CommandResultGql, {
description:
'To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days',
})
trashIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.trash(me.id, issueId);
}
@Mutation(() => CommandResultGql, { description: 'Back from the trash' })
restoreIssue(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
) {
return this.issuesService.restore(me.id, issueId);
}
@OptionalAuth()
@Query(() => [IssueGql], {
description: "A project's tickets in the trash, newest first",
})
trash(
@CurrentUser() me: User | undefined,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.issuesService.trashed(viewerOf(me), projectId);
}
@Mutation(() => BulkResultGql, {
description:
'The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own',

View File

@@ -342,6 +342,11 @@ export class IssueGql {
})
rank!: string;
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
@Field(() => String, {
nullable: true,
description: 'In the trash since then; null when it is not',
})
trashedAt!: string | null;
@Field(() => [DevLinkGql], {
description: 'Commits, branches and pull requests that mention it',
})

View File

@@ -309,6 +309,13 @@ export class IssueDto {
})
rank!: string;
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
@ApiProperty({
type: String,
nullable: true,
format: 'date-time',
description: 'In the trash since then; null when it is not',
})
trashedAt!: string | null;
@ApiProperty({
type: [DevLinkDto],
description: 'Commits, branches and pull requests that mention it',

View File

@@ -311,6 +311,47 @@ export class IssuesController {
);
}
@Delete('issues/:issue')
@ISSUE
@ApiOperation({
summary: '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.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async trash(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.trash(me.id, await this.refs.issueId(me.id, issue));
}
@Post('issues/:issue/restore')
@HttpCode(200)
@ISSUE
@ApiOperation({ summary: 'Bring the ticket back from the trash' })
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async restore(@CurrentUser() me: User, @Param('issue') issue: string) {
return this.issues.restore(me.id, await this.refs.issueId(me.id, issue));
}
@Get('projects/:project/trash')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: "The project's tickets in the trash, newest first" })
@ApiOkResponse({ type: [IssueDto] })
@ApiReadErrors()
async trashed(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
return this.refs.issueDtos(
await this.issues.trashed(
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
),
);
}
@Post('issues/bulk')
@HttpCode(200)
@ApiOperation({

View File

@@ -41,6 +41,7 @@ export const issuePageEvents = eventGroup({
files?: File[];
}>(),
watchRequested: type<{ issueId: string; watching: boolean }>(),
trashRequested: type<{ issueId: string; trashed: boolean }>(),
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
subtaskCreateRequested: type<{

View File

@@ -99,6 +99,13 @@ export class IssueFacade {
);
}
/** To the trash (true) or back from it (false). */
setTrashed(trashed: boolean): void {
this.withIssue((issueId) =>
this.dispatch.trashRequested({ issueId, trashed }),
);
}
/** Follow the ticket (or stop): be told when it moves or gets a comment. */
setWatching(watching: boolean): void {
this.withIssue((issueId) =>

View File

@@ -280,6 +280,22 @@ export const IssueStore = signalStore(
),
),
),
events
.on(issuePageEvents.trashRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
payload.trashed
? issues.trash(payload.issueId)
: issues.restore(payload.issueId),
payload.trashed
? 'Ticket en la papelera'
: 'Ticket recuperado',
done,
failed,
),
),
),
events
.on(issuePageEvents.watchRequested)
.pipe(

View File

@@ -119,6 +119,8 @@ export interface IssueSummary {
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
startDate: string | null;
dueDate: string | null;
/** In the trash since then (null: not in it). */
trashedAt: string | null;
commentCount: number;
updatedAt: string;
version: number;

View File

@@ -115,6 +115,11 @@ export abstract class IssuesGateway {
parentId: string | null,
): Observable<CommandResult>;
abstract epics(projectId: string): Observable<Epic[]>;
/** To the trash (whoever opened it, or a manager). */
abstract trash(issueId: string): Observable<CommandResult>;
abstract restore(issueId: string): Observable<CommandResult>;
/** The project's tickets in the trash, newest first. */
abstract trashed(projectId: string): Observable<IssueSummary[]>;
/** The same change on several tickets; each may refuse on its own. */
abstract bulkUpdate(
issueIds: string[],

View File

@@ -32,6 +32,9 @@ import {
MyIssuesDocument,
RankIssueDocument,
RemoveChecklistItemDocument,
RestoreIssueDocument,
TrashDocument,
TrashIssueDocument,
SearchIssuesDocument,
SetIssueParentDocument,
SetIssueReleaseDocument,
@@ -180,6 +183,24 @@ export class GraphqlIssuesGateway extends IssuesGateway {
);
}
trash(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(TrashIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.trashIssue)));
}
restore(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(RestoreIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.restoreIssue)));
}
trashed(projectId: string): Observable<IssueSummary[]> {
return this.gql
.query(TrashDocument, { projectId })
.pipe(map((d) => d.trash.map(toIssueSummary)));
}
bulkUpdate(issueIds: string[], change: BulkChange): Observable<BulkResult> {
return this.gql
.mutate(BulkUpdateIssuesDocument, {

View File

@@ -87,6 +87,7 @@ fragment IssueSummaryFields on Issue {
storyPoints
startDate
dueDate
trashedAt
}
fragment IssueFields on Issue {

View File

@@ -481,6 +481,7 @@ export type IssueSummaryFieldsFragment = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -509,6 +510,7 @@ export type IssueFieldsFragment = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -652,6 +654,7 @@ export type BoardQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -688,6 +691,7 @@ export type IssueQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
reporter: { id: string; name: string };
links: Array<{
type: IssueLinkType;
@@ -747,6 +751,7 @@ export type SearchIssuesQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -903,6 +908,7 @@ export type MyIssuesQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -935,6 +941,7 @@ export type IssueChildrenQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -967,6 +974,7 @@ export type EpicsQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -1041,6 +1049,7 @@ export type BacklogQuery = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -1068,6 +1077,53 @@ export type BulkUpdateIssuesMutation = {
};
};
export type TrashIssueMutationVariables = Exact<{
issueId: string;
}>;
export type TrashIssueMutation = {
trashIssue: { id: string; key: string | null; version: number };
};
export type RestoreIssueMutationVariables = Exact<{
issueId: string;
}>;
export type RestoreIssueMutation = {
restoreIssue: { id: string; key: string | null; version: number };
};
export type TrashQueryVariables = Exact<{
projectId: string;
}>;
export type TrashQuery = {
trash: Array<{
id: string;
projectId: string;
key: string;
type: string;
area: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
labels: Array<string>;
commentCount: number;
updatedAt: string;
version: number;
rank: string;
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
}>;
};
export type NotificationFieldsFragment = {
id: string;
kind: NotificationKind;
@@ -1714,6 +1770,7 @@ export type IssueChangedSubscription = {
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
@@ -2323,6 +2380,7 @@ export const IssueSummaryFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -2555,6 +2613,7 @@ export const IssueFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -4010,6 +4069,7 @@ export const BoardDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -4156,6 +4216,7 @@ export const IssueDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -4436,6 +4497,7 @@ export const SearchIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -5682,6 +5744,7 @@ export const MyIssuesDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -5842,6 +5905,7 @@ export const IssueChildrenDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -6020,6 +6084,7 @@ export const EpicsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -6502,6 +6567,7 @@ export const BacklogDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
@@ -6646,6 +6712,287 @@ export const BulkUpdateIssuesDocument = {
BulkUpdateIssuesMutation,
BulkUpdateIssuesMutationVariables
>;
export const TrashIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'TrashIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'trashIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
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<TrashIssueMutation, TrashIssueMutationVariables>;
export const RestoreIssueDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'RestoreIssue' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'restoreIssue' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
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<
RestoreIssueMutation,
RestoreIssueMutationVariables
>;
export const TrashDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Trash' },
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: 'trash' },
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: 'IssueSummaryFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueSummaryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Issue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
],
} as unknown as DocumentNode<TrashQuery, TrashQueryVariables>;
export const MyNotificationsDocument = {
kind: 'Document',
definitions: [
@@ -10421,6 +10768,7 @@ export const IssueChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},

View File

@@ -221,3 +221,21 @@ mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) {
}
}
}
mutation TrashIssue($issueId: ID!) {
trashIssue(issueId: $issueId) {
...CommandResultFields
}
}
mutation RestoreIssue($issueId: ID!) {
restoreIssue(issueId: $issueId) {
...CommandResultFields
}
}
query Trash($projectId: ID!) {
trash(projectId: $projectId) {
...IssueSummaryFields
}
}

View File

@@ -223,6 +223,9 @@ const NOBODY = '__none__';
description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge."
/>
}
<p class="ts-backlog__trash">
<a class="ts-plain-link" routerLink="../trash">Papelera</a>
</p>
`,
styles: `
.ts-backlog {
@@ -275,6 +278,11 @@ const NOBODY = '__none__';
align-items: center;
font-size: 0.875rem;
}
.ts-backlog__trash {
margin: 1rem 0 0;
font-size: 0.875rem;
text-align: right;
}
.ts-backlog__pos {
color: var(--ts-muted);
font-size: 0.75rem;

View File

@@ -520,3 +520,14 @@ a.ts-key {
.ts-dev__when {
font-size: 0.8125rem;
}
.ts-trashed {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
}

View File

@@ -94,6 +94,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<a class="ts-back" routerLink="../../board">← Tablero</a>
@if (facade.issue(); as issue) {
@if (issue.trashedAt) {
<div class="ts-trashed" role="status">
En la papelera desde {{ ago(issue.trashedAt) }}: no aparece en el
tablero ni en las búsquedas, y se borrará del todo a los 30 días.
@if (canTrash(issue)) {
<pi-action-button
size="small"
color="primary"
text="Recuperar"
(execute)="facade.setTrashed(false)"
/>
}
</div>
}
<div class="ts-issue">
<section class="ts-issue__main">
<header class="ts-issue__header">
@@ -698,6 +712,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
<pi-label>Área</pi-label>
</pi-select-field>
@if (!issue.trashedAt && canTrash(issue)) {
<pi-action-button
size="small"
color="grey"
appearance="ghost"
text="Mover a la papelera"
(execute)="trash()"
/>
}
<dl class="ts-facts">
<dt>Reportado por</dt>
<dd>{{ issue.reporter.name }}</dd>
@@ -1122,6 +1145,23 @@ export class IssuePage {
}
/** A screenshot pasted anywhere on the ticket is attached to it. */
/** Whoever opened it, or whoever manages the project. */
protected canTrash(issue: { reporter: { id: string } }): boolean {
return (
this.project.canContribute() &&
(issue.reporter.id === this.facade.me() || this.project.canManage())
);
}
protected trash(): void {
if (
confirm(
'¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.',
)
)
this.facade.setTrashed(true);
}
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {

View File

@@ -0,0 +1,130 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { PiActionButtonComponent, PiEmptyStateComponent } from '@pi/ui';
import { ToastService } from '@task-stream/web/shared/ui';
import { ProjectFacade } from '@task-stream/web/tracker/application';
import { IssueSummary, IssuesGateway } from '@task-stream/web/tracker/domain';
import { timeAgo } from '../ui/copy';
/** /p/:key/trash — tickets deleted in the last 30 days, to bring back. */
@Component({
selector: 'ts-trash-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiActionButtonComponent, PiEmptyStateComponent],
template: `
<div class="ts-section-header">
<p class="ts-muted">
Tickets borrados en los últimos 30 días. Después se borran del todo, con
sus comentarios.
</p>
</div>
@if (issues().length) {
<ul class="ts-trash ts-panel">
@for (issue of issues(); track issue.id) {
<li class="ts-trash__row">
<a class="ts-key" [routerLink]="['..', 'issues', issue.key]">{{
issue.key
}}</a>
<span class="ts-trash__title">{{ issue.title }}</span>
<span class="ts-muted">{{
issue.trashedAt ? ago(issue.trashedAt) : ''
}}</span>
@if (project.canContribute()) {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Recuperar"
(execute)="restore(issue)"
/>
}
</li>
}
</ul>
} @else if (loaded()) {
<pi-empty-state
icon="trash"
title="La papelera está vacía"
description="Lo que se borre aparecerá aquí durante 30 días."
/>
}
`,
styles: `
.ts-trash {
margin: 0;
padding: 0.25rem 0;
list-style: none;
}
.ts-trash__row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 0.75rem;
align-items: center;
padding: 0.5rem 0.875rem;
border-top: 1px solid var(--ts-line);
&:first-child {
border-top: 0;
}
}
.ts-trash__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
})
export class TrashPage {
protected readonly project = inject(ProjectFacade);
private readonly gateway = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
protected readonly issues = signal<IssueSummary[]>([]);
protected readonly loaded = signal(false);
protected readonly ago = timeAgo;
constructor() {
effect(() => {
const projectId = this.project.project()?.id;
if (projectId) untracked(() => this.reload(projectId));
});
}
protected restore(issue: IssueSummary): void {
this.gateway
.restore(issue.id)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
this.toast.success(`${issue.key} recuperado`);
this.issues.update((list) => list.filter((i) => i.id !== issue.id));
},
error: (error) =>
this.toast.error(
(error as { message?: string })?.message?.split('\n')[0] ||
'No se pudo recuperar',
),
});
}
private reload(projectId: string): void {
this.gateway
.trashed(projectId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (issues) => {
this.issues.set(issues);
this.loaded.set(true);
},
error: () => this.loaded.set(true),
});
}
}

View File

@@ -62,6 +62,12 @@ export const trackerRoutes: Routes = [
loadComponent: () =>
import('./pages/epics.page').then((m) => m.EpicsPage),
},
{
path: 'trash',
title: 'Papelera · TaskStream',
loadComponent: () =>
import('./pages/trash.page').then((m) => m.TrashPage),
},
{
path: 'releases',
title: 'Releases · TaskStream',