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

@@ -4,6 +4,10 @@ import {
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import {
ProjectArchivedEvent,
ProjectUnarchivedEvent,
} from '../events/project-archived.event';
import { ProjectCreatedEvent } from '../events/project-created.event';
import { ProjectDeletedEvent } from '../events/project-deleted.event';
import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
@@ -62,6 +66,8 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
public updatedAt!: string;
/** Set once deleted: the project can no longer be loaded for commands. */
public deleted = false;
/** Archived since then: read-only until its owner brings it back. */
public archivedAt: string | null = null;
constructor(id: string) {
super();
@@ -96,10 +102,20 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
if (this.roleOf(userId) === ProjectRole.VIEWER) {
throw new ForbiddenError(`Viewers cannot modify project ${this.key}`);
}
this.assertNotArchived();
}
/** Can manage members, releases and project settings. */
/** Can change members, releases, settings and rules (owners and admins). */
assertCanManage(userId: string): void {
this.assertManages(userId);
this.assertNotArchived();
}
/**
* Is an owner or an admin: enough to read what only they see (rules,
* webhooks…), archived or not. To change anything, assertCanManage.
*/
assertManages(userId: string): void {
const role = this.roleOf(userId);
if (role !== ProjectRole.OWNER && role !== ProjectRole.ADMIN) {
throw new ForbiddenError(
@@ -108,6 +124,36 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
}
}
/** An archived project only changes back to active (by its owner). */
assertNotArchived(): void {
if (this.archivedAt)
throw new BusinessRuleError(
`${this.key} está archivado: es de solo lectura hasta que su propietario lo desarchive`,
);
}
/**
* Only the owner archives a project: everything stays readable, nothing
* changes until they unarchive it. Idempotent.
*/
archive(actorId: string): void {
if (actorId !== this.ownerId)
throw new ForbiddenError(
`Only the owner can archive project ${this.key}`,
);
if (this.archivedAt) return;
this.apply(new ProjectArchivedEvent(actorId, this.id));
}
unarchive(actorId: string): void {
if (actorId !== this.ownerId)
throw new ForbiddenError(
`Only the owner can unarchive project ${this.key}`,
);
if (!this.archivedAt) return;
this.apply(new ProjectUnarchivedEvent(actorId, this.id));
}
updateDetails(
actorId: string,
changes: {
@@ -249,6 +295,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
this.deleted = true;
}
onProjectArchivedEvent(event: ProjectArchivedEvent): void {
this.archivedAt = event.occurredAt;
}
onProjectUnarchivedEvent(): void {
this.archivedAt = null;
}
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
this.settings = structuredClone(event.settings);
this.updatedAt = event.occurredAt;