Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
400daa4
Allow owners and admins to delete workspaces from settings.
paulocastellano Jul 26, 2026
642d7d8
Drop redundant canDelete prop from workspace settings.
paulocastellano Jul 26, 2026
e836440
Extract workspace delete danger zone into DeleteWorkspace component.
paulocastellano Jul 26, 2026
69d3f36
Clarify workspace delete billing copy across locales.
paulocastellano Jul 26, 2026
d292ec3
Match workspace delete card to the delete-account settings pattern.
paulocastellano Jul 26, 2026
7f34cfb
Harden workspace and account deletion around shared members.
paulocastellano Jul 26, 2026
124a7b8
Harden workspace delete: owner-only billing impact and safer member r…
paulocastellano Jul 26, 2026
9dab9ed
Fix workspace delete review findings.
cursoragent Jul 29, 2026
6ff1d1f
Harden invite accept and account delete edge cases.
cursoragent Jul 29, 2026
714294a
Fix remaining invite redirect and media cleanup edge cases.
cursoragent Jul 29, 2026
7fba43d
Fix invite current-workspace and account-delete edge cases.
cursoragent Jul 29, 2026
5f2d614
Fix Stripe-failure media leak and invite cross-account redirect.
cursoragent Jul 29, 2026
db6f03b
Never set cross-account current workspace on member rehome.
cursoragent Jul 29, 2026
f641f19
Sync Stripe workspace quantity when account delete billing fails.
cursoragent Jul 29, 2026
8349d42
Prune account invites when owner delete wipes workspaces.
cursoragent Jul 29, 2026
de2f039
Extract DeleteWorkspaceMedia to purge workspace media rows.
cursoragent Jul 30, 2026
c29ccdf
Redirect to calendar after deleting a workspace with a fallback.
cursoragent Jul 30, 2026
ce44795
Use Wayfinder for invite redirect and logo home links.
cursoragent Jul 30, 2026
bbd20e4
Use Wayfinder home() for AcceptInvite logo link.
cursoragent Jul 30, 2026
8a07804
Extract AcceptInvite title and description into computeds.
cursoragent Jul 30, 2026
23ced8f
Fix lazy-loading crash when deleting a workspace.
cursoragent Jul 30, 2026
498ad73
Avoid isAccountOwner during workspace delete fallback.
cursoragent Jul 30, 2026
cad1607
Add tests for DeleteWorkspace functionality
paulocastellano Jul 31, 2026
a747c11
Refactor member removal process to delete or restore stranded members
paulocastellano Jul 31, 2026
7a7f435
Enhance member removal and media management during account deletion
paulocastellano Jul 31, 2026
af90870
Enhance user account deletion process with force delete option
paulocastellano Jul 31, 2026
1970330
Extract shared delete/invite actions out of fat controllers.
paulocastellano Jul 31, 2026
4fc265f
Harden delete/invite invariants and replace invite string outcomes.
paulocastellano Jul 31, 2026
cd08377
Merge remote-tracking branch 'origin/main' into fix/207-delete-workspace
paulocastellano Jul 31, 2026
8429f68
Polish delete/invite teardown APIs and cancel Stripe on empty accounts.
paulocastellano Jul 31, 2026
95eb7d8
Finish stranded teardown craft: settle outside locks, clearer names.
paulocastellano Jul 31, 2026
913a2f6
Harden multi-account Stripe cancel order and typed stranded settlements.
paulocastellano Jul 31, 2026
2ae2a9b
Reuse strandedMemberOnSharedAccount across delete/invite feature tests.
paulocastellano Jul 31, 2026
5a5b0ee
Lock the account row during owner account teardown.
paulocastellano Aug 1, 2026
0f895e8
Drop personal-account restore when leaving a shared account.
paulocastellano Aug 1, 2026
0360e29
Close the account model and consolidate teardown actions.
paulocastellano Aug 1, 2026
6b9b99a
Remove orphaned members.errors.already_member translation key.
paulocastellano Aug 1, 2026
8ca9b82
Block invitees from creating a workspace on the invite shell.
paulocastellano Aug 1, 2026
043777a
Tighten stranded-member fixtures to the closed-account model.
paulocastellano Aug 1, 2026
efd33ac
Bind invite registration to the invited email.
paulocastellano Aug 1, 2026
5acfeb7
Move register validation into RegisterRequest.
paulocastellano Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions app/Actions/Account/CancelAccountSubscription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use Illuminate\Support\Facades\Log;
use Throwable;

