From 01ed082e77f6ea979510da1981ad6c2a1f61b09c Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Fri, 9 Jan 2026 11:46:32 -0600 Subject: [PATCH 01/17] Add case delete endpoint and action with tests --- .../Api/Actions/Cases/DeleteCase.php | 46 ++++++++++++++++++ .../Http/Controllers/Api/CaseController.php | 46 +++++++++++++++--- .../Models/ProcessRequestFactory.php | 16 +++++++ routes/api.php | 1 + tests/Feature/Api/CaseDeleteTest.php | 47 +++++++++++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php create mode 100644 tests/Feature/Api/CaseDeleteTest.php diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php new file mode 100644 index 0000000000..25e0a00079 --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -0,0 +1,46 @@ +where('case_number', $caseNumber) + ->pluck('id') + ->all(); + + if ($requestIds === []) { + abort(404); + } + + DB::transaction(function () use ($caseNumber, $requestIds) { + CaseStarted::query() + ->where('case_number', $caseNumber) + ->delete(); + + CaseParticipated::query() + ->where('case_number', $caseNumber) + ->delete(); + + CaseNumber::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + ProcessRequest::query() + ->whereIn('id', $requestIds) + ->delete(); + }); + } +} diff --git a/ProcessMaker/Http/Controllers/Api/CaseController.php b/ProcessMaker/Http/Controllers/Api/CaseController.php index caaacfc14d..574f13bf04 100644 --- a/ProcessMaker/Http/Controllers/Api/CaseController.php +++ b/ProcessMaker/Http/Controllers/Api/CaseController.php @@ -2,6 +2,8 @@ namespace ProcessMaker\Http\Controllers\Api; +use Illuminate\Http\JsonResponse; +use ProcessMaker\Http\Controllers\Api\Actions\Cases\DeleteCase; use ProcessMaker\Http\Controllers\Controller; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessRequest; @@ -12,7 +14,7 @@ class CaseController extends Controller /** * Get stage information for cases */ - public function getStagePerCase($case_number = null) + public function getStagePerCase(?string $case_number = null): JsonResponse { if (!empty($case_number)) { $responseData = $this->getSpecificCaseStages($case_number); @@ -31,12 +33,44 @@ public function getStagePerCase($case_number = null) return response()->json($responseData); } + /** + * Delete a case and its related requests. + * + * @param string $case_number + * @return JsonResponse + * + * @OA\Delete( + * path="/cases/{case_number}", + * summary="Delete a case and its related requests", + * operationId="deleteCase", + * tags={"Cases"}, + * @OA\Parameter( + * description="Case number to delete", + * in="path", + * name="case_number", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response( + * response=204, + * description="success" + * ), + * @OA\Response(response=404, ref="#/components/responses/404"), + * ) + */ + public function destroy(string $case_number): JsonResponse + { + (new DeleteCase)($case_number); + + return response()->json([], 204); + } + /** * Get specific case stages information * @param string $caseNumber The unique identifier of the case to retrieve stages for * @return array */ - private function getSpecificCaseStages($caseNumber) + private function getSpecificCaseStages(string $caseNumber): array { $allRequests = ProcessRequest::where('case_number', $caseNumber)->get(); // Check if any requests were found @@ -75,7 +109,7 @@ private function getSpecificCaseStages($caseNumber) * @param string|null $status The status to set for the stages * @return array */ - private function getDefaultCaseStages($status = null) + private function getDefaultCaseStages(?string $status = null): array { return [ [ @@ -100,7 +134,7 @@ private function getDefaultCaseStages($status = null) * @param string $stageName The name of the stage ('In Progress' or 'Completed') * @return string The mapped status */ - private function mapStatus($status, $stageName) + private function mapStatus(?string $status, string $stageName): string { if ($status === 'COMPLETED') { return 'Done'; @@ -120,11 +154,11 @@ private function mapStatus($status, $stageName) /** * Get the stages summary based on the provided request. * - * @param $requestId + * @param ProcessRequest $request * @return array An array of stage results, each containing the stage ID, name, status, * and completion date. */ - private function getStagesSummary(ProcessRequest $request) + private function getStagesSummary(ProcessRequest $request): array { $requestId = $request->id; $processId = $request->process_id; diff --git a/database/factories/ProcessMaker/Models/ProcessRequestFactory.php b/database/factories/ProcessMaker/Models/ProcessRequestFactory.php index 9c9ab875b3..5a4d2f7464 100644 --- a/database/factories/ProcessMaker/Models/ProcessRequestFactory.php +++ b/database/factories/ProcessMaker/Models/ProcessRequestFactory.php @@ -47,4 +47,20 @@ public function definition() }, ]; } + + public function withCaseNumber(int $caseNumber): self + { + $caseTitle = $this->faker->words(4, true); + + return $this->state([ + 'case_number' => $caseNumber, + 'case_title' => $caseTitle, + 'case_title_formatted' => $caseTitle, + ])->afterCreating(function (ProcessRequest $request) use ($caseNumber, $caseTitle) { + $request->case_number = $caseNumber; + $request->case_title = $caseTitle; + $request->case_title_formatted = $caseTitle; + $request->save(); + }); + } } diff --git a/routes/api.php b/routes/api.php index 2b3ffc38bb..04a8c96f5b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -235,6 +235,7 @@ // Cases Route::get('cases/stages_bar/{case_number?}', [CaseController::class, 'getStagePerCase'])->name('cases.stage'); + Route::delete('cases/{case_number}', [CaseController::class, 'destroy'])->name('cases.destroy'); // TaskDrafts Route::put('drafts/{task}', [TaskDraftController::class, 'update'])->name('taskdraft.update'); diff --git a/tests/Feature/Api/CaseDeleteTest.php b/tests/Feature/Api/CaseDeleteTest.php new file mode 100644 index 0000000000..da4e6689ed --- /dev/null +++ b/tests/Feature/Api/CaseDeleteTest.php @@ -0,0 +1,47 @@ +count(2) + ->withCaseNumber($caseNumber) + ->create(); + + CaseNumber::query()->create(['process_request_id' => $requests->first()->id]); + CaseNumber::query()->create(['process_request_id' => $requests->last()->id]); + CaseStarted::factory()->create(['case_number' => $caseNumber]); + CaseParticipated::factory()->create(['case_number' => $caseNumber]); + + $response = $this->apiCall('DELETE', route('api.cases.destroy', ['case_number' => $caseNumber])); + + $response->assertStatus(204); + $this->assertDatabaseMissing('process_requests', ['case_number' => $caseNumber]); + $this->assertDatabaseMissing('cases_started', ['case_number' => $caseNumber]); + $this->assertDatabaseMissing('cases_participated', ['case_number' => $caseNumber]); + $this->assertDatabaseMissing('case_numbers', ['process_request_id' => $requests->first()->id]); + $this->assertDatabaseMissing('case_numbers', ['process_request_id' => $requests->last()->id]); + } + + public function testDeleteCaseReturnsNotFoundWhenMissing(): void + { + $caseNumber = 99999; + + $response = $this->apiCall('DELETE', route('api.cases.destroy', ['case_number' => $caseNumber])); + + $response->assertStatus(404); + } +} From b8628292e39244d6f6075c06987f450b30ed8960 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 12 Jan 2026 12:21:44 -0600 Subject: [PATCH 02/17] Remove case dependencies on delete and expand factories/tests --- .../Api/Actions/Cases/DeleteCase.php | 246 ++++++++++++++++-- .../ProcessMaker/Models/CaseNumberFactory.php | 21 ++ .../Models/InboxRuleLogFactory.php | 23 +- .../Models/ProcessRequestLockFactory.php | 26 ++ .../Models/ScheduledTaskFactory.php | 9 + tests/Feature/Api/CaseDeleteTest.php | 120 ++++++++- 6 files changed, 423 insertions(+), 22 deletions(-) create mode 100644 database/factories/ProcessMaker/Models/CaseNumberFactory.php create mode 100644 database/factories/ProcessMaker/Models/ProcessRequestLockFactory.php diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index 25e0a00079..265d46257a 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -3,44 +3,258 @@ namespace ProcessMaker\Http\Controllers\Api\Actions\Cases; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use ProcessMaker\Models\CaseNumber; use ProcessMaker\Models\CaseParticipated; use ProcessMaker\Models\CaseStarted; +use ProcessMaker\Models\Comment; +use ProcessMaker\Models\InboxRule; +use ProcessMaker\Models\InboxRuleLog; +use ProcessMaker\Models\Media; +use ProcessMaker\Models\ProcessAbeRequestToken; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestLock; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\ScheduledTask; +use ProcessMaker\Models\TaskDraft; class DeleteCase { public function __invoke(string $caseNumber): void { - // Delete later dependent records for requests and tokens (process_request_tokens, - // process_request_locks, process_abe_request_tokens, scheduled_tasks, - // inbox_rules, inbox_rule_logs, ellucian_ethos_sync_global_task_list, comments). + $requestIds = $this->getRequestIds($caseNumber); - $requestIds = ProcessRequest::query() + if ($requestIds === []) { + abort(404); + } + + $tokenIds = $this->getRequestTokenIds($requestIds); + + DB::transaction(function () use ($caseNumber, $requestIds, $tokenIds) { + $this->deleteInboxRuleLogs($tokenIds); + $this->deleteInboxRules($tokenIds); + $this->deleteProcessRequestLocks($requestIds, $tokenIds); + $this->deleteProcessAbeRequestTokens($requestIds, $tokenIds); + $this->deleteScheduledTasks($requestIds, $tokenIds); + $this->deleteEllucianEthosSyncTasks($tokenIds); + $draftIds = $this->getTaskDraftIds($tokenIds); + $this->deleteTaskDraftMedia($draftIds); + $this->deleteTaskDrafts($tokenIds); + $this->deleteComments($caseNumber, $requestIds, $tokenIds); + $this->deleteRequestMedia($requestIds); + $this->deleteCaseNumbers($requestIds); + $this->deleteCasesStarted($caseNumber); + $this->deleteCasesParticipated($caseNumber); + $this->deleteProcessRequestTokens($requestIds); + $this->deleteProcessRequests($requestIds); + }); + } + + private function getRequestIds(string $caseNumber): array + { + return ProcessRequest::query() ->where('case_number', $caseNumber) ->pluck('id') ->all(); + } + private function getRequestTokenIds(array $requestIds): array + { if ($requestIds === []) { - abort(404); + return []; } - DB::transaction(function () use ($caseNumber, $requestIds) { - CaseStarted::query() - ->where('case_number', $caseNumber) - ->delete(); + return ProcessRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->pluck('id') + ->all(); + } + + private function getTaskDraftIds(array $tokenIds): array + { + if ($tokenIds === []) { + return []; + } + + return TaskDraft::query() + ->whereIn('task_id', $tokenIds) + ->pluck('id') + ->all(); + } + + private function deleteCasesStarted(string $caseNumber): void + { + CaseStarted::query() + ->where('case_number', $caseNumber) + ->delete(); + } + + private function deleteCasesParticipated(string $caseNumber): void + { + CaseParticipated::query() + ->where('case_number', $caseNumber) + ->delete(); + } + + private function deleteCaseNumbers(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + CaseNumber::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + } + + private function deleteProcessRequests(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + ProcessRequest::query() + ->whereIn('id', $requestIds) + ->delete(); + } + + private function deleteProcessRequestTokens(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + ProcessRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + } - CaseParticipated::query() - ->where('case_number', $caseNumber) + private function deleteProcessRequestLocks(array $requestIds, array $tokenIds): void + { + ProcessRequestLock::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ProcessRequestLock::query() + ->whereIn('process_request_token_id', $tokenIds) ->delete(); + } + } + + private function deleteProcessAbeRequestTokens(array $requestIds, array $tokenIds): void + { + ProcessAbeRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); - CaseNumber::query() - ->whereIn('process_request_id', $requestIds) + if ($tokenIds !== []) { + ProcessAbeRequestToken::query() + ->whereIn('process_request_token_id', $tokenIds) ->delete(); + } + } + + private function deleteScheduledTasks(array $requestIds, array $tokenIds): void + { + ScheduledTask::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); - ProcessRequest::query() - ->whereIn('id', $requestIds) + if ($tokenIds !== []) { + ScheduledTask::query() + ->whereIn('process_request_token_id', $tokenIds) ->delete(); - }); + } + } + + private function deleteInboxRules(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + InboxRule::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteInboxRuleLogs(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + InboxRuleLog::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteEllucianEthosSyncTasks(array $tokenIds): void + { + if ($tokenIds === [] || !Schema::hasTable('ellucian_ethos_sync_global_task_list')) { + return; + } + + DB::table('ellucian_ethos_sync_global_task_list') + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteTaskDrafts(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + TaskDraft::query() + ->whereIn('task_id', $tokenIds) + ->delete(); + } + + private function deleteTaskDraftMedia(array $draftIds): void + { + if ($draftIds === []) { + return; + } + + Media::query() + ->where('model_type', TaskDraft::class) + ->whereIn('model_id', $draftIds) + ->get() + ->each + ->delete(); + } + + private function deleteRequestMedia(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + Media::query() + ->where('model_type', ProcessRequest::class) + ->whereIn('model_id', $requestIds) + ->get() + ->each + ->delete(); + } + + private function deleteComments(string $caseNumber, array $requestIds, array $tokenIds): void + { + Comment::query() + ->where('case_number', $caseNumber) + ->orWhere(function ($query) use ($requestIds, $tokenIds) { + $query->where('commentable_type', ProcessRequest::class) + ->whereIn('commentable_id', $requestIds); + + if ($tokenIds !== []) { + $query->orWhere(function ($nestedQuery) use ($tokenIds) { + $nestedQuery->where('commentable_type', ProcessRequestToken::class) + ->whereIn('commentable_id', $tokenIds); + }); + } + }) + ->delete(); } } diff --git a/database/factories/ProcessMaker/Models/CaseNumberFactory.php b/database/factories/ProcessMaker/Models/CaseNumberFactory.php new file mode 100644 index 0000000000..328293278a --- /dev/null +++ b/database/factories/ProcessMaker/Models/CaseNumberFactory.php @@ -0,0 +1,21 @@ + function () { + return ProcessRequest::factory()->create()->id; + }, + ]; + } +} diff --git a/database/factories/ProcessMaker/Models/InboxRuleLogFactory.php b/database/factories/ProcessMaker/Models/InboxRuleLogFactory.php index 1099e9e308..e94d0f6842 100644 --- a/database/factories/ProcessMaker/Models/InboxRuleLogFactory.php +++ b/database/factories/ProcessMaker/Models/InboxRuleLogFactory.php @@ -2,8 +2,11 @@ namespace Database\Factories\ProcessMaker\Models; -use ProcessMaker\Models\InboxRuleLog; use Illuminate\Database\Eloquent\Factories\Factory; +use ProcessMaker\Models\InboxRule; +use ProcessMaker\Models\InboxRuleLog; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\User; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\ProcessMaker\Models\InboxRule> @@ -15,9 +18,21 @@ class InboxRuleLogFactory extends Factory public function definition() { return [ - 'inbox_rule_id' => null, - 'task_id' => null, - 'inbox_rule_data' => json_encode(['key1' => 'value1', 'key2' => 'value2']), + 'user_id' => function () { + return User::factory()->create()->id; + }, + 'inbox_rule_id' => function () { + return InboxRule::factory()->create()->id; + }, + 'process_request_token_id' => function () { + return ProcessRequestToken::factory()->create()->id; + }, + 'inbox_rule_attributes' => [ + 'make_draft' => false, + 'submit_data' => false, + 'mark_as_priority' => false, + 'reassign_to_user_id' => null, + ], 'created_at' => now(), 'updated_at' => now(), ]; diff --git a/database/factories/ProcessMaker/Models/ProcessRequestLockFactory.php b/database/factories/ProcessMaker/Models/ProcessRequestLockFactory.php new file mode 100644 index 0000000000..2debf1fc26 --- /dev/null +++ b/database/factories/ProcessMaker/Models/ProcessRequestLockFactory.php @@ -0,0 +1,26 @@ + function () { + return ProcessRequest::factory()->create()->id; + }, + 'process_request_token_id' => function () { + return ProcessRequestToken::factory()->create()->id; + }, + 'due_at' => now()->addMinute(), + ]; + } +} diff --git a/database/factories/ProcessMaker/Models/ScheduledTaskFactory.php b/database/factories/ProcessMaker/Models/ScheduledTaskFactory.php index 8c017adaf6..27d47f23b6 100644 --- a/database/factories/ProcessMaker/Models/ScheduledTaskFactory.php +++ b/database/factories/ProcessMaker/Models/ScheduledTaskFactory.php @@ -38,4 +38,13 @@ public function definition() 'configuration' => '{}', ]; } + + public function forToken(ProcessRequestToken $token): self + { + return $this->state([ + 'process_id' => $token->process_id, + 'process_request_id' => $token->process_request_id, + 'process_request_token_id' => $token->getKey(), + ]); + } } diff --git a/tests/Feature/Api/CaseDeleteTest.php b/tests/Feature/Api/CaseDeleteTest.php index da4e6689ed..3673ed926f 100644 --- a/tests/Feature/Api/CaseDeleteTest.php +++ b/tests/Feature/Api/CaseDeleteTest.php @@ -2,10 +2,22 @@ namespace Tests\Feature\Api; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use ProcessMaker\Models\CaseNumber; use ProcessMaker\Models\CaseParticipated; use ProcessMaker\Models\CaseStarted; +use ProcessMaker\Models\Comment; +use ProcessMaker\Models\InboxRule; +use ProcessMaker\Models\InboxRuleLog; +use ProcessMaker\Models\Media; +use ProcessMaker\Models\ProcessAbeRequestToken; use ProcessMaker\Models\ProcessRequest; +use ProcessMaker\Models\ProcessRequestLock; +use ProcessMaker\Models\ProcessRequestToken; +use ProcessMaker\Models\ScheduledTask; +use ProcessMaker\Models\TaskDraft; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -21,8 +33,8 @@ public function testDeleteCaseRemovesCoreRecords(): void ->withCaseNumber($caseNumber) ->create(); - CaseNumber::query()->create(['process_request_id' => $requests->first()->id]); - CaseNumber::query()->create(['process_request_id' => $requests->last()->id]); + CaseNumber::factory()->create(['process_request_id' => $requests->first()->id]); + CaseNumber::factory()->create(['process_request_id' => $requests->last()->id]); CaseStarted::factory()->create(['case_number' => $caseNumber]); CaseParticipated::factory()->create(['case_number' => $caseNumber]); @@ -36,6 +48,110 @@ public function testDeleteCaseRemovesCoreRecords(): void $this->assertDatabaseMissing('case_numbers', ['process_request_id' => $requests->last()->id]); } + public function testDeleteCaseRemovesDependentRecords(): void + { + $caseNumber = 24680; + $request = ProcessRequest::factory() + ->withCaseNumber($caseNumber) + ->create(); + $token = ProcessRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_id' => $request->process_id, + 'user_id' => $this->user->id, + ]); + + CaseNumber::factory()->create(['process_request_id' => $request->id]); + CaseStarted::factory()->create(['case_number' => $caseNumber]); + CaseParticipated::factory()->create(['case_number' => $caseNumber]); + + ProcessRequestLock::factory()->create([ + 'process_request_id' => $request->id, + 'process_request_token_id' => $token->id, + ]); + + DB::table('scheduled_tasks')->insert( + ScheduledTask::factory()->forToken($token)->raw([ + 'type' => 'INTERMEDIATE_TIMER_EVENT', + 'configuration' => '{}', + 'created_at' => now(), + 'updated_at' => now(), + ]) + ); + + $inboxRule = InboxRule::factory()->create([ + 'user_id' => $this->user->id, + 'process_request_token_id' => $token->id, + ]); + + InboxRuleLog::factory()->create([ + 'user_id' => $this->user->id, + 'inbox_rule_id' => $inboxRule->id, + 'process_request_token_id' => $token->id, + ]); + + ProcessAbeRequestToken::factory()->create([ + 'process_request_id' => $request->id, + 'process_request_token_id' => $token->id, + ]); + + $draft = TaskDraft::factory()->create([ + 'task_id' => $token->id, + 'data' => ['key1' => 'value1'], + ]); + + Media::factory()->create([ + 'model_type' => TaskDraft::class, + 'model_id' => $draft->id, + ]); + + Media::factory()->create([ + 'model_type' => ProcessRequest::class, + 'model_id' => $request->id, + 'custom_properties' => [ + 'data_name' => 'case/file.txt', + ], + ]); + + Comment::factory()->create([ + 'commentable_type' => ProcessRequest::class, + 'commentable_id' => $request->id, + 'case_number' => $caseNumber, + ]); + Comment::factory()->create([ + 'commentable_type' => ProcessRequestToken::class, + 'commentable_id' => $token->id, + 'case_number' => $caseNumber, + ]); + + if (Schema::hasTable('ellucian_ethos_sync_global_task_list')) { + DB::table('ellucian_ethos_sync_global_task_list')->insert([ + 'user_id' => $this->user->id, + 'global_task_uuid' => (string) Str::uuid(), + 'process_request_token_id' => $token->id, + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + $response = $this->apiCall('DELETE', route('api.cases.destroy', ['case_number' => $caseNumber])); + + $response->assertStatus(204); + $this->assertDatabaseMissing('process_request_tokens', ['id' => $token->id]); + $this->assertDatabaseMissing('process_request_locks', ['process_request_id' => $request->id]); + $this->assertDatabaseMissing('scheduled_tasks', ['process_request_id' => $request->id]); + $this->assertDatabaseMissing('inbox_rules', ['id' => $inboxRule->id]); + $this->assertDatabaseMissing('inbox_rule_logs', ['process_request_token_id' => $token->id]); + $this->assertDatabaseMissing('process_abe_request_tokens', ['process_request_token_id' => $token->id]); + $this->assertDatabaseMissing('task_drafts', ['task_id' => $token->id]); + $this->assertDatabaseMissing('media', ['model_type' => TaskDraft::class, 'model_id' => $draft->id]); + $this->assertDatabaseMissing('media', ['model_type' => ProcessRequest::class, 'model_id' => $request->id]); + $this->assertSoftDeleted('comments', ['case_number' => $caseNumber]); + if (Schema::hasTable('ellucian_ethos_sync_global_task_list')) { + $this->assertDatabaseMissing('ellucian_ethos_sync_global_task_list', ['process_request_token_id' => $token->id]); + } + } + public function testDeleteCaseReturnsNotFoundWhenMissing(): void { $caseNumber = 99999; From 642b34ffbefd218edb13c8f9ce8ef9f16bd96c9f Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 12 Jan 2026 12:31:57 -0600 Subject: [PATCH 03/17] Document case delete endpoint in Swagger --- ProcessMaker/Http/Controllers/Api/CaseController.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ProcessMaker/Http/Controllers/Api/CaseController.php b/ProcessMaker/Http/Controllers/Api/CaseController.php index 574f13bf04..1d4ba3a891 100644 --- a/ProcessMaker/Http/Controllers/Api/CaseController.php +++ b/ProcessMaker/Http/Controllers/Api/CaseController.php @@ -55,7 +55,19 @@ public function getStagePerCase(?string $case_number = null): JsonResponse * response=204, * description="success" * ), + * @OA\Response( + * response=401, + * description="Unauthorized" + * ), * @OA\Response(response=404, ref="#/components/responses/404"), + * @OA\Response( + * response=409, + * description="Conflict" + * ), + * @OA\Response( + * response=500, + * description="Internal Server Error" + * ), * ) */ public function destroy(string $case_number): JsonResponse From 350548bf095fbd68bcacbc9f6697c79c3b506942 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 12 Jan 2026 13:12:41 -0600 Subject: [PATCH 04/17] Fix delete cascades for inbox rules and process requests --- .../Http/Controllers/Api/Actions/Cases/DeleteCase.php | 4 ++++ ProcessMaker/Models/InboxRule.php | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index 265d46257a..eb9dd51813 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -115,6 +115,8 @@ private function deleteProcessRequests(array $requestIds): void ProcessRequest::query() ->whereIn('id', $requestIds) + ->get() + ->each ->delete(); } @@ -176,6 +178,8 @@ private function deleteInboxRules(array $tokenIds): void InboxRule::query() ->whereIn('process_request_token_id', $tokenIds) + ->get() + ->each ->delete(); } diff --git a/ProcessMaker/Models/InboxRule.php b/ProcessMaker/Models/InboxRule.php index 59107b38f9..e83c55f5e3 100644 --- a/ProcessMaker/Models/InboxRule.php +++ b/ProcessMaker/Models/InboxRule.php @@ -35,7 +35,9 @@ class InboxRule extends ProcessMakerModel protected static function booted() { static::deleting(function (InboxRule $inboxRule) { - $inboxRule->savedSearch()->delete(); + if (class_exists(SavedSearch::class)) { + $inboxRule->savedSearch()->delete(); + } }); } From 9a11a42c9357c560b9a4b1a09621bce5d13c4654 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 13 Jan 2026 10:11:31 -0600 Subject: [PATCH 05/17] feat(cases): recount saved searches after case delete --- .../Api/Actions/Cases/DeleteCase.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index eb9dd51813..4a81bd5d7a 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -48,6 +48,8 @@ public function __invoke(string $caseNumber): void $this->deleteProcessRequestTokens($requestIds); $this->deleteProcessRequests($requestIds); }); + + $this->dispatchSavedSearchRecount(); } private function getRequestIds(string $caseNumber): array @@ -261,4 +263,20 @@ private function deleteComments(string $caseNumber, array $requestIds, array $to }) ->delete(); } + + private function dispatchSavedSearchRecount(): void + { + if (!config('savedsearch.count', false)) { + return; + } + + $jobClass = 'ProcessMaker\\Package\\SavedSearch\\Jobs\\RecountAllSavedSearches'; + if (!class_exists($jobClass)) { + return; + } + + DB::afterCommit(static function () use ($jobClass): void { + $jobClass::dispatch(['request', 'task']); + }); + } } From 07a3894231978972233fa93ae3e238688148ced2 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Jan 2026 16:40:32 -0500 Subject: [PATCH 06/17] Create CaseDeleted Event --- ProcessMaker/Events/CaseDeleted.php | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 ProcessMaker/Events/CaseDeleted.php diff --git a/ProcessMaker/Events/CaseDeleted.php b/ProcessMaker/Events/CaseDeleted.php new file mode 100644 index 0000000000..70f88af2eb --- /dev/null +++ b/ProcessMaker/Events/CaseDeleted.php @@ -0,0 +1,65 @@ +caseNumber = $caseNumber; + $this->caseTitle = $caseTitle; + } + + /** + * Get specific data related to the event + * + * @return array + */ + public function getData(): array + { + return [ + 'case_title' => $this->caseTitle, + 'case_number' => $this->caseNumber, + 'deleted_at' => Carbon::now(), + ]; + } + + /** + * Get specific data related to the event + * + * @return array + */ + public function getChanges(): array + { + return [ + 'case_number' => $this->caseNumber, + ]; + } + + /** + * Get the Event name + * + * @return string + */ + public function getEventName(): string + { + return 'CaseDeleted'; + } +} From 826c5026f88f724d79030e32dfaa4e660de70d7d Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Jan 2026 16:41:46 -0500 Subject: [PATCH 07/17] Listen for CaseDeletedEvent in EventServiceProvider --- ProcessMaker/Providers/EventServiceProvider.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ProcessMaker/Providers/EventServiceProvider.php b/ProcessMaker/Providers/EventServiceProvider.php index f2cc0c0832..1ee0c13b57 100644 --- a/ProcessMaker/Providers/EventServiceProvider.php +++ b/ProcessMaker/Providers/EventServiceProvider.php @@ -9,6 +9,7 @@ use ProcessMaker\Events\AuthClientCreated; use ProcessMaker\Events\AuthClientDeleted; use ProcessMaker\Events\AuthClientUpdated; +use ProcessMaker\Events\CaseDeleted; use ProcessMaker\Events\CategoryCreated; use ProcessMaker\Events\CategoryDeleted; use ProcessMaker\Events\CategoryUpdated; @@ -139,6 +140,7 @@ public function boot() $this->app['events']->listen(AuthClientCreated::class, SecurityLogger::class); $this->app['events']->listen(AuthClientDeleted::class, SecurityLogger::class); $this->app['events']->listen(AuthClientUpdated::class, SecurityLogger::class); + $this->app['events']->listen(CaseDeleted::class, SecurityLogger::class); $this->app['events']->listen(CategoryCreated::class, SecurityLogger::class); $this->app['events']->listen(CategoryDeleted::class, SecurityLogger::class); $this->app['events']->listen(CategoryUpdated::class, SecurityLogger::class); From c7b609e26749a66c151aec4c96e1fac269822958 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Jan 2026 16:43:29 -0500 Subject: [PATCH 08/17] Dispatch CaseDeleted; handle record deletion in trait Record deletion moved to trait to resolve sonarqube complexity issue (too many methods in DeleteCase) --- .../Api/Actions/Cases/DeleteCase.php | 217 +++--------------- .../Api/Actions/Cases/DeletesCaseRecords.php | 202 ++++++++++++++++ 2 files changed, 229 insertions(+), 190 deletions(-) create mode 100644 ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index 4a81bd5d7a..5cd9cc4495 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -3,23 +3,16 @@ namespace ProcessMaker\Http\Controllers\Api\Actions\Cases; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; -use ProcessMaker\Models\CaseNumber; -use ProcessMaker\Models\CaseParticipated; +use ProcessMaker\Events\CaseDeleted; use ProcessMaker\Models\CaseStarted; -use ProcessMaker\Models\Comment; -use ProcessMaker\Models\InboxRule; -use ProcessMaker\Models\InboxRuleLog; -use ProcessMaker\Models\Media; -use ProcessMaker\Models\ProcessAbeRequestToken; use ProcessMaker\Models\ProcessRequest; -use ProcessMaker\Models\ProcessRequestLock; use ProcessMaker\Models\ProcessRequestToken; -use ProcessMaker\Models\ScheduledTask; use ProcessMaker\Models\TaskDraft; class DeleteCase { + use DeletesCaseRecords; + public function __invoke(string $caseNumber): void { $requestIds = $this->getRequestIds($caseNumber); @@ -28,6 +21,9 @@ public function __invoke(string $caseNumber): void abort(404); } + // Get case title before deletion for event logging + $caseTitle = $this->getCaseTitle($caseNumber); + $tokenIds = $this->getRequestTokenIds($requestIds); DB::transaction(function () use ($caseNumber, $requestIds, $tokenIds) { @@ -49,6 +45,8 @@ public function __invoke(string $caseNumber): void $this->deleteProcessRequests($requestIds); }); + CaseDeleted::dispatch($caseNumber, $caseTitle); + $this->dispatchSavedSearchRecount(); } @@ -60,6 +58,25 @@ private function getRequestIds(string $caseNumber): array ->all(); } + private function getCaseTitle(string $caseNumber): string + { + $caseStarted = CaseStarted::query() + ->where('case_number', $caseNumber) + ->first(); + + if ($caseStarted) { + return $caseStarted->case_title; + } + + // Get case title from the first ProcessRequest if CaseStarted doesn't exist + $firstRequest = ProcessRequest::query() + ->where('case_number', $caseNumber) + ->whereNull('parent_request_id') + ->first(); + + return $firstRequest?->case_title ?? "Case #{$caseNumber}"; + } + private function getRequestTokenIds(array $requestIds): array { if ($requestIds === []) { @@ -84,186 +101,6 @@ private function getTaskDraftIds(array $tokenIds): array ->all(); } - private function deleteCasesStarted(string $caseNumber): void - { - CaseStarted::query() - ->where('case_number', $caseNumber) - ->delete(); - } - - private function deleteCasesParticipated(string $caseNumber): void - { - CaseParticipated::query() - ->where('case_number', $caseNumber) - ->delete(); - } - - private function deleteCaseNumbers(array $requestIds): void - { - if ($requestIds === []) { - return; - } - - CaseNumber::query() - ->whereIn('process_request_id', $requestIds) - ->delete(); - } - - private function deleteProcessRequests(array $requestIds): void - { - if ($requestIds === []) { - return; - } - - ProcessRequest::query() - ->whereIn('id', $requestIds) - ->get() - ->each - ->delete(); - } - - private function deleteProcessRequestTokens(array $requestIds): void - { - if ($requestIds === []) { - return; - } - - ProcessRequestToken::query() - ->whereIn('process_request_id', $requestIds) - ->delete(); - } - - private function deleteProcessRequestLocks(array $requestIds, array $tokenIds): void - { - ProcessRequestLock::query() - ->whereIn('process_request_id', $requestIds) - ->delete(); - - if ($tokenIds !== []) { - ProcessRequestLock::query() - ->whereIn('process_request_token_id', $tokenIds) - ->delete(); - } - } - - private function deleteProcessAbeRequestTokens(array $requestIds, array $tokenIds): void - { - ProcessAbeRequestToken::query() - ->whereIn('process_request_id', $requestIds) - ->delete(); - - if ($tokenIds !== []) { - ProcessAbeRequestToken::query() - ->whereIn('process_request_token_id', $tokenIds) - ->delete(); - } - } - - private function deleteScheduledTasks(array $requestIds, array $tokenIds): void - { - ScheduledTask::query() - ->whereIn('process_request_id', $requestIds) - ->delete(); - - if ($tokenIds !== []) { - ScheduledTask::query() - ->whereIn('process_request_token_id', $tokenIds) - ->delete(); - } - } - - private function deleteInboxRules(array $tokenIds): void - { - if ($tokenIds === []) { - return; - } - - InboxRule::query() - ->whereIn('process_request_token_id', $tokenIds) - ->get() - ->each - ->delete(); - } - - private function deleteInboxRuleLogs(array $tokenIds): void - { - if ($tokenIds === []) { - return; - } - - InboxRuleLog::query() - ->whereIn('process_request_token_id', $tokenIds) - ->delete(); - } - - private function deleteEllucianEthosSyncTasks(array $tokenIds): void - { - if ($tokenIds === [] || !Schema::hasTable('ellucian_ethos_sync_global_task_list')) { - return; - } - - DB::table('ellucian_ethos_sync_global_task_list') - ->whereIn('process_request_token_id', $tokenIds) - ->delete(); - } - - private function deleteTaskDrafts(array $tokenIds): void - { - if ($tokenIds === []) { - return; - } - - TaskDraft::query() - ->whereIn('task_id', $tokenIds) - ->delete(); - } - - private function deleteTaskDraftMedia(array $draftIds): void - { - if ($draftIds === []) { - return; - } - - Media::query() - ->where('model_type', TaskDraft::class) - ->whereIn('model_id', $draftIds) - ->get() - ->each - ->delete(); - } - - private function deleteRequestMedia(array $requestIds): void - { - if ($requestIds === []) { - return; - } - - Media::query() - ->where('model_type', ProcessRequest::class) - ->whereIn('model_id', $requestIds) - ->get() - ->each - ->delete(); - } - - private function deleteComments(string $caseNumber, array $requestIds, array $tokenIds): void - { - Comment::query() - ->where('case_number', $caseNumber) - ->orWhere(function ($query) use ($requestIds, $tokenIds) { - $query->where('commentable_type', ProcessRequest::class) - ->whereIn('commentable_id', $requestIds); - - if ($tokenIds !== []) { - $query->orWhere(function ($nestedQuery) use ($tokenIds) { - $nestedQuery->where('commentable_type', ProcessRequestToken::class) - ->whereIn('commentable_id', $tokenIds); - }); - } - }) - ->delete(); - } - private function dispatchSavedSearchRecount(): void { if (!config('savedsearch.count', false)) { diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php new file mode 100644 index 0000000000..d8b3493ccf --- /dev/null +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php @@ -0,0 +1,202 @@ +where('case_number', $caseNumber) + ->delete(); + } + + private function deleteCasesParticipated(string $caseNumber): void + { + CaseParticipated::query() + ->where('case_number', $caseNumber) + ->delete(); + } + + private function deleteCaseNumbers(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + CaseNumber::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + } + + private function deleteProcessRequests(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + ProcessRequest::query() + ->whereIn('id', $requestIds) + ->get() + ->each + ->delete(); + } + + private function deleteProcessRequestTokens(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + ProcessRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + } + + private function deleteProcessRequestLocks(array $requestIds, array $tokenIds): void + { + ProcessRequestLock::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ProcessRequestLock::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + } + + private function deleteProcessAbeRequestTokens(array $requestIds, array $tokenIds): void + { + ProcessAbeRequestToken::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ProcessAbeRequestToken::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + } + + private function deleteScheduledTasks(array $requestIds, array $tokenIds): void + { + ScheduledTask::query() + ->whereIn('process_request_id', $requestIds) + ->delete(); + + if ($tokenIds !== []) { + ScheduledTask::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + } + + private function deleteInboxRules(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + InboxRule::query() + ->whereIn('process_request_token_id', $tokenIds) + ->get() + ->each + ->delete(); + } + + private function deleteInboxRuleLogs(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + InboxRuleLog::query() + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteEllucianEthosSyncTasks(array $tokenIds): void + { + if ($tokenIds === [] || !Schema::hasTable('ellucian_ethos_sync_global_task_list')) { + return; + } + + DB::table('ellucian_ethos_sync_global_task_list') + ->whereIn('process_request_token_id', $tokenIds) + ->delete(); + } + + private function deleteTaskDrafts(array $tokenIds): void + { + if ($tokenIds === []) { + return; + } + + TaskDraft::query() + ->whereIn('task_id', $tokenIds) + ->delete(); + } + + private function deleteTaskDraftMedia(array $draftIds): void + { + if ($draftIds === []) { + return; + } + + Media::query() + ->where('model_type', TaskDraft::class) + ->whereIn('model_id', $draftIds) + ->get() + ->each + ->delete(); + } + + private function deleteRequestMedia(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + Media::query() + ->where('model_type', ProcessRequest::class) + ->whereIn('model_id', $requestIds) + ->get() + ->each + ->delete(); + } + + private function deleteComments(string $caseNumber, array $requestIds, array $tokenIds): void + { + Comment::query() + ->where('case_number', $caseNumber) + ->orWhere(function ($query) use ($requestIds, $tokenIds) { + $query->where('commentable_type', ProcessRequest::class) + ->whereIn('commentable_id', $requestIds); + + if ($tokenIds !== []) { + $query->orWhere(function ($nestedQuery) use ($tokenIds) { + $nestedQuery->where('commentable_type', ProcessRequestToken::class) + ->whereIn('commentable_id', $tokenIds); + }); + } + }) + ->delete(); + } +} From a08015979758865d8394bbe68b66c6c8c77cca39 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Jan 2026 16:55:26 -0500 Subject: [PATCH 09/17] Update comments --- .../Http/Controllers/Api/Actions/Cases/DeleteCase.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index 5cd9cc4495..b4a1dae226 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -21,9 +21,7 @@ public function __invoke(string $caseNumber): void abort(404); } - // Get case title before deletion for event logging $caseTitle = $this->getCaseTitle($caseNumber); - $tokenIds = $this->getRequestTokenIds($requestIds); DB::transaction(function () use ($caseNumber, $requestIds, $tokenIds) { @@ -68,7 +66,7 @@ private function getCaseTitle(string $caseNumber): string return $caseStarted->case_title; } - // Get case title from the first ProcessRequest if CaseStarted doesn't exist + // If CaseStarted doesn't exist, get case title from the first ProcessRequest $firstRequest = ProcessRequest::query() ->where('case_number', $caseNumber) ->whereNull('parent_request_id') From 5658b8dbbb39906d3e600cc62e4ea256e37427bc Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Jan 2026 17:38:55 -0500 Subject: [PATCH 10/17] Change case_title to name for priority in modal --- ProcessMaker/Events/CaseDeleted.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProcessMaker/Events/CaseDeleted.php b/ProcessMaker/Events/CaseDeleted.php index 70f88af2eb..2eeb6569b9 100644 --- a/ProcessMaker/Events/CaseDeleted.php +++ b/ProcessMaker/Events/CaseDeleted.php @@ -35,7 +35,7 @@ public function __construct(int $caseNumber, string $caseTitle) public function getData(): array { return [ - 'case_title' => $this->caseTitle, + 'name' => $this->caseTitle, 'case_number' => $this->caseNumber, 'deleted_at' => Carbon::now(), ]; From 78dafca0b4ed68d700abcda1739b309620319d11 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Tue, 13 Jan 2026 17:40:48 -0500 Subject: [PATCH 11/17] Add testCaseDeleted test to SecurityLogsTest.php --- tests/Feature/Api/SecurityLogsTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Feature/Api/SecurityLogsTest.php b/tests/Feature/Api/SecurityLogsTest.php index d4fa5b6290..a8d0a8fc71 100644 --- a/tests/Feature/Api/SecurityLogsTest.php +++ b/tests/Feature/Api/SecurityLogsTest.php @@ -43,12 +43,14 @@ use ProcessMaker\Events\UserDeleted; use ProcessMaker\Events\UserRestored; use ProcessMaker\Events\UserUpdated; +use ProcessMaker\Http\Controllers\Api\Actions\Cases\DeleteCase; use ProcessMaker\Managers\SignalManager; use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Group; use ProcessMaker\Models\Permission; use ProcessMaker\Models\Process; use ProcessMaker\Models\ProcessCategory; +use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessTemplates; use ProcessMaker\Models\Screen; use ProcessMaker\Models\Script; @@ -248,6 +250,24 @@ public function checkAssertsSegurityLog(string $event, $date = 'created_at', $to } } + /** + * This tests Case Deleted + */ + public function testCaseDeleted() + { + $caseNumber = 12345; + + // Create a ProcessRequest with a case number + ProcessRequest::factory() + ->withCaseNumber($caseNumber) + ->create(); + + // Use the DeleteCase action to delete the case and dispatch the CaseDeleted event + (new DeleteCase)($caseNumber); + + $this->checkAssertsSegurityLog('CaseDeleted', 'deleted_at'); + } + /** * This test Category Created */ From 26a4700e7b48de7be0410a8e0d9d28a2ad3c51f4 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 14 Jan 2026 10:46:52 -0500 Subject: [PATCH 12/17] Resolve caseNumber type mismatch --- ProcessMaker/Events/CaseDeleted.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Events/CaseDeleted.php b/ProcessMaker/Events/CaseDeleted.php index 2eeb6569b9..8c3abb6049 100644 --- a/ProcessMaker/Events/CaseDeleted.php +++ b/ProcessMaker/Events/CaseDeleted.php @@ -12,7 +12,7 @@ class CaseDeleted implements SecurityLogEventInterface use Dispatchable; use FormatSecurityLogChanges; - private int $caseNumber; + private string $caseNumber; private string $caseTitle; @@ -21,7 +21,7 @@ class CaseDeleted implements SecurityLogEventInterface * * @return void */ - public function __construct(int $caseNumber, string $caseTitle) + public function __construct(string $caseNumber, string $caseTitle) { $this->caseNumber = $caseNumber; $this->caseTitle = $caseTitle; From ed4e52f0ea8e20656985fb10bd9519231d1fa81d Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 14 Jan 2026 11:07:01 -0500 Subject: [PATCH 13/17] Add null fallback in getCaseTitle --- ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index b4a1dae226..a9317899af 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -63,7 +63,7 @@ private function getCaseTitle(string $caseNumber): string ->first(); if ($caseStarted) { - return $caseStarted->case_title; + return $caseStarted->case_title ?? "Case #{$caseNumber}"; } // If CaseStarted doesn't exist, get case title from the first ProcessRequest From 9cc9f51c84e2ac848348eaf4b16f099004082ba5 Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 14 Jan 2026 11:19:15 -0500 Subject: [PATCH 14/17] Update getCaseTitle() --- .../Http/Controllers/Api/Actions/Cases/DeleteCase.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index a9317899af..d98f39e703 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -64,15 +64,15 @@ private function getCaseTitle(string $caseNumber): string if ($caseStarted) { return $caseStarted->case_title ?? "Case #{$caseNumber}"; - } - - // If CaseStarted doesn't exist, get case title from the first ProcessRequest - $firstRequest = ProcessRequest::query() + } else { + // If CaseStarted doesn't exist, get case title from the first ProcessRequest + $firstRequest = ProcessRequest::query() ->where('case_number', $caseNumber) ->whereNull('parent_request_id') ->first(); - return $firstRequest?->case_title ?? "Case #{$caseNumber}"; + return $firstRequest?->case_title ?? "Case #{$caseNumber}"; + } } private function getRequestTokenIds(array $requestIds): array From 0e8b89f1484fc31c53e664ad0be1c1f034ecda3b Mon Sep 17 00:00:00 2001 From: Teisha McRae Date: Wed, 14 Jan 2026 11:25:58 -0500 Subject: [PATCH 15/17] Update testCaseDeleted() in SecurityLogsTest.php --- tests/Feature/Api/SecurityLogsTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Api/SecurityLogsTest.php b/tests/Feature/Api/SecurityLogsTest.php index a8d0a8fc71..3ba3db3a9f 100644 --- a/tests/Feature/Api/SecurityLogsTest.php +++ b/tests/Feature/Api/SecurityLogsTest.php @@ -43,7 +43,6 @@ use ProcessMaker\Events\UserDeleted; use ProcessMaker\Events\UserRestored; use ProcessMaker\Events\UserUpdated; -use ProcessMaker\Http\Controllers\Api\Actions\Cases\DeleteCase; use ProcessMaker\Managers\SignalManager; use ProcessMaker\Models\EnvironmentVariable; use ProcessMaker\Models\Group; @@ -257,13 +256,13 @@ public function testCaseDeleted() { $caseNumber = 12345; - // Create a ProcessRequest with a case number ProcessRequest::factory() ->withCaseNumber($caseNumber) ->create(); - // Use the DeleteCase action to delete the case and dispatch the CaseDeleted event - (new DeleteCase)($caseNumber); + $response = $this->apiCall('DELETE', route('api.cases.destroy', ['case_number' => $caseNumber])); + + $response->assertStatus(204); $this->checkAssertsSegurityLog('CaseDeleted', 'deleted_at'); } From b0e70a2054c13e6b5845d8425a7d105c636043a5 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Fri, 16 Jan 2026 11:31:04 -0600 Subject: [PATCH 16/17] delete notifications tied to case on delete and add test --- .../Api/Actions/Cases/DeleteCase.php | 1 + .../Api/Actions/Cases/DeletesCaseRecords.php | 21 ++++++ tests/Feature/Api/CaseDeleteTest.php | 65 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php index d98f39e703..6d34bcf7b7 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeleteCase.php @@ -35,6 +35,7 @@ public function __invoke(string $caseNumber): void $this->deleteTaskDraftMedia($draftIds); $this->deleteTaskDrafts($tokenIds); $this->deleteComments($caseNumber, $requestIds, $tokenIds); + $this->deleteNotifications($requestIds); $this->deleteRequestMedia($requestIds); $this->deleteCaseNumbers($requestIds); $this->deleteCasesStarted($caseNumber); diff --git a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php index d8b3493ccf..f59850a67b 100644 --- a/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php +++ b/ProcessMaker/Http/Controllers/Api/Actions/Cases/DeletesCaseRecords.php @@ -11,6 +11,7 @@ use ProcessMaker\Models\InboxRule; use ProcessMaker\Models\InboxRuleLog; use ProcessMaker\Models\Media; +use ProcessMaker\Models\Notification; use ProcessMaker\Models\ProcessAbeRequestToken; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestLock; @@ -199,4 +200,24 @@ private function deleteComments(string $caseNumber, array $requestIds, array $to }) ->delete(); } + + private function deleteNotifications(array $requestIds): void + { + if ($requestIds === []) { + return; + } + + $notificationTypes = [ + 'COMMENT', + 'FILE_SHARED', + 'TASK_CREATED', + 'TASK_COMPLETED', + 'TASK_REASSIGNED', + ]; + + Notification::query() + ->whereIn('data->request_id', $requestIds) + ->whereIn('data->type', $notificationTypes) + ->delete(); + } } diff --git a/tests/Feature/Api/CaseDeleteTest.php b/tests/Feature/Api/CaseDeleteTest.php index 3673ed926f..01d205e39b 100644 --- a/tests/Feature/Api/CaseDeleteTest.php +++ b/tests/Feature/Api/CaseDeleteTest.php @@ -12,6 +12,7 @@ use ProcessMaker\Models\InboxRule; use ProcessMaker\Models\InboxRuleLog; use ProcessMaker\Models\Media; +use ProcessMaker\Models\Notification; use ProcessMaker\Models\ProcessAbeRequestToken; use ProcessMaker\Models\ProcessRequest; use ProcessMaker\Models\ProcessRequestLock; @@ -152,6 +153,70 @@ public function testDeleteCaseRemovesDependentRecords(): void } } + public function testDeleteCaseRemovesCaseNotifications(): void + { + $caseNumber = 13579; + $request = ProcessRequest::factory() + ->withCaseNumber($caseNumber) + ->create(); + $otherRequest = ProcessRequest::factory() + ->withCaseNumber($caseNumber + 1) + ->create(); + + $notificationTypes = [ + 'COMMENT', + 'FILE_SHARED', + 'TASK_CREATED', + 'TASK_COMPLETED', + 'TASK_REASSIGNED', + ]; + + $deletedNotificationIds = []; + foreach ($notificationTypes as $type) { + $deletedNotificationIds[] = Notification::factory()->create([ + 'notifiable_type' => get_class($this->user), + 'notifiable_id' => $this->user->getKey(), + 'data' => json_encode([ + 'type' => $type, + 'request_id' => $request->id, + 'url' => "/requests/{$request->id}", + ]), + 'url' => "/requests/{$request->id}", + ])->id; + } + + $keptDifferentRequest = Notification::factory()->create([ + 'notifiable_type' => get_class($this->user), + 'notifiable_id' => $this->user->getKey(), + 'data' => json_encode([ + 'type' => 'TASK_CREATED', + 'request_id' => $otherRequest->id, + 'url' => "/requests/{$otherRequest->id}", + ]), + 'url' => "/requests/{$otherRequest->id}", + ]); + + $keptDifferentType = Notification::factory()->create([ + 'notifiable_type' => get_class($this->user), + 'notifiable_id' => $this->user->getKey(), + 'data' => json_encode([ + 'type' => 'MESSAGE', + 'request_id' => $request->id, + 'url' => "/requests/{$request->id}", + ]), + 'url' => "/requests/{$request->id}", + ]); + + $response = $this->apiCall('DELETE', route('api.cases.destroy', ['case_number' => $caseNumber])); + + $response->assertStatus(204); + foreach ($deletedNotificationIds as $notificationId) { + $this->assertDatabaseMissing('notifications', ['id' => $notificationId]); + } + $this->assertDatabaseHas('notifications', ['id' => $keptDifferentRequest->id]); + $this->assertDatabaseHas('notifications', ['id' => $keptDifferentType->id]); + } + public function testDeleteCaseReturnsNotFoundWhenMissing(): void { $caseNumber = 99999; From ead6e29487ce6aff6d8c651b33f6e26f62feb031 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 20 Jan 2026 13:21:38 -0600 Subject: [PATCH 17/17] fix(cases): guard missing case requests before rendering detail --- ProcessMaker/Http/Controllers/CasesController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ProcessMaker/Http/Controllers/CasesController.php b/ProcessMaker/Http/Controllers/CasesController.php index c179bad86f..2dfcb6db82 100644 --- a/ProcessMaker/Http/Controllers/CasesController.php +++ b/ProcessMaker/Http/Controllers/CasesController.php @@ -65,6 +65,9 @@ public function show($case_number) // Get all the request related to this case number $allRequests = ProcessRequest::where('case_number', $case_number)->get(); + if ($allRequests->isEmpty()) { + abort(404); + } $parentRequest = null; $requestCount = $allRequests->count(); // Search the parent request parent_request_id and load $request @@ -74,6 +77,7 @@ public function show($case_number) break; } } + $request = $parentRequest ?: $allRequests->first(); $request->participants; $request->user; // Load the data and key values