Skip to content

πŸ—οΈ PUT-1732: delete a workspace seat for good, once it is disabled - #3740

Open
jfcastro92 wants to merge 1 commit into
juancastro/put-1746-1733-member-facing-lifecyclefrom
juancastro/put-1732-hard-delete-on-request
Open

πŸ—οΈ PUT-1732: delete a workspace seat for good, once it is disabled#3740
jfcastro92 wants to merge 1 commit into
juancastro/put-1746-1733-member-facing-lifecyclefrom
juancastro/put-1732-hard-delete-on-request

Conversation

@jfcastro92

Copy link
Copy Markdown
Collaborator

Closes PUT-1732. Stacked on #3738.

The gap this closes

Phase 5's scope named hard delete, and it was the one piece missing. cascadeDelete existed but appeared in the team path only as a provisioning rollback β€” there was no way for a workspace owner to remove a seat they no longer want. A disabled account persisted forever with no route out.

The ordering is the substance

Deletion is refused unless the account is already disabled:

DELETE /teams/:uid/members/:username
  -> 409 {"code": "account_must_be_disabled_first"}

That puts a reversible step in front of the only irreversible operation in the feature, and means deletion cannot be reached by a single action on a live account. Disable first, then delete.

The audit row is written before cascadeDelete runs. The FKs are ON DELETE SET NULL and the _keep columns carry the identifiers, so the record of what was done survives the account it names. Written after, it would name nothing.

The billing emit that is deliberately absent

deleteMember does not emit team.account.deleted. UserAccountService.cascadeDelete already captures the seat before the delete and emits it after β€” so a second emit here would close the storage charge twice.

That was worth checking rather than assuming: the natural-looking implementation is captureSeatForBilling β†’ cascadeDelete β†’ emitSeatDeleted, which is exactly what the phase 3 wiring already does one layer down. The test asserts the count is 1, so a future reader who adds the "missing" emit gets told.

Disabling closed the per-account charge; this closes the storage one, and it is the only thing that does.

No restore window

Considered and rejected, for three reasons: the reversible step already exists earlier at disable; a disabled account costs only the bytes it holds, so nothing pressures a hasty delete; and a restore promise means retaining data the workspace has explicitly asked to be rid of. Delete means delete. There is no retention timer either β€” with no file transfer, the account still holds the member's work, so a clock would destroy real data on a schedule nobody revisited.

Published in rate-limits-and-quotas.md next to the workspace-deletion note, because the two are easy to confuse and only one of them frees a seat.

Verification

Typecheck clean. Full backend suite 7887 passed | 26 skipped.

Falsified β€” each piece broken, the failing test confirmed, then restored:

Broke Failed
the disable gate 1 β€” refuses to delete a live account
the audit append 1 β€” keeps the audit trail attributable after the account is gone

Four tests total: the two above, one asserting the account is really gone and the deleted event fires exactly once, and an HTTP test covering the route wiring end to end (409 on a live account, 200 after disabling, row gone).

Not done here

The confirmation dialog β€” PUT-1732 step 2 says "separately confirmed" β€” is GUI, so it lands in phase 6 with TabTeams (PUT-1740). The endpoint is unconditional today; the confirmation is a client-side gate, which is the right place for it but does mean the API will delete on a single call until that ships.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.85%
⬆️ +0.01%
29125 / 31032
πŸ”΅ Statements 91.87%
⬆️ +0.01%
31621 / 34417
πŸ”΅ Functions 90.61%
🟰 ±0%
5370 / 5926
πŸ”΅ Branches 80.77%
⬆️ +0.01%
20904 / 25879
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/controllers/team/TeamController.ts 80.61%
⬆️ +1.25%
43.33%
🟰 ±0%
72.54%
⬆️ +0.54%
83.85%
⬆️ +1.39%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 201-212, 220, 264, 287-296, 340-342, 355-357, 362-364, 371-373
src/backend/services/team/TeamService.ts 86.64%
⬆️ +0.38%
72.35%
⬆️ +0.33%
96.42%
⬆️ +0.06%
88.8%
⬆️ +0.34%
101, 103, 129-130, 150-151, 172, 240-242, 245-247, 256-268, 297-300, 322-333, 383-386, 394, 400, 445-448, 579-581, 641, 725-728, 825, 844-846, 874
Generated in workflow #1599 for commit 8c939a9 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1732-hard-delete-on-request branch from 6b96d3e to 2ba9468 Compare September 4, 2026 04:31
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1732-hard-delete-on-request branch from 2ba9468 to de223f1 Compare September 4, 2026 13:35
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Terminology: "workspace" is now "team", everywhere