class CancelAccountSubscription
{
/**
* Cancel the non-ended named Stripe subscription for an account before
* local teardown.
*
* @return bool false when Stripe cancel fails (nothing local should be deleted)
*/
public static function execute(Account $account): bool
{
try {
$subscription = $account->subscription(Account::SUBSCRIPTION_NAME);

if ($subscription && ! $subscription->ended()) {
$subscription->cancelNow();
}

return true;
} catch (Throwable $e) {
Log::warning('Failed to cancel Stripe subscription during account delete', [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);

return false;
}
}
}
34 changes: 34 additions & 0 deletions app/Actions/Auth/LogoutAndInvalidateSession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Actions\Auth;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class LogoutAndInvalidateSession
{
/**
* Log the user out while the row still exists, then invalidate the session.
* SessionGuard cycles the remember token via save() — calling this after
* $user->delete() can re-insert the deleted user.
*
* @param array{banner?: string|null, bannerStyle?: string|null}|null $reflash
*/
public static function execute(?Request $request = null, ?array $reflash = null): void
{
$request ??= request();

Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();

$banner = data_get($reflash, 'banner');

if ($banner !== null) {
session()->flash('flash.banner', $banner);
session()->flash('flash.bannerStyle', data_get($reflash, 'bannerStyle', 'info'));
}
}
}
84 changes: 84 additions & 0 deletions app/Actions/Invite/AcceptInvite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Actions\Invite;

use App\Actions\Account\CancelAccountSubscription;
use App\Enums\Invite\Result;
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class AcceptInvite
{
public static function execute(User $user, Invite $invite): Result
{
if ($invite->email !== $user->email) {
return Result::WrongEmail;
}

$previousAccountId = null;

$result = DB::transaction(function () use ($user, $invite, &$previousAccountId): Result {
$lockedInvite = Invite::query()
->whereKey($invite->id)
->lockForUpdate()
->first();

if (! $lockedInvite) {
return Result::Gone;
}

if ($lockedInvite->accepted_at !== null) {
return Result::AlreadyAccepted;
}

$workspaces = ResolveInviteWorkspaces::execute($lockedInvite);

if ($workspaces->isEmpty()) {
$lockedInvite->delete();

return Result::Gone;
}

// Already on the account: still attach any missing workspace memberships.
if ($user->account_id === $lockedInvite->account_id) {
AttachInviteWorkspaces::execute($user, $lockedInvite, $workspaces);

$lockedInvite->update(['accepted_at' => now()]);

return Result::AlreadyMember;
}

$previousAccountId = $user->account_id;

$user->update(['account_id' => $lockedInvite->account_id]);
$user->refresh();

AttachInviteWorkspaces::execute($user, $lockedInvite, $workspaces);

$lockedInvite->update(['accepted_at' => now()]);

return Result::Accepted;
});

// Invite signup leaves an empty personal account shell. Drop it after
// commit so Stripe cancel is not held inside the invite lock. Members
// never own a non-empty account, so there is nothing else to tear down.
if ($result === Result::Accepted && $previousAccountId) {
$shell = Account::query()
->whereKey($previousAccountId)
->where('owner_id', $user->id)
->whereDoesntHave('workspaces')
->first();

if ($shell && CancelAccountSubscription::execute($shell)) {
$shell->delete();
}
}

return $result;
}
}
51 changes: 51 additions & 0 deletions app/Actions/Invite/AttachInviteWorkspaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace App\Actions\Invite;

use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Collection;

class AttachInviteWorkspaces
{
/**
* @param Collection<int, Workspace> $workspaces
*/
public static function execute(User $user, Invite $invite, Collection $workspaces): void
{
foreach ($workspaces as $workspace) {
$alreadyMember = $workspace->members()
->where('users.id', $user->id)
->exists();

// Never overwrite an existing pivot role (avoids demoting admins).
if (! $alreadyMember) {
$workspace->members()->attach($user->id, [
'role' => $invite->role->value,
]);
}

// Accept often switches account_id while an old personal workspace is
// still current — always land on a workspace of the invite account.
if (! self::currentWorkspaceBelongsToAccount($user)) {
$user->update(['current_workspace_id' => $workspace->id]);
$user->refresh();
}
}
}

public static function currentWorkspaceBelongsToAccount(User $user): bool
{
if (! $user->current_workspace_id || ! $user->account_id) {
return false;
}

return $user->workspaces()
->where('workspaces.id', $user->current_workspace_id)
->where('workspaces.account_id', $user->account_id)
->exists();
}
}
38 changes: 38 additions & 0 deletions app/Actions/Invite/DeclineInvite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Actions\Invite;

use App\Enums\Invite\Result;
use App\Models\Invite;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class DeclineInvite
{
public static function execute(User $user, Invite $invite): Result
{
if ($invite->email !== $user->email) {
return Result::WrongEmail;
}

$workspacesGone = DB::transaction(function () use ($invite): bool {
$lockedInvite = Invite::query()
->whereKey($invite->id)
->lockForUpdate()
->first();

if (! $lockedInvite) {
return true;
}

$workspaces = ResolveInviteWorkspaces::execute($lockedInvite);
$lockedInvite->delete();

return $workspaces->isEmpty();
});

return $workspacesGone ? Result::Gone : Result::Declined;
}
}
44 changes: 43 additions & 1 deletion app/Actions/Invite/RemoveMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,54 @@

namespace App\Actions\Invite;

use App\Actions\User\ReassignCurrentWorkspace;
use App\Actions\User\SettleStrandedMember;
use App\Actions\User\StrandedSettlement;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\DB;

class RemoveMember
{
public static function execute(Workspace $workspace, string $userId): void
{
$workspace->members()->detach($userId);
$settlement = StrandedSettlement::none();

DB::transaction(function () use ($workspace, $userId, &$settlement): void {
$account = $workspace->account;

// Serialize with DeleteWorkspace / other RemoveMember calls on this
// account so concurrent removals cannot skip stranded cleanup.
if ($account?->id) {
Account::query()->whereKey($account->id)->lockForUpdate()->first();
}

$user = User::query()->find($userId);

$workspace->members()->detach($userId);

if (! $user) {
return;
}

$user->refresh();

if ($user->current_workspace_id === $workspace->id) {
ReassignCurrentWorkspace::forUserAwayFrom($user, $workspace);
$user->refresh();
}

// Last membership on this shared account — delete the invitee.
if (
$account
&& $user->account_id === $account->id
&& $user->id !== $account->owner_id
) {
$settlement = SettleStrandedMember::execute($user, $account);
}
});

$settlement->flush();
}
}
34 changes: 34 additions & 0 deletions app/Actions/Invite/ResolveInviteWorkspaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Actions\Invite;

use App\Models\Invite;
use App\Models\Workspace;
use Illuminate\Support\Collection;

class ResolveInviteWorkspaces
{
/**
* Workspaces listed on the invite that still exist on the invite's account.
*
* @return Collection<int, Workspace>
*/
public static function execute(Invite $invite): Collection
{
$ids = collect(data_get($invite, 'workspaces', []))
->filter()
->values()
->all();

if ($ids === []) {
return collect();
}

return Workspace::query()
->whereIn('id', $ids)
->where('account_id', $invite->account_id)
->get();
}
}
Loading