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:
@@ -21,6 +21,7 @@ export * from './release-updated.event';
|
||||
export * from './release-shipped.event';
|
||||
export * from './release-archived.event';
|
||||
export * from './project-deleted.event';
|
||||
export * from './project-archived.event';
|
||||
export * from './issue-deleted.event';
|
||||
export * from './release-deleted.event';
|
||||
export * from './issue-attachment-added.event';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** The owner archived the project: read-only until it is unarchived. */
|
||||
@AutowiredEvent
|
||||
export class ProjectArchivedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, projectId: string) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The owner brought an archived project back. */
|
||||
@AutowiredEvent
|
||||
export class ProjectUnarchivedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, projectId: string) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -106,4 +106,28 @@ describe('Deleting a project', () => {
|
||||
).toHaveLength(2);
|
||||
expect(source.links.map((l) => l.targetIssueId)).toEqual(['o']);
|
||||
});
|
||||
|
||||
it('archives a project read-only, and only its owner brings it back', () => {
|
||||
const project = newProject();
|
||||
expect(() => project.archive('admin')).toThrow(ForbiddenError);
|
||||
project.archive('owner');
|
||||
expect(project.archivedAt).not.toBeNull();
|
||||
// Idempotent.
|
||||
const events = project.getUncommittedEvents().length;
|
||||
project.archive('owner');
|
||||
expect(project.getUncommittedEvents()).toHaveLength(events);
|
||||
|
||||
// Nothing changes, but who manages it can still read what only they see.
|
||||
expect(() => project.assertCanContribute('admin')).toThrow(/archivado/);
|
||||
expect(() => project.updateDetails('owner', { name: 'Otra' })).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
expect(() => project.assertManages('admin')).not.toThrow();
|
||||
expect(() => project.unarchive('admin')).toThrow(ForbiddenError);
|
||||
|
||||
project.unarchive('owner');
|
||||
expect(project.archivedAt).toBeNull();
|
||||
expect(() => project.assertCanContribute('admin')).not.toThrow();
|
||||
expect(replay(project, Project).rebuilt.archivedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,6 +18,8 @@ export class ProjectReadModel {
|
||||
acceptsProposals!: boolean;
|
||||
owner!: UserRefReadModel;
|
||||
members!: ProjectMemberReadModel[];
|
||||
/** Archived since then (read-only); null while active. */
|
||||
archivedAt!: string | null;
|
||||
/** Statuses, transitions, ticket types and areas. */
|
||||
settings!: ProjectSettings;
|
||||
createdAt!: string;
|
||||
|
||||
Reference in New Issue
Block a user