The feature said puter.teams in the namespace and "workspace" in every string, comment and error code. That divergence was deliberate at design time; it is now settled the other way, and the whole stack says team.

Applied to every commit rather than added on top, so each PR shows its own code already named correctly and no intermediate commit references a name a later one changes. All 13 commits still typecheck individually.

The contract-bearing renames

These were free to change now and expensive after release, which is why they went in this pass:

Before After
not_the_workspace_owner not_the_team_owner
workspace_limit_reached team_limit_reached
workspace_deleted team_deleted
max_workspaces_per_user max_teams_per_user
max_seats_per_workspace max_seats_per_team
disabled_by_workspace disabled_by_team
createWorkspace / updateWorkspace / deleteWorkspace createTeam / updateTeam / deleteTeam

Plus twoWorkspaces.ts β†’ twoTeams.ts, shareWorkspaces.js β†’ shareTeams.js, the teams-workspace-* CSS classes, and the team_workspace_deleted email template, which the mechanical pass turned into the stutter team_team_deleted and is now team_closed.

Scoped, and checked

"workspace" means other things in this repo. These were deliberately left alone, and verified untouched:

  • kvShares.ts and the KV integration tests, where workspace:abc: is a key prefix in an example, not a team
  • Events.md and KV.md, which use it the same way
  • UIPermissionDialog tests, same
  • package.json / webpack/BaseConfig.cjs / .gitignore, where it is npm workspaces

Audited three ways: every changed line mentions team or workspace (no collateral edits), no stutters or mangled words (team_team, teamspace, …), and both old config keys are gone with the new ones consistent across all 12 sites that read them.

One thing not renamed

The git branch names still say workspace (put-1740-1762-workspace-gui). Renaming a branch changes a PR's head ref and closes the PR, so they stay as they are β€” they are refs, not product surface.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1732-hard-delete-on-request branch from de223f1 to 1973243 Compare September 4, 2026 14:31
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1732-hard-delete-on-request branch from 1973243 to fbd43ad Compare September 4, 2026 15:06
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1732-hard-delete-on-request branch from fbd43ad to 1c731f9 Compare September 4, 2026 15:27
A disabled account persists indefinitely. Removing it is an explicit request,
never a timer, and it is refused on a live account with
`account_must_be_disabled_first` β€” which puts a reversible step in front of the
only irreversible operation in the feature.

The audit row is written before `cascadeDelete` runs. The FKs are ON DELETE SET
NULL and the `_keep` columns carry the identifiers, so the record of what was
done survives the account it names.

No second billing emit here: `cascadeDelete` already captures the seat and
fires `team.account.deleted` through UserAccountService, and emitting again
would close the storage charge twice. Disabling closed the per-account charge;
this closes the storage one, and it is the only thing that does.

There is no restore window, and none was wanted: the reversible step already
exists earlier at disable, a disabled account costs only the bytes it holds so
nothing pressures a hasty delete, and a restore promise means retaining data
the team explicitly asked to be rid of.

Published in rate-limits-and-quotas.md alongside the team-deletion note,
since the two are easy to confuse and only one of them frees a seat.

Closes PUT-1732.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1732-hard-delete-on-request branch from 1c731f9 to 8c939a9 Compare September 4, 2026 19:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants