feat: archive and unarchive projects and teams

Merged
EV
18d ago
+2356-41

Stacked on #11506 (its commit is the first one here); merge that first, then this diff is the archive feature alone.

What

You can now Archive / Unarchive projects and teams that you have finished working on. An archived project or team keeps its members, tasks, time logs and history, and you can still open it by its id. It no longer appears in any list or picker. The one exception is the Projects or Teams page with Include Archived turned on, which is where you unarchive it. Delete still works the same way, for a project or team created by mistake.

API

  • New endpoints. Each sets isArchived and archivedAt, which every project and team row already has, so no schema change is needed:

    • PUT /organization-projects/:id/archive and /:id/unarchive. Permission ALL_ORG_EDIT | ORG_PROJECT_EDIT; project managers pass through ProjectManagerOrPermissionGuard, the same as for editing.
    • PUT /organization-team/:id/archive and /:id/unarchive. Permission ALL_ORG_EDIT | ORG_TEAM_EDIT; without CHANGE_SELECTED_EMPLOYEE the caller must manage the team (assertMayManageTeam), the same as for editing or deleting it.
    • GraphQL twins with the same permissions: archiveOrganizationProject, unarchiveOrganizationProject, archiveOrganizationTeam, unarchiveOrganizationTeam.
  • Lists leave archived rows out unless the caller asks for them. This is done on the server, so every consumer gets it at once: the web app's 25+ pickers, the desktop timer, Ever Teams (/organization-team/me), MCP, Zapier and AI tools. It covers:

    • GET /organization-projects, /pagination, /count and /employee/:employeeId;
    • GET /organization-team, /pagination, /me and /count;
    • the GraphQL list and count fields.

    The caller controls it through where[isArchived]:

    where[isArchived]Rows returned
    not setonly rows that are not archived
    true / false, as a boolean or as textrows in that state
    [false, true]both ("Include Archived")
  • GET /:id is not filtered, so an archived project's or team's own page still opens.

  • GraphQL list filters. organizationProjects, organizationTeams and myOrganizationTeams apply their connection filter to the rows the service has already read. When that filter names isArchived anywhere, including inside and/or/not, they now read both states first, so filter: { isArchived: { eq: true } } returns the archived rows.

  • Links to archived items are kept on edit. The edit forms (and other clients such as Ever Teams) pick teams and projects from lists that no longer show archived ones, so they never send those links back. PUT /organization-projects/:id now keeps a project's links to archived teams that the payload omits, and PUT /organization-team/:id does the same for a team's archived projects. Without this, a save would silently drop those links.

  • Migration 1791000003440-NormalizeProjectAndTeamArchiveFlags sets isArchived = false on rows where it is NULL, so such rows cannot drop out of every list. It is idempotent.

Web app

  • Projects page:
    • an Include Archived toggle (works in both table and grid layout);
    • an Archive button with a confirmation dialog, or Unarchive when the selected project is already archived;
    • an Archived badge on archived rows;
    • the header project picker removes a project when it is archived and adds it back when it is unarchived.
  • Header pickers: a project or team that is archived or deleted while selected is no longer kept as the selection; the picker falls back to "All". Before this change, deleting the selected item had the same problem.
  • Teams page: the same toggle and buttons, with "(Archived)" after the team name. The team pickers update the same way.
  • i18n: existing keys are reused (BUTTONS.ARCHIVE, FORM.CHECKBOXES.INCLUDE_ARCHIVED, ORGANIZATIONS_PAGE.ARCHIVED). The new keys BUTTONS.UNARCHIVE and the archive/unarchive toasts are translated in en, ar, bg, de, es, fr, he, it, nl, pl, pt, ru and zh. ach is the Crowdin pseudo-locale and falls back to English.

Not changed

  • Global platform stats (StatsService, all tenants) still count every team record.

  • Project managers who lack the role permission still can't use project mutations over GraphQL. ProjectManagerOrPermissionGuard reads only request.params.id, the same as for every existing project mutation; nothing here changes that.

  • Time logs, tasks and reports that reference an archived project or team still show it.

  • A project's linked teams and a team's linked projects are still returned in full.

Tests

  • archived-scope.spec.ts covers the request semantics: the default, a boolean, text, and a list.
  • organization-team.service.with-deleted.spec.ts now runs findAll on real SQLite under both TypeORM and MikroORM with archived rows:
    • the default leaves them out;
    • asking for true returns them, including when true arrives as text;
    • "Include Archived" returns both;
    • another tenant's archived team is never returned;
    • a read with no where also leaves archived teams out (before this change it threw).
  • organization-project.service.archive.spec.ts:
    • pagination, findAll, countBy and findByEmployee all use the default and honour the caller's isArchived;
    • setArchived writes only the flag and its timestamp, and logs the activity;
    • setArchived refuses a project the caller's tenant cannot read.
  • organization-team.service.archive.spec.ts:
    • setArchived writes only the flag and its timestamp;
    • a team manager may archive the team;
    • any other caller without CHANGE_SELECTED_EMPLOYEE gets 403 and nothing is written;
    • countBy uses the default.
  • archived-scope.spec.ts also covers archivedScopeOfFilter (at any depth) and withArchivedLinks. The OrganizationProjectService.update and OrganizationTeamService.update tests keep archived links the payload omits and leave relations the payload does not mention untouched. The resolver specs check that GraphQL isArchived filters read both states.
  • project-team-archive-flags-migration.spec.ts runs on real SQLite: NULL becomes false, an archived row stays archived, a rerun is a no-op, and a database without the tables is skipped.

🤖 Generated with Claude Code