Purge user functionality

This commit is contained in:
David Bomba
2026-01-12 17:01:11 +11:00
parent 2b525ad279
commit 250549f78d
6 changed files with 573 additions and 26 deletions

View File

@@ -12,31 +12,32 @@
namespace App\Http\Controllers;
use App\Models\User;
use App\Utils\Ninja;
use App\Models\CompanyUser;
use App\Factory\UserFactory;
use App\Filters\UserFilters;
use Illuminate\Http\Response;
use App\Utils\Traits\MakesHash;
use App\Events\User\UserWasCreated;
use App\Events\User\UserWasDeleted;
use App\Events\User\UserWasUpdated;
use App\Factory\UserFactory;
use App\Filters\UserFilters;
use App\Http\Controllers\Traits\VerifiesUserEmail;
use App\Http\Requests\User\BulkUserRequest;
use App\Http\Requests\User\CreateUserRequest;
use App\Http\Requests\User\DestroyUserRequest;
use App\Http\Requests\User\DetachCompanyUserRequest;
use App\Http\Requests\User\DisconnectUserMailerRequest;
use App\Http\Requests\User\EditUserRequest;
use App\Http\Requests\User\ReconfirmUserRequest;
use App\Http\Requests\User\ShowUserRequest;
use App\Http\Requests\User\StoreUserRequest;
use App\Http\Requests\User\UpdateUserRequest;
use App\Jobs\Company\CreateCompanyToken;
use App\Jobs\User\UserEmailChanged;
use App\Models\CompanyUser;
use App\Models\User;
use App\Repositories\UserRepository;
use App\Transformers\UserTransformer;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use Illuminate\Http\Response;
use App\Jobs\Company\CreateCompanyToken;
use App\Http\Requests\User\BulkUserRequest;
use App\Http\Requests\User\EditUserRequest;
use App\Http\Requests\User\ShowUserRequest;
use App\Http\Requests\User\PurgeUserRequest;
use App\Http\Requests\User\StoreUserRequest;
use App\Http\Requests\User\CreateUserRequest;
use App\Http\Requests\User\UpdateUserRequest;
use App\Http\Requests\User\DestroyUserRequest;
use App\Http\Requests\User\ReconfirmUserRequest;
use App\Http\Controllers\Traits\VerifiesUserEmail;
use App\Http\Requests\User\DetachCompanyUserRequest;
use App\Http\Requests\User\DisconnectUserMailerRequest;
/**
* Class UserController.
@@ -350,4 +351,12 @@ class UserController extends BaseController
}
public function purge(PurgeUserRequest $request, User $user)
{
$this->user_repo->purge($user, auth()->user());
return response()->noContent();
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\User;
use App\Http\Requests\Request;
class PurgeUserRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->user()->isOwner() && auth()->user()->id !== $this->user->id;
}
}

View File

@@ -351,6 +351,196 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(Client::class);
}
public function activities(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Activity::class);
}
public function bank_integrations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(BankIntegration::class)->withTrashed();
}
public function bank_transaction_rules(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(BankTransactionRule::class)->withTrashed();
}
public function bank_transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(BankTransaction::class)->withTrashed();
}
public function client_contacts(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ClientContact::class)->withTrashed();
}
public function company_gateways(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(CompanyGateway::class)->withTrashed();
}
public function company_ledgers(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(CompanyLedger::class);
}
public function company_tokens(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(CompanyToken::class)->withTrashed();
}
public function credit_invitations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(CreditInvitation::class)->withTrashed();
}
public function credits(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Credit::class)->withTrashed();
}
public function designs(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Design::class)->withTrashed();
}
public function expense_categories(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(ExpenseCategory::class)->withTrashed();
}
public function expenses(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Expense::class)->withTrashed();
}
public function group_settings(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(GroupSetting::class)->withTrashed();
}
public function invoice_invitations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(InvoiceInvitation::class)->withTrashed();
}
public function invoices(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Invoice::class)->withTrashed();
}
public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Location::class)->withTrashed();
}
public function payment_terms(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PaymentTerm::class)->withTrashed();
}
public function payments(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Payment::class)->withTrashed();
}
public function products(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Product::class)->withTrashed();
}
public function projects(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Project::class)->withTrashed();
}
public function purchase_order_invitations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrderInvitation::class)->withTrashed();
}
public function purchase_orders(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(PurchaseOrder::class)->withTrashed();
}
public function quote_invitations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(QuoteInvitation::class)->withTrashed();
}
public function quotes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Quote::class)->withTrashed();
}
public function recurring_expenses(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(RecurringExpense::class)->withTrashed();
}
public function recurring_invoice_invitations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(RecurringInvoiceInvitation::class)->withTrashed();
}
public function recurring_invoices(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(RecurringInvoice::class)->withTrashed();
}
public function recurring_quotes(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(RecurringQuote::class)->withTrashed();
}
public function recurring_quote_invitations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(RecurringQuoteInvitation::class)->withTrashed();
}
public function schedules(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Scheduler::class)->withTrashed();
}
public function system_logs(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(SystemLog::class)->withTrashed();
}
public function tasks(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Task::class)->withTrashed();
}
public function task_statuses(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(TaskStatus::class)->withTrashed();
}
public function tax_rates(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(TaxRate::class)->withTrashed();
}
public function vendor_contacts(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(VendorContact::class)->withTrashed();
}
public function vendors(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Vendor::class)->withTrashed();
}
public function webhooks(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(Webhook::class)->withTrashed();
}
/**
* Returns a comma separated list of user permissions.
*

View File

@@ -12,17 +12,33 @@
namespace App\Repositories;
use App\DataMapper\CompanySettings;
use App\Events\User\UserWasArchived;
use App\Events\User\UserWasDeleted;
use App\Events\User\UserWasRestored;
use App\Jobs\Company\CreateCompanyToken;
use App\Models\CompanyUser;
use App\Models\Task;
use App\Models\User;
use App\Utils\Ninja;
use App\Utils\Traits\MakesHash;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Vendor;
use App\Models\Expense;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Product;
use App\Models\Project;
use App\Models\CompanyUser;
use Illuminate\Http\Request;
use App\Models\PurchaseOrder;
use App\Models\RecurringQuote;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringExpense;
use App\Models\RecurringInvoice;
use Illuminate\Support\Facades\DB;
use App\DataMapper\CompanySettings;
use App\Events\User\UserWasDeleted;
use App\Events\User\UserWasArchived;
use App\Events\User\UserWasRestored;
use App\Repositories\BaseRepository;
use Illuminate\Support\Facades\Hash;
use App\Jobs\Company\CreateCompanyToken;
/**
* UserRepository.
@@ -242,4 +258,51 @@ class UserRepository extends BaseRepository
});
}
}
public function purge(User $user, User $new_owner_user): void
{
\DB::transaction(function () use ($user, $new_owner_user) {
// Relations to transfer user_id to new owner
$allRelations = [
'activities', 'bank_integrations', 'bank_transaction_rules',
'bank_transactions', 'client_contacts', 'company_gateways',
'company_ledgers', 'company_tokens', 'credit_invitations',
'designs', 'expense_categories', 'group_settings',
'invoice_invitations', 'locations', 'payment_terms',
'quote_invitations', 'purchase_order_invitations',
'recurring_invoice_invitations', 'recurring_quote_invitations',
'schedules', 'system_logs', 'task_statuses', 'tax_rates',
'vendor_contacts', 'webhooks',
// Models that also have assigned_user_id
'clients', 'invoices', 'credits', 'quotes', 'payments',
'expenses', 'tasks', 'projects', 'vendors', 'products',
'purchase_orders', 'recurring_invoices', 'recurring_expenses',
'recurring_quotes',
];
foreach ($allRelations as $relation) {
$user->{$relation}()->update(['user_id' => $new_owner_user->id]);
}
// Models with both user_id and assigned_user_id
$modelsWithAssignedUser = [
Client::class, Invoice::class, Credit::class, Quote::class,
Payment::class, Expense::class, Task::class, Project::class,
Vendor::class, Product::class, PurchaseOrder::class,
RecurringInvoice::class, RecurringExpense::class, RecurringQuote::class,
];
foreach ($modelsWithAssignedUser as $model) {
// Null out assigned_user_id
$model::withTrashed()
->where('assigned_user_id', $user->id)
->update(['assigned_user_id' => null]);
}
$user->forceDelete();
});
}
}

View File

@@ -436,6 +436,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local
Route::post('/users/{user}/disconnect_mailer', [UserController::class, 'disconnectOauthMailer']);
Route::post('/users/{user}/disconnect_oauth', [UserController::class, 'disconnectOauth']);
Route::post('/user/{user}/reconfirm', [UserController::class, 'reconfirm']);
Route::post('/user/{user}/purge', [UserController::class, 'purge'])->middleware('password_protected');
Route::resource('webhooks', WebhookController::class);
Route::post('webhooks/bulk', [WebhookController::class, 'bulk'])->name('webhooks.bulk');

View File

@@ -755,4 +755,260 @@ class UserTest extends TestCase
$this->assertFalse($arr['data']['company_user']['is_owner']);
$this->assertEquals($arr['data']['company_user']['permissions'], 'create_invoice,create_invoice');
}
public function testPurgeUserTransfersEntities()
{
// Create account and owner user
$account = Account::factory()->create([
'hosted_client_count' => 1000,
'hosted_company_count' => 1000,
]);
$account->num_users = 3;
$account->save();
$owner_user = User::factory()->create([
'account_id' => $account->id,
'email' => \Illuminate\Support\Str::random(32)."@example.com",
]);
$settings = CompanySettings::defaults();
$company = Company::factory()->create([
'account_id' => $account->id,
'settings' => $settings,
]);
$owner_user->companies()->attach($company->id, [
'account_id' => $account->id,
'is_owner' => 1,
'is_admin' => 1,
'is_locked' => 0,
'permissions' => '',
'notifications' => CompanySettings::notificationAdminDefaults(),
'settings' => null,
]);
// Create secondary user to be purged
$secondary_user = User::factory()->create([
'account_id' => $account->id,
'email' => \Illuminate\Support\Str::random(32)."@example.com",
]);
$secondary_user->companies()->attach($company->id, [
'account_id' => $account->id,
'is_owner' => 0,
'is_admin' => 1,
'is_locked' => 0,
'permissions' => '',
'notifications' => CompanySettings::notificationAdminDefaults(),
'settings' => null,
]);
// Create a client owned by secondary user
$client = \App\Models\Client::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'assigned_user_id' => $secondary_user->id,
]);
// Create client contact
$client_contact = \App\Models\ClientContact::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'is_primary' => true,
]);
// Create invoice owned by secondary user
$invoice = \App\Models\Invoice::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
'status_id' => \App\Models\Invoice::STATUS_DRAFT,
]);
$invoice = $invoice->service()->createInvitations()->markSent()->save();
// Create credit owned by secondary user
$credit = \App\Models\Credit::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
'status_id' => \App\Models\Credit::STATUS_DRAFT,
]);
$credit = $credit->service()->createInvitations()->markSent()->save();
// Create quote owned by secondary user
$quote = \App\Models\Quote::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
'status_id' => \App\Models\Quote::STATUS_DRAFT,
]);
$quote = $quote->service()->createInvitations()->markSent()->save();
// Create recurring invoice owned by secondary user
$recurring_invoice = \App\Models\RecurringInvoice::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
'status_id' => \App\Models\RecurringInvoice::STATUS_DRAFT,
]);
$recurring_invoice = $recurring_invoice->service()->createInvitations()->start()->save();
// Create expense owned by secondary user
$expense = \App\Models\Expense::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'assigned_user_id' => $secondary_user->id,
]);
// Create task owned by secondary user
$task = \App\Models\Task::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
]);
// Create vendor owned by secondary user
$vendor = \App\Models\Vendor::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'assigned_user_id' => $secondary_user->id,
]);
// Create vendor contact
$vendor_contact = \App\Models\VendorContact::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'vendor_id' => $vendor->id,
'is_primary' => true,
]);
// Create product owned by secondary user
$product = \App\Models\Product::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'assigned_user_id' => $secondary_user->id,
]);
// Create project owned by secondary user
$project = \App\Models\Project::factory()->create([
'user_id' => $secondary_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
]);
// Create an entity owned by owner but assigned to secondary user
$invoice_assigned_only = \App\Models\Invoice::factory()->create([
'user_id' => $owner_user->id,
'company_id' => $company->id,
'client_id' => $client->id,
'assigned_user_id' => $secondary_user->id,
]);
$invoice = $invoice->load('invitations');
$this->assertCount(1, $invoice->invitations);
$this->assertCount(1, $recurring_invoice->invitations);
// Store IDs for later assertions
$secondary_user_id = $secondary_user->id;
$client_id = $client->id;
$client_contact_id = $client_contact->id;
$invoice_id = $invoice->id;
$invoice_invitation_id = $invoice->invitations()->first()->id;
$credit_id = $credit->id;
$credit_invitation_id = $credit->invitations()->first()->id;
$quote_id = $quote->id;
$quote_invitation_id = $quote->invitations()->first()->id;
$recurring_invoice_id = $recurring_invoice->id;
$expense_id = $expense->id;
$task_id = $task->id;
$vendor_id = $vendor->id;
$vendor_contact_id = $vendor_contact->id;
$product_id = $product->id;
$project_id = $project->id;
$invoice_assigned_only_id = $invoice_assigned_only->id;
// Perform the purge
$user_repo = new UserRepository();
$user_repo->purge($secondary_user, $owner_user);
// Assert secondary user is deleted
$this->assertNull(User::find($secondary_user_id));
// Assert all entities are now owned by owner user
$client = \App\Models\Client::find($client_id);
$this->assertEquals($owner_user->id, $client->user_id);
$this->assertNull($client->assigned_user_id);
// Assert client contact user_id updated
$client_contact = \App\Models\ClientContact::find($client_contact_id);
$this->assertEquals($owner_user->id, $client_contact->user_id);
$invoice = \App\Models\Invoice::find($invoice_id);
$this->assertEquals($owner_user->id, $invoice->user_id);
$this->assertNull($invoice->assigned_user_id);
// Assert invoice invitation user_id updated
$invoice_invitation = \App\Models\InvoiceInvitation::find($invoice_invitation_id);
$this->assertEquals($owner_user->id, $invoice_invitation->user_id);
$credit = \App\Models\Credit::find($credit_id);
$this->assertEquals($owner_user->id, $credit->user_id);
$this->assertNull($credit->assigned_user_id);
// Assert credit invitation user_id updated
$credit_invitation = \App\Models\CreditInvitation::find($credit_invitation_id);
$this->assertEquals($owner_user->id, $credit_invitation->user_id);
$quote = \App\Models\Quote::find($quote_id);
$this->assertEquals($owner_user->id, $quote->user_id);
$this->assertNull($quote->assigned_user_id);
// Assert quote invitation user_id updated
$quote_invitation = \App\Models\QuoteInvitation::find($quote_invitation_id);
$this->assertEquals($owner_user->id, $quote_invitation->user_id);
$recurring_invoice = \App\Models\RecurringInvoice::find($recurring_invoice_id);
$this->assertEquals($owner_user->id, $recurring_invoice->user_id);
$this->assertNull($recurring_invoice->assigned_user_id);
$expense = \App\Models\Expense::find($expense_id);
$this->assertEquals($owner_user->id, $expense->user_id);
$this->assertNull($expense->assigned_user_id);
$task = \App\Models\Task::find($task_id);
$this->assertEquals($owner_user->id, $task->user_id);
$this->assertNull($task->assigned_user_id);
$vendor = \App\Models\Vendor::find($vendor_id);
$this->assertEquals($owner_user->id, $vendor->user_id);
$this->assertNull($vendor->assigned_user_id);
// Assert vendor contact user_id updated
$vendor_contact = \App\Models\VendorContact::find($vendor_contact_id);
$this->assertEquals($owner_user->id, $vendor_contact->user_id);
$product = \App\Models\Product::find($product_id);
$this->assertEquals($owner_user->id, $product->user_id);
$this->assertNull($product->assigned_user_id);
$project = \App\Models\Project::find($project_id);
$this->assertEquals($owner_user->id, $project->user_id);
$this->assertNull($project->assigned_user_id);
// Assert entity owned by owner but assigned to secondary now has null assigned_user_id
$invoice_assigned_only = \App\Models\Invoice::find($invoice_assigned_only_id);
$this->assertEquals($owner_user->id, $invoice_assigned_only->user_id);
$this->assertNull($invoice_assigned_only->assigned_user_id);
}
}