Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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
46 changes: 46 additions & 0 deletions app/Actions/Account/AccountsRequiringCancel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use App\Models\User;
use Illuminate\Support\Collection;

class AccountsRequiringCancel
{
/**
* Accounts whose Stripe subscription must be canceled before tearing down
* $user (and, for owners, force-deleting remaining members' personal leftovers).
*
* Order matters for partial-failure safety: member-owned personals first,
* the shared account last. If a personal cancel fails, the shared sub is
* still intact and local delete aborts cleanly. Ended personals no-op on retry.
*
* @return Collection<int, Account>
*/
public static function forDeletingUser(User $user, ?Account $account, bool $isOwner): Collection
{
if ($isOwner && $account) {
$memberIds = User::query()
->where('account_id', $account->id)
->where('id', '!=', $user->id)
->pluck('id');

$memberOwnedAccounts = Account::query()
->whereIn('owner_id', $memberIds)
->where('id', '!=', $account->id)
->get();

return $memberOwnedAccounts
->concat([$account])
->unique('id')
->values();
}

return Account::query()
->where('owner_id', $user->id)
->get();
}
}
37 changes: 37 additions & 0 deletions app/Actions/Account/CancelAccountSubscription.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Actions\Account;

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

class CancelAccountSubscription
{
/**
* Cancel any non-ended named Stripe subscription 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;
}
}
}
31 changes: 31 additions & 0 deletions app/Actions/Account/CancelAccounts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use Illuminate\Support\Collection;

class CancelAccounts
{
/**
* Cancel Stripe for each account in order. Stops on the first failure.
*
* Callers should order accounts so a mid-loop failure leaves the most
* important subscription intact (e.g. member personals before the shared account).
*
* @param Collection<int, Account>|iterable<int, Account> $accounts
* @return bool false when any cancel failed — local teardown must not proceed
*/
public static function execute(iterable $accounts): bool
{
foreach ($accounts as $account) {
if (! CancelAccountSubscription::execute($account)) {
return false;
}
}

return true;
}
}
67 changes: 67 additions & 0 deletions app/Actions/Account/DeleteAccount.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Actions\User\ReassignCurrentWorkspace;
use App\Actions\User\SettleStrandedMember;
use App\Actions\Workspace\PurgeWorkspace;
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
use App\Models\Workspace;

class DeleteAccount
{
/**
* Tear down a shared account owned by $owner: workspaces, invites, and
* force-delete remaining members. Does not delete the owner user row.
*
* @return list<string> media paths for DeleteOrphanedMediaFiles after commit
*/
public static function execute(Account $account, User $owner): array
{
$mediaPaths = [];

$owner->update(['current_workspace_id' => null]);

Workspace::query()
->where('account_id', $account->id)
->get()
->each(function (Workspace $workspace) use ($owner, &$mediaPaths): void {
ReassignCurrentWorkspace::awayFromWorkspace(
$workspace,
exceptUserId: $owner->id,
);

$mediaPaths = [
...$mediaPaths,
...PurgeWorkspace::execute($workspace),
];
});

$owner->workspaces()->detach();

Invite::query()->where('account_id', $account->id)->delete();

$settled = SettleStrandedMember::forceDeleteMembers(
$account,
exceptUserId: $owner->id,
);

$mediaPaths = [
...$mediaPaths,
...$settled->mediaPaths,
];

$owner->update(['account_id' => null]);

if (Account::query()->whereKey($account->id)->exists()) {
$account->subscriptions()->delete();
$account->delete();
}

return $mediaPaths;
}
}
76 changes: 76 additions & 0 deletions app/Actions/Account/DeleteEmptyOwnedAccounts.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace App\Actions\Account;

use App\Models\Account;
use App\Models\User;

class DeleteEmptyOwnedAccounts
{
/**
* Cancel any Stripe subscription, then delete accounts the user owns that
* have no workspaces. Prefer calling outside a held DB lock.
*
* Skips an account when Stripe cancel fails so we never drop the local row
* while billing continues remotely.
*
* @return list<string> IDs of accounts that were deleted
*/
public static function execute(
User $user,
?string $onlyAccountId = null,
?string $exceptAccountId = null,
): array {
$ids = Account::query()
->where('owner_id', $user->id)
->when(
$onlyAccountId,
fn ($query) => $query->whereKey($onlyAccountId),
)
->when(
$exceptAccountId,
fn ($query) => $query->where('id', '!=', $exceptAccountId),
)
->whereDoesntHave('workspaces')
->pluck('id')
->all();

return self::executeByIds($ids, $user);
}

/**
* Cancel Stripe then delete the given empty accounts (by id).
* Safe to call after a lock is released — including after the owner user
* row was deleted (owner_id may already be null via FK).
*
* @param list<string> $accountIds
* @return list<string> IDs of accounts that were deleted
*/
public static function executeByIds(array $accountIds, ?User $user = null): array
{
$deleted = [];

foreach ($accountIds as $accountId) {
$orphan = Account::query()->find($accountId);

if (! $orphan || $orphan->workspaces()->exists()) {
continue;
}

if (! CancelAccountSubscription::execute($orphan)) {
continue;
}

if ($user && $user->account_id === $orphan->id) {
$user->update(['account_id' => null]);
}

$orphan->delete();
$deleted[] = $orphan->id;
}

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

declare(strict_types=1);

namespace App\Actions\Account;

use App\Actions\Workspace\PurgeWorkspace;
use App\Models\Account;
use App\Models\User;
use App\Models\Workspace;

class PurgeOwnedAccounts
{
/**
* Tear down every account the user owns (optionally skipping one), including
* their workspaces. Returns media paths for post-commit file cleanup.
*
* @return list<string>
*/
public static function execute(User $user, ?Account $except = null): array
{
$mediaPaths = [];

Account::query()
->where('owner_id', $user->id)
->when(
$except,
fn ($query) => $query->where('id', '!=', $except->id),
)
->get()
->each(function (Account $owned) use ($user, &$mediaPaths): void {
Workspace::query()
->where('account_id', $owned->id)
->get()
->each(function (Workspace $workspace) use (&$mediaPaths): void {
$mediaPaths = [
...$mediaPaths,
...PurgeWorkspace::execute($workspace),
];
});

if ($user->account_id === $owned->id) {
$user->update(['account_id' => null]);
}

$owned->delete();
});

return $mediaPaths;
}
}
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'));
}
}
}
Loading