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

@@ -51,6 +51,7 @@ export const projectPageEvents = eventGroup({
analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(),
visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(),
archiveRequested: type<{ projectId: string; archived: boolean }>(),
proposalsChangeRequested: type<{
projectId: string;
acceptsProposals: boolean;

View File

@@ -162,6 +162,12 @@ export class ProjectFacade {
this.dispatch.visibilityChangeRequested({ projectId, isPublic });
}
/** Archives the project (read-only for everyone) or brings it back. Owner only. */
setArchived(archived: boolean): void {
const projectId = this.projectId();
if (projectId) this.dispatch.archiveRequested({ projectId, archived });
}
/** Take bug reports and ideas from anyone (while public). Owners and admins. */
setAcceptsProposals(acceptsProposals: boolean): void {
const projectId = this.projectId();

View File

@@ -100,11 +100,17 @@ export const ProjectStore = signalStore(
);
return {
myRole,
/** Archived projects are read-only for everyone. */
canManage: computed(
() => myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN,
() =>
!project()?.archivedAt &&
(myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN),
),
canContribute: computed(
() => !!myRole() && myRole() !== ProjectRole.VIEWER,
() =>
!project()?.archivedAt &&
!!myRole() &&
myRole() !== ProjectRole.VIEWER,
),
members: computed(() => project()?.members ?? []),
/** Statuses, types and areas of the project, with lookups. */
@@ -433,6 +439,20 @@ export const ProjectStore = signalStore(
),
),
),
events
.on(projectPageEvents.archiveRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
projects.setArchived(payload.projectId, payload.archived),
payload.archived
? 'Proyecto archivado: queda en solo lectura'
: 'Proyecto desarchivado',
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
),
),
),
events
.on(projectPageEvents.proposalsChangeRequested)
.pipe(

View File

@@ -39,8 +39,13 @@ export const ProjectsStore = signalStore(
error: null,
}),
withComputed(({ projects }) => ({
/** By name, archived ones last. */
sorted: computed(() =>
[...projects()].sort((a, b) => a.name.localeCompare(b.name)),
[...projects()].sort(
(a, b) =>
Number(!!a.archivedAt) - Number(!!b.archivedAt) ||
a.name.localeCompare(b.name),
),
),
})),
withReducer(

View File

@@ -31,6 +31,8 @@ export interface Project {
owner: UserRef;
members: ProjectMember[];
settings: ProjectSettings;
/** Archived since then (read-only); null while active. */
archivedAt: string | null;
updatedAt: string;
version: number;
}

View File

@@ -71,6 +71,11 @@ export abstract class ProjectsGateway {
projectId: string,
since: string | null,
): Observable<ProjectInsights>;
/** Archives the project (read-only) or brings it back. Owner only. */
abstract setArchived(
projectId: string,
archived: boolean,
): Observable<CommandResult>;
/** Statistics of the last `days` days. */
abstract analytics(
projectId: string,

View File

@@ -17,6 +17,8 @@ import {
ChangeProjectMemberRoleDocument,
CreateProjectDocument,
DeleteProjectDocument,
ArchiveProjectDocument,
UnarchiveProjectDocument,
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectInsightsDocument,
@@ -102,6 +104,16 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toCommandResult(d.updateProject)));
}
setArchived(projectId: string, archived: boolean): Observable<CommandResult> {
return archived
? this.gql
.mutate(ArchiveProjectDocument, { projectId })
.pipe(map((d) => toCommandResult(d.archiveProject)))
: this.gql
.mutate(UnarchiveProjectDocument, { projectId })
.pipe(map((d) => toCommandResult(d.unarchiveProject)));
}
delete(projectId: string, confirmKey: string): Observable<CommandResult> {
return this.gql
.mutate(DeleteProjectDocument, { projectId, confirmKey })

View File

@@ -10,6 +10,7 @@ fragment ProjectFields on Project {
description
isPublic
acceptsProposals
archivedAt
updatedAt
version
owner {

View File

@@ -527,6 +527,7 @@ export type ProjectFieldsFragment = {
description: string | null;
isPublic: boolean;
acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -1522,6 +1523,7 @@ export type MyProjectsQuery = {
description: string | null;
isPublic: boolean;
acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -1573,6 +1575,7 @@ export type ProjectQuery = {
description: string | null;
isPublic: boolean;
acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -1734,6 +1737,7 @@ export type PublicProjectsQuery = {
description: string | null;
isPublic: boolean;
acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -1781,6 +1785,22 @@ export type UpdateProjectMutation = {
updateProject: { id: string; key: string | null; version: number };
};
export type ArchiveProjectMutationVariables = Exact<{
projectId: string;
}>;
export type ArchiveProjectMutation = {
archiveProject: { id: string; key: string | null; version: number };
};
export type UnarchiveProjectMutationVariables = Exact<{
projectId: string;
}>;
export type UnarchiveProjectMutation = {
unarchiveProject: { id: string; key: string | null; version: number };
};
export type DeleteProjectMutationVariables = Exact<{
projectId: string;
confirmKey: string;
@@ -2410,6 +2430,7 @@ export type ProjectChangedSubscription = {
description: string | null;
isPublic: boolean;
acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -2464,6 +2485,7 @@ export type MyProjectsChangedSubscription = {
description: string | null;
isPublic: boolean;
acceptsProposals: boolean;
archivedAt: string | null;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -2730,6 +2752,7 @@ export const ProjectFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -9475,6 +9498,7 @@ export const MyProjectsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -9722,6 +9746,7 @@ export const ProjectDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -10820,6 +10845,7 @@ export const PublicProjectsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -11057,6 +11083,146 @@ export const UpdateProjectDocument = {
UpdateProjectMutation,
UpdateProjectMutationVariables
>;
export const ArchiveProjectDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'ArchiveProject' },
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: 'archiveProject' },
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: '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<
ArchiveProjectMutation,
ArchiveProjectMutationVariables
>;
export const UnarchiveProjectDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnarchiveProject' },
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: 'unarchiveProject' },
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: '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<
UnarchiveProjectMutation,
UnarchiveProjectMutationVariables
>;
export const DeleteProjectDocument = {
kind: 'Document',
definitions: [
@@ -14206,6 +14372,7 @@ export const ProjectChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -14440,6 +14607,7 @@ export const MyProjectsChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{

View File

@@ -139,6 +139,18 @@ mutation UpdateProject($input: UpdateProjectInput!) {
}
}
mutation ArchiveProject($projectId: ID!) {
archiveProject(projectId: $projectId) {
...CommandResultFields
}
}
mutation UnarchiveProject($projectId: ID!) {
unarchiveProject(projectId: $projectId) {
...CommandResultFields
}
}
mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
deleteProject(projectId: $projectId, confirmKey: $confirmKey) {
...CommandResultFields

View File

@@ -101,12 +101,27 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
@if (facade.canManage()) {
@if (facade.canManage() || facade.isOwner()) {
<a routerLink="settings" routerLinkActive="is-active"
>Configuración</a
>
}
</nav>
@if (project.archivedAt) {
<p class="ts-archived" role="status">
<strong>Proyecto archivado</strong>
Se puede ver todo, pero es de solo lectura.
@if (facade.isOwner()) {
<button
type="button"
class="ts-plain-link"
(click)="facade.setArchived(false)"
>
Desarchivar
</button>
}
</p>
}
<router-outlet />
} @else if (facade.error(); as error) {
<pi-note
@@ -142,6 +157,19 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
}
`,
styles: `
.ts-archived {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1rem;
padding: 0.625rem 0.875rem;
border: 1px solid var(--ts-line);
border-radius: 0.625rem;
background: var(--ts-sunken);
color: var(--ts-ink-soft);
font-size: 0.875rem;
}
.ts-eyebrow .ts-pill {
margin-left: 0.5rem;
letter-spacing: 0;

View File

@@ -693,6 +693,32 @@ function idFrom(name: string): string {
}
@if (facade.isOwner() && facade.project(); as project) {
<section
class="ts-panel ts-block ts-visibility"
aria-labelledby="archive-title"
>
<div>
<h3 id="archive-title">Archivar el proyecto</h3>
<p class="ts-muted">
@if (project.archivedAt) {
Archivado: se puede ver todo, pero nadie puede cambiar nada
hasta que lo desarchives.
} @else {
Para un proyecto terminado: queda todo a la vista (tablero,
tickets, historia, estadísticas) y en solo lectura. Se puede
desarchivar cuando quieras.
}
</p>
</div>
<button
type="button"
class="ts-secondary"
(click)="facade.setArchived(!project.archivedAt)"
>
{{ project.archivedAt ? 'Desarchivar' : 'Archivar' }}
</button>
</section>
<section
class="ts-panel ts-block ts-danger"
aria-labelledby="danger-title"

View File

@@ -25,6 +25,9 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
@if (project().isPublic) {
<span class="ts-pill" [style.--tone]="publicTone">Público</span>
}
@if (project().archivedAt) {
<span class="ts-pill">Archivado</span>
}
<span class="ts-project__updated ts-mono">{{
ago(project().updatedAt)
}}</span>