feat: archive a project instead of deleting it

The owner archives a finished project (archiveProject, POST
/projects/{project}/archive) and brings it back (unarchive). While
archived it stays readable, managers still read its rules and webhooks,
but no command changes it: contributing and managing checks refuse with
422, votes and public proposals too, and automations do not run. The web
shows a read-only notice on every page of it (with «Desarchivar» for the
owner), hides what edits, marks it on the home page and lists it last.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:46:22 +02:00
parent e598f76e1c
commit 4acf629106
42 changed files with 1057 additions and 14 deletions

View File

@@ -96,9 +96,12 @@ async function managedProject(
access: WriteAccess,
projectId: string,
actorId: string,
/** Only to read the rules: an archived project still shows them. */
reading = false,
): Promise<Project> {
const project = await access.project(projectId);
project.assertCanManage(actorId);
if (reading) project.assertManages(actorId);
else project.assertCanManage(actorId);
return project;
}
@@ -231,6 +234,7 @@ export class RunAutomationCommandHandler
? await this.access.live(issue.projectId, Project)
: null;
if (!issue || !project) return 'The ticket is gone';
if (project.archivedAt) return 'The project is archived';
const { kind, value } = command.automation.action;
const actor = SYSTEM_ACTOR_ID;
switch (kind) {
@@ -297,7 +301,7 @@ export class GetAutomationsQueryHandler
) {}
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
await managedProject(this.access, query.projectId, query.viewerId);
await managedProject(this.access, query.projectId, query.viewerId, true);
return this.automations.findByProject(query.projectId);
}
}

View File

@@ -0,0 +1,19 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ArchiveProjectCommand } from './archive-project.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
@CommandHandler(ArchiveProjectCommand)
export class ArchiveProjectCommandHandler
implements ICommandHandler<ArchiveProjectCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: ArchiveProjectCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
if (command.archived) project.archive(command.actorId);
else project.unarchive(command.actorId);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** The owner archives the project (read-only) or, with `archived` false, brings it back. */
export class ArchiveProjectCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly archived: boolean,
) {
super();
}
}

View File

@@ -21,6 +21,7 @@ import { UpdateReleaseCommandHandler } from './update-release.command-handler';
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
import { DeleteProjectCommandHandler } from './delete-project.command-handler';
import { ArchiveProjectCommandHandler } from './archive-project.command-handler';
import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
@@ -66,6 +67,7 @@ export const COMMAND_HANDLERS = [
PublishReleaseCommandHandler,
ArchiveReleaseCommandHandler,
DeleteProjectCommandHandler,
ArchiveProjectCommandHandler,
// Emitted by sagas only (cascades and reactions to other aggregates).
DeleteIssueCommandHandler,
DeleteReleaseCommandHandler,

View File

@@ -24,6 +24,7 @@ export class VoteIssueCommandHandler
throw new ForbiddenError(
`You are not a member of project ${project.key}`,
);
project.assertNotArchived();
if (issue.trashedAt)
throw new BusinessRuleError(`${issue.key} is in the trash`);
issue.vote(command.actorId, command.on);

View File

@@ -26,6 +26,8 @@ import {
ProjectRole,
ProjectSettingsChangedEvent,
ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectWorkflow,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
@@ -109,6 +111,10 @@ export function describeEvent(
return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectArchivedEvent)
return `${actor} archivó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectUnarchivedEvent)
return `${actor} desarchivó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectSettingsChangedEvent)
return `${actor} cambió los estados, tipos o áreas del proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) {

View File

@@ -25,6 +25,8 @@ import {
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent,
@@ -57,6 +59,8 @@ import { versionOf } from './read-model-notifier';
@EventsHandler(
ProjectCreatedEvent,
ProjectUpdatedEvent,
ProjectArchivedEvent,
ProjectUnarchivedEvent,
ProjectSettingsChangedEvent,
ProjectMemberAddedEvent,
ProjectMemberRemovedEvent,

View File

@@ -16,6 +16,7 @@ import { ProjectMemberAddedEventHandler } from './project-member-added.event-han
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
import { ProjectArchivedEventHandler } from './project-archived.event-handler';
import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
import { ReleaseCreatedEventHandler } from './release-created.event-handler';
import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
@@ -63,6 +64,7 @@ export const EVENT_HANDLERS = [
ProjectMemberRemovedEventHandler,
ProjectMemberRoleChangedEventHandler,
ProjectUpdatedEventHandler,
ProjectArchivedEventHandler,
ProjectSettingsChangedEventHandler,
ReleaseArchivedEventHandler,
ReleaseCreatedEventHandler,

View File

@@ -0,0 +1,28 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
ProjectArchivedEvent,
ProjectUnarchivedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectArchivedEvent, ProjectUnarchivedEvent)
export class ProjectArchivedEventHandler
implements IEventHandler<ProjectArchivedEvent | ProjectUnarchivedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectArchivedEvent | ProjectUnarchivedEvent) {
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
archivedAt:
event instanceof ProjectArchivedEvent ? event.occurredAt : null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.project(event);
}
}

View File

@@ -29,6 +29,7 @@ import { ChangeProjectMemberRoleCommand } from './commands/change-project-member
import { CommandResult } from './commands/command-result';
import { CreateProjectCommand } from './commands/create-project.command';
import { DeleteProjectCommand } from './commands/delete-project.command';
import { ArchiveProjectCommand } from './commands/archive-project.command';
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
import { UpdateProjectCommand } from './commands/update-project.command';
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
@@ -75,7 +76,7 @@ export class ProjectsService {
(await this.findProjects.findById(projectIdOrKey)) ??
(await this.findProjects.findByKey(projectIdOrKey.toUpperCase()));
if (!found?.isPublic) throw new NotFoundError('Project', projectIdOrKey);
if (!found.acceptsProposals)
if (!found.acceptsProposals || found.archivedAt)
throw new ForbiddenError(`${found.key} does not take proposals`);
const cleanTitle = title.trim();
@@ -170,7 +171,8 @@ export class ProjectsService {
automations: Automation[];
}> {
const project = await this.access.project(projectId);
project.assertCanManage(actorId);
// Copying only reads it: an archived project makes a fine template.
project.assertManages(actorId);
const automations: Automation[] = await this.queryBus.execute(
new GetAutomationsQuery(actorId, projectId),
);
@@ -241,6 +243,17 @@ export class ProjectsService {
}
/** Statuses, transitions, ticket types and areas (owners and admins). */
/** Archives the project (read-only) or brings it back; its owner only. */
setArchived(
actorId: string,
projectId: string,
archived: boolean,
): Promise<CommandResult> {
return this.commands.execute(
new ArchiveProjectCommand(actorId, projectId, archived),
);
}
updateSettings(
actorId: string,
projectId: string,

View File

@@ -327,9 +327,7 @@ export class GetWebhooksQueryHandler
) {}
async execute(query: GetWebhooksQuery): Promise<WebhookView[]> {
(await this.access.project(query.projectId)).assertCanManage(
query.viewerId,
);
(await this.access.project(query.projectId)).assertManages(query.viewerId);
return (await this.webhooks.findByProject(query.projectId)).map(view);
}
}