From a2d5a8affd34daab998f456b6e106b0a1988f377 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 17 Jul 2026 20:10:25 +0000 Subject: [PATCH] Require package reinstall after industry change and wipe old queues. Changing industry now flags a pending reinstall with an app banner; reinstall clears queues, tickets, and service points before applying the new template. Co-authored-by: Cursor --- .../Controllers/Qms/SettingsController.php | 53 ++++++---- .../Qms/Industry/IndustryPackageInstaller.php | 100 +++++++++++++++++- resources/views/partials/flash.blade.php | 32 ++++++ resources/views/qms/settings/edit.blade.php | 28 +++-- tests/Feature/IndustryPackageTest.php | 92 ++++++++++++++++ 5 files changed, 276 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/Qms/SettingsController.php b/app/Http/Controllers/Qms/SettingsController.php index 6dbe493..610a288 100644 --- a/app/Http/Controllers/Qms/SettingsController.php +++ b/app/Http/Controllers/Qms/SettingsController.php @@ -35,6 +35,8 @@ class SettingsController extends Controller ?? data_get($organization->settings, 'industry') ?? 'custom'; $activePackage = $packages->get($packageKey); + $selectedIndustry = data_get($organization->settings, 'industry', $packageKey); + $selectedPackage = $packages->get($selectedIndustry) ?? $activePackage; return view('qms.settings.edit', [ 'organization' => $organization, @@ -43,7 +45,9 @@ class SettingsController extends Controller 'appointmentModes' => config('qms.appointment_modes'), 'industries' => $packages->labels(), 'activePackage' => $activePackage, + 'selectedPackage' => $selectedPackage, 'packageMeta' => data_get($organization->settings, 'industry_package'), + 'needsPackageReinstall' => IndustryPackageInstaller::needsReinstall($organization), 'hasPaidPlan' => $plans->hasPaidPlan($organization), 'isPro' => $plans->isPro($organization), 'isEnterprise' => $plans->isEnterprise($organization), @@ -82,6 +86,7 @@ class SettingsController extends Controller $settings['appointment_mode'] = $validated['appointment_mode']; $previousIndustry = $settings['industry'] ?? null; + $installedPackage = data_get($settings, 'industry_package.key'); if ($industry !== null) { $settings['industry'] = $industry; } @@ -95,6 +100,17 @@ class SettingsController extends Controller } $settings['integrations'] = $integrations; + $industryChanged = $industry !== null + && $industry !== $previousIndustry; + $outOfSyncWithPackage = $industry !== null + && is_string($installedPackage) + && $installedPackage !== '' + && $industry !== $installedPackage; + + if ($industryChanged || $outOfSyncWithPackage) { + $settings['industry_package_needs_reinstall'] = true; + } + $logoPath = $organization->logo_path; if ($request->boolean('remove_logo')) { @@ -115,28 +131,21 @@ class SettingsController extends Controller AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id); - $industryChanged = ($settings['industry'] ?? null) && ($settings['industry'] ?? null) !== $previousIndustry; $packageNote = ''; - if ($industryChanged) { - $branch = Branch::query() - ->where('organization_id', $organization->id) - ->where('is_active', true) - ->orderBy('id') - ->first(); - if ($branch) { - try { - app(IndustryPackageInstaller::class)->install($organization->fresh(), $branch, $settings['industry'], [ - 'actor_ref' => $owner, - ]); - $packageNote = ' Industry package re-provisioned.'; - } catch (\Throwable $e) { - report($e); - $packageNote = ' Settings saved, but industry package re-provision failed — try Reinstall package.'; - } - } + if ($industryChanged || $outOfSyncWithPackage) { + $label = $registry->get($industry)?->label() ?? $industry; + $packageNote = " Industry changed to {$label} — reinstall the industry package to replace queues and service points."; } - return back()->with('success', 'Settings saved.'.$packageNote); + $redirect = back()->with('success', 'Settings saved.'.$packageNote); + if ($industryChanged || $outOfSyncWithPackage) { + $redirect->with( + 'warning', + 'Reinstall the industry package to apply the new template. This will clear existing queues, tickets, and service points.' + ); + } + + return $redirect; } public function reinstallPackage(Request $request, IndustryPackageInstaller $installer): RedirectResponse @@ -159,9 +168,13 @@ class SettingsController extends Controller $result = $installer->install($organization, $branch, $key, [ 'actor_ref' => $owner, 'include_optional' => true, + 'replace' => true, ]); - $message = 'Industry package synced.'; + $message = ($result['replaced'] ?? false) + ? 'Industry package reinstalled. Previous queues, tickets, and service points were cleared.' + : 'Industry package synced.'; + if (($result['skipped_reason'] ?? null) === 'plan_queue_limit') { $message .= ' Some stages were skipped because of your plan queue limit — upgrade to Pro for the full template.'; } elseif (($result['skipped_reason'] ?? null) === 'stages_managed_by_integration') { diff --git a/app/Services/Qms/Industry/IndustryPackageInstaller.php b/app/Services/Qms/Industry/IndustryPackageInstaller.php index ea9ef0c..ce69e8b 100644 --- a/app/Services/Qms/Industry/IndustryPackageInstaller.php +++ b/app/Services/Qms/Industry/IndustryPackageInstaller.php @@ -4,9 +4,19 @@ namespace App\Services\Qms\Industry; use App\Models\Branch; use App\Models\Counter; +use App\Models\CustomerFeedback; use App\Models\Department; +use App\Models\Device; +use App\Models\DisplayScreen; use App\Models\Organization; +use App\Models\QueueAppointment; +use App\Models\QueueRule; use App\Models\ServiceQueue; +use App\Models\ServiceSession; +use App\Models\Ticket; +use App\Models\TicketEvent; +use App\Models\TicketTransfer; +use App\Models\VoiceAnnouncement; use App\Models\Workflow; use App\Models\WorkflowStep; use App\Services\Qms\AuditLogger; @@ -27,7 +37,7 @@ class IndustryPackageInstaller ) {} /** - * @param array{skip_stages?: bool, include_optional?: bool, actor_ref?: string|null} $options + * @param array{skip_stages?: bool, include_optional?: bool, actor_ref?: string|null, replace?: bool} $options * @return array{ * package: string, * departments: int, @@ -35,7 +45,8 @@ class IndustryPackageInstaller * service_points: int, * workflow_id: int|null, * skipped_stages: list, - * skipped_reason: string|null + * skipped_reason: string|null, + * replaced: bool * } */ public function install(Organization $organization, Branch $branch, ?string $packageKey = null, array $options = []): array @@ -49,6 +60,7 @@ class IndustryPackageInstaller $skipStages = (bool) ($options['skip_stages'] ?? false); $includeOptional = (bool) ($options['include_optional'] ?? true); + $replace = (bool) ($options['replace'] ?? false); $actor = $options['actor_ref'] ?? $organization->owner_ref; // Care (and similar) own clinical stage provisioning — still apply terminology. @@ -64,9 +76,18 @@ class IndustryPackageInstaller 'workflow_id' => null, 'skipped_stages' => [], 'skipped_reason' => $skipStages ? 'stages_managed_by_integration' : null, + 'replaced' => false, ]; - return DB::transaction(function () use ($organization, $branch, $package, $key, $skipStages, $includeOptional, $actor, &$result) { + return DB::transaction(function () use ($organization, $branch, $package, $key, $skipStages, $includeOptional, $replace, $actor, &$result) { + // Replacing a package wipes queues/tickets/points so the new template is clean. + // Care-managed healthcare keeps clinical queues (skip_stages). + if ($replace && ! $skipStages) { + $this->clearOperationalData($organization); + $result['replaced'] = true; + $organization->refresh(); + } + $this->applyPackageSettings($organization, $package); $departmentsByCode = []; @@ -122,6 +143,7 @@ class IndustryPackageInstaller $settings = $organization->settings ?? []; $settings['industry'] = $key; + unset($settings['industry_package_needs_reinstall']); $settings['industry_package'] = [ 'key' => $key, 'version' => (int) config('industry_packages.version', 1), @@ -152,6 +174,78 @@ class IndustryPackageInstaller }); } + /** + * Whether the selected industry differs from the installed package template. + */ + public static function needsReinstall(Organization $organization): bool + { + if ((bool) data_get($organization->settings, 'industry_package_needs_reinstall')) { + return true; + } + + $selected = data_get($organization->settings, 'industry'); + $installed = data_get($organization->settings, 'industry_package.key'); + + return is_string($selected) + && $selected !== '' + && is_string($installed) + && $installed !== '' + && $selected !== $installed; + } + + /** + * Wipe queues, service points, tickets, workflows, and related operational data. + * Keeps organization, branches, and members. + */ + public function clearOperationalData(Organization $organization): void + { + $orgId = (int) $organization->id; + $ownerRef = (string) $organization->owner_ref; + + $ticketIds = Ticket::withTrashed()->where('organization_id', $orgId)->pluck('id'); + $queueIds = ServiceQueue::withTrashed()->where('organization_id', $orgId)->pluck('id'); + $counterIds = Counter::withTrashed()->where('organization_id', $orgId)->pluck('id'); + $workflowIds = Workflow::withTrashed()->where('organization_id', $orgId)->pluck('id'); + + CustomerFeedback::query()->where('organization_id', $orgId)->delete(); + VoiceAnnouncement::query()->where('organization_id', $orgId)->delete(); + ServiceSession::query()->where('owner_ref', $ownerRef)->delete(); + TicketTransfer::query()->where('owner_ref', $ownerRef)->delete(); + TicketEvent::query()->where('owner_ref', $ownerRef)->delete(); + QueueAppointment::withTrashed()->where('organization_id', $orgId)->forceDelete(); + + if ($ticketIds->isNotEmpty()) { + Ticket::withTrashed()->whereIn('id', $ticketIds)->forceDelete(); + } + + QueueRule::query()->where('owner_ref', $ownerRef)->delete(); + + if ($workflowIds->isNotEmpty()) { + WorkflowStep::query()->whereIn('workflow_id', $workflowIds)->delete(); + Workflow::withTrashed()->whereIn('id', $workflowIds)->forceDelete(); + } + + DisplayScreen::withTrashed()->where('organization_id', $orgId)->forceDelete(); + Device::withTrashed()->where('organization_id', $orgId)->forceDelete(); + + DB::table('queue_staff_sessions')->where('owner_ref', $ownerRef)->delete(); + DB::table('queue_counter_assignments')->where('owner_ref', $ownerRef)->delete(); + + if ($counterIds->isNotEmpty()) { + DB::table('queue_counter_service_queue')->whereIn('counter_id', $counterIds)->delete(); + Counter::withTrashed()->whereIn('id', $counterIds)->forceDelete(); + } + + if ($queueIds->isNotEmpty()) { + ServiceQueue::withTrashed()->whereIn('id', $queueIds)->forceDelete(); + } + + Department::withTrashed() + ->where('owner_ref', $ownerRef) + ->whereIn('branch_id', Branch::query()->where('organization_id', $orgId)->pluck('id')) + ->forceDelete(); + } + protected function applyPackageSettings(Organization $organization, IndustryPackage $package): void { // Persisted fully after install; noop placeholder for future hooks. diff --git a/resources/views/partials/flash.blade.php b/resources/views/partials/flash.blade.php index 657ef02..60e7625 100644 --- a/resources/views/partials/flash.blade.php +++ b/resources/views/partials/flash.blade.php @@ -22,6 +22,38 @@ @endif +@auth + @php + $industryBannerOrg = app(\App\Services\Qms\OrganizationResolver::class)->resolveForUser(auth()->user()); + $industryNeedsReinstall = $industryBannerOrg + && \App\Services\Qms\Industry\IndustryPackageInstaller::needsReinstall($industryBannerOrg); + $industryCanManage = $industryNeedsReinstall + && app(\App\Services\Qms\QmsPermissions::class)->can( + app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user(), $industryBannerOrg), + 'settings.manage' + ); + $industrySelectedLabel = $industryNeedsReinstall + ? (app(\App\Services\Qms\Industry\IndustryPackageRegistry::class) + ->get((string) data_get($industryBannerOrg->settings, 'industry')) + ?->label() ?? data_get($industryBannerOrg->settings, 'industry')) + : null; + @endphp + @if ($industryCanManage && ! request()->routeIs('qms.settings.*')) +
+
+
+

Reinstall industry package required

+

+ Industry is set to {{ $industrySelectedLabel }}, but the installed queues still match the previous template. + Reinstall to clear old queues and apply the new package. +

+
+ Open settings +
+
+ @endif +@endauth + @if ($errors->any())

Please fix the following:

diff --git a/resources/views/qms/settings/edit.blade.php b/resources/views/qms/settings/edit.blade.php index d9c77d0..343a2c2 100644 --- a/resources/views/qms/settings/edit.blade.php +++ b/resources/views/qms/settings/edit.blade.php @@ -153,18 +153,34 @@ {{-- Separate form: nested forms break Save settings in browsers --}} - @if ($canManage && $activePackage) + @if ($canManage && ($selectedPackage ?? $activePackage)) + @php $templatePackage = $selectedPackage ?? $activePackage; @endphp
- + @if ($needsPackageReinstall ?? false) +
+

Industry package reinstall required

+

+ You selected {{ $templatePackage->label() }}, but the installed template still matches + {{ $activePackage?->label() ?? 'the previous industry' }}. + Reinstall to clear old queues and apply the new service points. +

+
+ @endif +

- {{ $activePackage->label() }} uses - {{ count($activePackage->stages()) }} workflow stages and - industry-specific terminology ({{ $activePackage->terminology()['ticket'] ?? 'Ticket' }}). + {{ $templatePackage->label() }} uses + {{ count($templatePackage->stages()) }} workflow stages and + industry-specific terminology ({{ $templatePackage->terminology()['ticket'] ?? 'Ticket' }}).

@csrf - +
diff --git a/tests/Feature/IndustryPackageTest.php b/tests/Feature/IndustryPackageTest.php index bc21003..c10b5be 100644 --- a/tests/Feature/IndustryPackageTest.php +++ b/tests/Feature/IndustryPackageTest.php @@ -206,4 +206,96 @@ class IndustryPackageTest extends TestCase $this->assertSame($before, ServiceQueue::where('organization_id', $org->id)->count()); $this->assertSame('patient', data_get($org->fresh()->settings, 'industry_package.ticket_entity')); } + + public function test_replace_install_clears_previous_queues_and_tickets(): void + { + $user = User::create([ + 'public_id' => 'pkg-replace-owner', + 'name' => 'Replace Owner', + 'email' => 'replace-owner@example.com', + 'password' => bcrypt('password'), + ]); + + $org = app(OrganizationResolver::class)->completeOnboarding($user, [ + 'organization_name' => 'Cafe', + 'industry' => 'restaurant', + 'appointment_mode' => 'hybrid', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + $org->update([ + 'settings' => array_merge($org->settings ?? [], [ + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + ]), + ]); + + $branch = Branch::where('organization_id', $org->id)->first(); + $oldQueue = ServiceQueue::where('organization_id', $org->id)->first(); + $this->assertNotNull($oldQueue); + + app(QueueEngine::class)->issueTicket($oldQueue, $user->public_id, [ + 'customer_name' => 'Old Ticket', + 'source' => 'reception', + ]); + $this->assertGreaterThan(0, \App\Models\Ticket::where('organization_id', $org->id)->count()); + + $result = app(IndustryPackageInstaller::class)->install($org->fresh(), $branch, 'banking', [ + 'replace' => true, + 'include_optional' => true, + ]); + + $this->assertTrue($result['replaced']); + $this->assertSame(0, \App\Models\Ticket::where('organization_id', $org->id)->count()); + $this->assertFalse( + ServiceQueue::where('organization_id', $org->id)->where('id', $oldQueue->id)->exists() + ); + $this->assertSame('banking', data_get($org->fresh()->settings, 'industry_package.key')); + $this->assertNull(data_get($org->fresh()->settings, 'industry_package_needs_reinstall')); + $this->assertGreaterThanOrEqual(3, ServiceQueue::where('organization_id', $org->id)->count()); + } + + public function test_changing_industry_requires_reinstall_without_auto_provision(): void + { + $this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class); + + $user = User::create([ + 'public_id' => 'pkg-settings-owner', + 'name' => 'Settings Owner', + 'email' => 'settings-pkg@example.com', + 'password' => bcrypt('password'), + ]); + + $org = app(OrganizationResolver::class)->completeOnboarding($user, [ + 'organization_name' => 'Shop', + 'industry' => 'restaurant', + 'appointment_mode' => 'hybrid', + 'branch_name' => 'Main', + 'timezone' => 'UTC', + ]); + + $beforeQueues = ServiceQueue::where('organization_id', $org->id)->count(); + + $this->actingAs($user) + ->put(route('qms.settings.update'), [ + 'name' => $org->name, + 'timezone' => $org->timezone, + 'appointment_mode' => 'hybrid', + 'industry' => 'banking', + 'notifications_enabled' => '1', + ]) + ->assertRedirect(); + + $org->refresh(); + $this->assertSame('banking', data_get($org->settings, 'industry')); + $this->assertSame('restaurant', data_get($org->settings, 'industry_package.key')); + $this->assertTrue((bool) data_get($org->settings, 'industry_package_needs_reinstall')); + $this->assertTrue(IndustryPackageInstaller::needsReinstall($org)); + $this->assertSame($beforeQueues, ServiceQueue::where('organization_id', $org->id)->count()); + + $this->actingAs($user) + ->get(route('qms.dashboard')) + ->assertOk() + ->assertSee('Reinstall industry package required'); + } }