with(['person.client', 'client.person']) ->where('active', 1) ->when($request->input('search'), function ($que, $search) { $que->whereHas('person', function ($q) use ($search) { $q->where('full_name', 'ilike', '%'.$search.'%'); }); }) ->addSelect([ // Count of active contracts (a contract is considered active if it has an active pivot in contract_segment) 'active_contracts_count' => \DB::query() ->from('contracts') ->selectRaw('COUNT(*)') ->whereColumn('contracts.client_case_id', 'client_cases.id') ->whereNull('contracts.deleted_at') ->whereExists(function ($q) { $q->from('contract_segment') ->whereColumn('contract_segment.contract_id', 'contracts.id') ->where('contract_segment.active', true); }), // Sum of balances for accounts of active contracts 'active_contracts_balance_sum' => \DB::query() ->from('contracts') ->join('accounts', 'accounts.contract_id', '=', 'contracts.id') ->selectRaw('COALESCE(SUM(accounts.balance_amount), 0)') ->whereColumn('contracts.client_case_id', 'client_cases.id') ->whereNull('contracts.deleted_at') ->whereExists(function ($q) { $q->from('contract_segment') ->whereColumn('contract_segment.contract_id', 'contracts.id') ->where('contract_segment.active', true); }), ]) ->orderByDesc('created_at'); return Inertia::render('Cases/Index', [ 'client_cases' => $query ->paginate($request->integer('perPage', 15), ['*'], 'client-cases-page') ->withQueryString(), 'filters' => $request->only(['search']), ]); } /** * Show the form for creating a new resource. */ public function create() { // } /** * Store a newly created resource in storage. */ public function store(Request $request) { // $cuuid = $request->input('client_uuid'); $client = \App\Models\Client::where('uuid', $cuuid)->firstOrFail(); if (isset($client->id)) { \DB::transaction(function () use ($request, $client) { $pq = $request->input('person'); $person = $client->person()->create([ 'nu' => rand(100000, 200000), 'first_name' => $pq['first_name'], 'last_name' => $pq['last_name'], 'full_name' => $pq['full_name'], 'gender' => null, 'birthday' => null, 'tax_number' => $pq['tax_number'], 'social_security_number' => $pq['social_security_number'], 'description' => $pq['description'], 'group_id' => 2, 'type_id' => 1, ]); $person->addresses()->create([ 'address' => $pq['address']['address'], 'country' => $pq['address']['country'], 'type_id' => $pq['address']['type_id'], ]); $person->phones()->create([ 'nu' => $pq['phone']['nu'], 'country_code' => $pq['phone']['country_code'], 'type_id' => $pq['phone']['type_id'], ]); $person->clientCase()->create([ 'client_id' => $client->id, ]); }); } return to_route('client.show', $client); } public function storeContract(ClientCase $clientCase, StoreContractRequest $request) { \DB::transaction(function () use ($request, $clientCase) { // Create contract $contract = $clientCase->contracts()->create([ 'reference' => $request->input('reference'), 'start_date' => \App\Services\DateNormalizer::toDate($request->input('start_date')), 'type_id' => $request->input('type_id'), 'description' => $request->input('description'), ]); // Note: Contract config auto-application is handled in Contract model created hook. // Optionally create related account with amounts and/or type $initial = $request->input('initial_amount'); $balance = $request->input('balance_amount'); $accountTypeId = $request->input('account_type_id'); if (! is_null($initial) || ! is_null($balance) || ! is_null($accountTypeId)) { $contract->account()->create([ 'type_id' => $accountTypeId, 'initial_amount' => $initial ?? 0, 'balance_amount' => $balance ?? 0, ]); } }); // Preserve segment filter if present $segment = request('segment'); return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } public function updateContract(ClientCase $clientCase, string $uuid, UpdateContractRequest $request) { $contract = Contract::where('uuid', $uuid)->firstOrFail(); if (! $contract->active) { return back()->with('warning', __('contracts.edit_not_allowed_archived')); } \DB::transaction(function () use ($request, $contract) { $contract->update([ 'reference' => $request->input('reference'), 'type_id' => $request->input('type_id'), 'description' => $request->input('description'), 'start_date' => $request->filled('start_date') ? \App\Services\DateNormalizer::toDate($request->input('start_date')) : $contract->start_date, ]); $initial = $request->input('initial_amount'); // Use has() to distinguish between an omitted field and an explicit 0 / null intent $balanceFieldPresent = $request->has('balance_amount'); $balance = $balanceFieldPresent ? $request->input('balance_amount') : null; // Always allow updating existing account even if only balance set to 0 (or unchanged) so user can correct it. $hasType = $request->has('account_type_id'); $shouldUpsertAccount = ($contract->account()->exists()) || (! is_null($initial)) || $balanceFieldPresent || $hasType; if ($shouldUpsertAccount) { $accountData = []; // Track old balance before applying changes $currentAccount = $contract->account; // newest (latestOfMany) if (! is_null($initial)) { $accountData['initial_amount'] = $initial; } // If the balance field was present in the request payload we always apply it (allow setting to 0) if ($balanceFieldPresent) { // Allow explicitly setting to 0, fallback to 0 if null provided $accountData['balance_amount'] = $balance ?? 0; } if ($request->has('account_type_id')) { $accountData['type_id'] = $request->input('account_type_id'); } if ($currentAccount) { $currentAccount->update($accountData); if (array_key_exists('balance_amount', $accountData)) { $currentAccount->forceFill(['balance_amount' => $accountData['balance_amount']])->save(); $freshBal = (float) optional($currentAccount->fresh())->balance_amount; if ((float) $freshBal !== (float) $accountData['balance_amount']) { \DB::table('accounts') ->where('id', $currentAccount->id) ->update(['balance_amount' => $accountData['balance_amount'], 'updated_at' => now()]); $freshBal = (float) optional($currentAccount->fresh())->balance_amount; } } else { $freshBal = (float) optional($currentAccount->fresh())->balance_amount; } } else { $accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData); $created = $contract->account()->create($accountData); $freshBal = (float) optional($created->fresh())->balance_amount; } } }); // Preserve segment filter if present $segment = request('segment'); return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } /** * Debug endpoint: list all account rows for a contract (only in debug mode). */ public function debugContractAccounts(ClientCase $clientCase, string $uuid, Request $request) { abort_unless(config('app.debug'), 404); $contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(['id', 'uuid', 'reference']); $accounts = \DB::table('accounts') ->where('contract_id', $contract->id) ->orderBy('id') ->get(['id', 'contract_id', 'initial_amount', 'balance_amount', 'type_id', 'created_at', 'updated_at']); return response()->json([ 'contract' => $contract, 'accounts' => $accounts, 'count' => $accounts->count(), ]); } public function storeActivity(ClientCase $clientCase, Request $request) { try { $attributes = $request->validate([ 'due_date' => 'nullable|date', 'amount' => 'nullable|decimal:0,4', 'note' => 'nullable|string', 'action_id' => 'exists:\App\Models\Action,id', 'decision_id' => 'exists:\App\Models\Decision,id', 'contract_uuid' => 'nullable|uuid', 'send_auto_mail' => 'sometimes|boolean', 'attachment_document_ids' => 'sometimes|array', 'attachment_document_ids.*' => 'integer', ]); // Map contract_uuid to contract_id within the same client case, if provided $contractId = null; if (! empty($attributes['contract_uuid'])) { $contract = Contract::withTrashed() ->where('uuid', $attributes['contract_uuid']) ->where('client_case_id', $clientCase->id) ->first(); if ($contract) { // Archived contracts are allowed: link activity regardless of active flag $contractId = $contract->id; } } // Create activity $row = $clientCase->activities()->create([ 'due_date' => $attributes['due_date'] ?? null, 'amount' => $attributes['amount'] ?? null, 'note' => $attributes['note'] ?? null, 'action_id' => $attributes['action_id'], 'decision_id' => $attributes['decision_id'], 'contract_id' => $contractId, ]); /*foreach ($activity->decision->events as $e) { $class = '\\App\\Events\\' . $e->name; event(new $class($clientCase)); }*/ logger()->info('Activity successfully inserted', $attributes); // Auto mail dispatch (best-effort) try { $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']); // Filter attachments to those belonging to the selected contract $attachmentIds = collect($attributes['attachment_document_ids'] ?? []) ->filter() ->map(fn ($v) => (int) $v) ->values(); $validAttachmentIds = collect(); if ($attachmentIds->isNotEmpty() && $contractId) { $validAttachmentIds = \App\Models\Document::query() ->where('documentable_type', \App\Models\Contract::class) ->where('documentable_id', $contractId) ->whereIn('id', $attachmentIds) ->pluck('id'); } $result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag, [ 'attachment_ids' => $validAttachmentIds->all(), ]); if (($result['skipped'] ?? null) === 'missing-contract' && $sendFlag) { // If template requires contract and user attempted to send, surface a validation message return back()->with('warning', 'Email not queued: required contract is missing for the selected template.'); } if (($result['skipped'] ?? null) === 'no-recipients' && $sendFlag) { return back()->with('warning', 'Email not queued: no eligible client emails to receive auto mails.'); } } catch (\Throwable $e) { // Do not fail activity creation due to mailing issues logger()->warning('Auto mail dispatch failed: '.$e->getMessage()); } // Stay on the current page (desktop or phone) instead of forcing a redirect to the desktop route. // Use 303 to align with Inertia's recommended POST/Redirect/GET behavior. return back(303)->with('success', 'Successful created!'); } catch (QueryException $e) { logger()->error('Database error occurred:', ['error' => $e->getMessage()]); return back()->with('error', 'Failed to insert activity. '.$e->getMessage()); } catch (Exception $e) { logger()->error('An unexpected error occurred:', ['error' => $e->getMessage()]); // Return a generic error response return back()->with('error', 'An unexpected error occurred. Please try again later.'); } } public function deleteActivity(ClientCase $clientCase, \App\Models\Activity $activity, Request $request) { // Ensure activity belongs to this case if ($activity->client_case_id !== $clientCase->id) { abort(404); } \DB::transaction(function () use ($activity) { $activity->delete(); }); return back()->with('success', 'Activity deleted.'); } public function deleteContract(ClientCase $clientCase, string $uuid, Request $request) { $contract = Contract::where('uuid', $uuid)->firstOrFail(); // Preserve segment filter if present $segment = request('segment'); return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } public function updateContractSegment(ClientCase $clientCase, string $uuid, Request $request) { $validated = $request->validate([ 'segment_id' => ['required', 'integer', 'exists:segments,id'], ]); $contract = $clientCase->contracts()->where('uuid', $uuid)->firstOrFail(); // Safety: Disallow segment change if contract archived (inactive) if (! $contract->active) { return back()->with('warning', __('contracts.segment_change_not_allowed_archived')); } \DB::transaction(function () use ($contract, $validated) { // Deactivate current active relation(s) \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('active', true) ->update(['active' => false]); // Attach or update the selected segment as active $existing = \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('segment_id', $validated['segment_id']) ->first(); if ($existing) { \DB::table('contract_segment') ->where('id', $existing->id) ->update(['active' => true, 'updated_at' => now()]); } else { $contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]); } }); return back()->with('success', 'Contract segment updated.'); } public function attachSegment(ClientCase $clientCase, Request $request) { $validated = $request->validate([ 'segment_id' => ['required', 'integer', 'exists:segments,id'], 'contract_uuid' => ['nullable', 'uuid'], 'make_active_for_contract' => ['sometimes', 'boolean'], ]); \DB::transaction(function () use ($clientCase, $validated) { // Attach segment to client case if not already attached $attached = \DB::table('client_case_segment') ->where('client_case_id', $clientCase->id) ->where('segment_id', $validated['segment_id']) ->first(); if (! $attached) { $clientCase->segments()->attach($validated['segment_id'], ['active' => true]); } elseif (! $attached->active) { \DB::table('client_case_segment') ->where('id', $attached->id) ->update(['active' => true, 'updated_at' => now()]); } // Optionally make it active for a specific contract if (! empty($validated['contract_uuid']) && ($validated['make_active_for_contract'] ?? false)) { $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->firstOrFail(); if (! $contract->active) { // Prevent segment activation for archived contract return; // Silent; we still attach to case but do not alter archived contract } \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('active', true) ->update(['active' => false]); $existing = \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('segment_id', $validated['segment_id']) ->first(); if ($existing) { \DB::table('contract_segment') ->where('id', $existing->id) ->update(['active' => true, 'updated_at' => now()]); } else { $contract->segments()->attach($validated['segment_id'], ['active' => true, 'created_at' => now(), 'updated_at' => now()]); } } }); return back()->with('success', 'Segment attached to case.'); } public function storeDocument(ClientCase $clientCase, Request $request) { $validated = $request->validate([ 'file' => 'required|file|max:25600|mimes:doc,docx,pdf,txt,csv,xls,xlsx,jpeg,png', // 25MB and allowed types 'name' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_public' => 'sometimes|boolean', 'contract_uuid' => 'nullable|uuid', ]); $file = $validated['file']; $disk = 'public'; $contract = null; if (! empty($validated['contract_uuid'])) { $contract = $clientCase->contracts()->where('uuid', $validated['contract_uuid'])->first(); if ($contract && ! $contract->active) { return back()->with('warning', __('contracts.document_not_allowed_archived')); } } $directory = $contract ? ('contracts/'.$contract->uuid.'/documents') : ('cases/'.$clientCase->uuid.'/documents'); $path = $file->store($directory, $disk); $doc = new Document([ 'name' => $validated['name'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), 'description' => $validated['description'] ?? null, 'user_id' => optional($request->user())->id, 'disk' => $disk, 'path' => $path, 'file_name' => basename($path), 'original_name' => $file->getClientOriginalName(), 'extension' => $file->getClientOriginalExtension(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'checksum' => null, 'is_public' => (bool) ($validated['is_public'] ?? false), ]); if ($contract) { $contract->documents()->save($doc); } else { $clientCase->documents()->save($doc); } // Generate preview immediately for Office docs to avoid first-view delay $ext = strtolower($doc->extension ?? pathinfo($doc->original_name ?? $doc->file_name, PATHINFO_EXTENSION)); if (in_array($ext, ['doc', 'docx'])) { \App\Jobs\GenerateDocumentPreview::dispatch($doc->id); } return back()->with('success', 'Document uploaded.'); } public function updateDocument(ClientCase $clientCase, Document $document, Request $request) { // Validate that the document being updated is scoped to this case (or one of its contracts). // Ensure the document belongs to this case or its contracts $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; $belongsToContractOfCase = false; if ($document->documentable_type === Contract::class) { $belongsToContractOfCase = Contract::withTrashed() ->where('id', $document->documentable_id) ->where('client_case_id', $clientCase->id) ->exists(); } if (! ($belongsToCase || $belongsToContractOfCase)) { logger()->warning('Document update 404: document not in scope of client case or its contracts', [ 'doc_id' => $document->id, 'doc_uuid' => $document->uuid, 'doc_type' => $document->documentable_type, 'doc_doc_id' => $document->documentable_id, 'route_case_id' => $clientCase->id, 'route_case_uuid' => $clientCase->uuid, ]); abort(404); } // Strictly validate that provided contract_uuid (when present) belongs to THIS client case. // If a different case's contract UUID is provided, return a validation error (422) instead of falling back. $validated = $request->validate([ 'name' => 'nullable|string|max:255', 'description' => 'nullable|string', 'is_public' => 'sometimes|boolean', // Optional reassignment to a contract within the same case // Note: empty string explicitly means "move back to case" when the key exists in the request. 'contract_uuid' => [ 'nullable', 'uuid', \Illuminate\Validation\Rule::exists('contracts', 'uuid')->where(function ($q) use ($clientCase, $request) { // Allow empty string if key exists (handled later) by skipping exists check when empty $incoming = $request->input('contract_uuid'); if (is_null($incoming) || $incoming === '') { // Return a condition that always matches something harmless; exists rule is ignored in this case return $q; // no-op, DBAL will still run but empty will be caught by nullable } return $q->where('client_case_id', $clientCase->id); }), ], ]); // Basic attribute updates $document->name = $validated['name'] ?? $document->name; if (array_key_exists('description', $validated)) { $document->description = $validated['description']; } if (array_key_exists('is_public', $validated)) { $document->is_public = (bool) $validated['is_public']; } // Reassign to contract or back to case IF the key is present in the payload (explicit intent). if ($request->exists('contract_uuid')) { $incoming = $request->input('contract_uuid'); if ($incoming === '' || is_null($incoming)) { // Explicitly move relation back to the case $document->documentable_type = ClientCase::class; $document->documentable_id = $clientCase->id; } else { // Safe to resolve within this case due to the validation rule above $target = $clientCase->contracts()->where('uuid', $incoming)->firstOrFail(['id', 'uuid', 'active']); if (! $target->active) { return back()->with('warning', __('contracts.document_not_allowed_archived')); } $document->documentable_type = Contract::class; $document->documentable_id = $target->id; } } $document->save(); // Refresh documents list on page return back()->with('success', __('Document updated.')); } public function viewDocument(ClientCase $clientCase, Document $document, Request $request) { // Ensure the document belongs to this client case or its contracts $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; $belongsToContractOfCase = false; if ($document->documentable_type === Contract::class) { // Include soft-deleted contracts when verifying ownership to this case $belongsToContractOfCase = Contract::withTrashed() ->where('id', $document->documentable_id) ->where('client_case_id', $clientCase->id) ->exists(); } if (! ($belongsToCase || $belongsToContractOfCase)) { logger()->warning('Document view 404: document does not belong to case or its contracts', [ 'document_id' => $document->id, 'document_uuid' => $document->uuid, 'documentable_type' => $document->documentable_type, 'documentable_id' => $document->documentable_id, 'client_case_id' => $clientCase->id, 'client_case_uuid' => $clientCase->uuid, ]); abort(404); } // Optional: add authz checks here (e.g., policies) $disk = $document->disk ?: 'public'; // Normalize relative path (handle legacy 'public/' or 'public\\' prefixes and backslashes on Windows) $relPath = $document->path ?? ''; $relPath = str_replace('\\', '/', $relPath); // unify slashes $relPath = ltrim($relPath, '/'); if (str_starts_with($relPath, 'public/')) { $relPath = substr($relPath, 7); } // If a preview exists (e.g., PDF generated for doc/docx), stream that $previewDisk = config('files.preview_disk', 'public'); if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { $stream = Storage::disk($previewDisk)->readStream($document->preview_path); if ($stream === false) { abort(404); } return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ 'Content-Type' => $document->preview_mime ?: 'application/pdf', 'Content-Disposition' => 'inline; filename="'.addslashes(($document->original_name ?: $document->file_name).'.pdf').'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } // If it's a DOC/DOCX and no preview yet, queue generation and show 202 Accepted $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); if (in_array($ext, ['doc', 'docx'])) { \App\Jobs\GenerateDocumentPreview::dispatch($document->id); return response('Preview is being generated. Please try again shortly.', 202); } // Try multiple path candidates to account for legacy prefixes $candidates = []; $candidates[] = $relPath; // also try raw original (normalized slashes, trimmed) $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; if ($raw && $raw !== $relPath) { $candidates[] = $raw; } // if path accidentally contains 'storage/' prefix (public symlink), strip it if (str_starts_with($relPath, 'storage/')) { $candidates[] = substr($relPath, 8); } if ($raw && str_starts_with($raw, 'storage/')) { $candidates[] = substr($raw, 8); } $existsOnDisk = false; foreach ($candidates as $cand) { if (Storage::disk($disk)->exists($cand)) { $existsOnDisk = true; $relPath = $cand; break; } } if (! $existsOnDisk) { // Fallback: some legacy files may live directly under public/, attempt to stream from there $publicFull = public_path($relPath); $real = @realpath($publicFull); $publicRoot = @realpath(public_path()); $realN = $real ? str_replace('\\', '/', $real) : null; $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { logger()->info('Document view fallback: serving from public path', [ 'document_id' => $document->id, 'path' => $realN, ]); $fp = @fopen($real, 'rb'); if ($fp === false) { abort(404); } return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } logger()->warning('Document view 404: file missing on disk and public fallback failed', [ 'document_id' => $document->id, 'document_uuid' => $document->uuid, 'disk' => $disk, 'path' => $document->path, 'normalizedCandidates' => $candidates, 'public_candidate' => $publicFull, ]); abort(404); } $stream = Storage::disk($disk)->readStream($relPath); if ($stream === false) { logger()->warning('Document view: readStream failed, attempting fallbacks', [ 'document_id' => $document->id, 'disk' => $disk, 'relPath' => $relPath, ]); $headers = [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]; // Fallback 1: get() the bytes directly try { $bytes = Storage::disk($disk)->get($relPath); } catch (\Throwable $e) { $bytes = null; } if (! is_null($bytes) && $bytes !== false) { return response($bytes, 200, $headers); } // Fallback 2: open via absolute path (local driver) $abs = null; try { if (method_exists(Storage::disk($disk), 'path')) { $abs = Storage::disk($disk)->path($relPath); } } catch (\Throwable $e) { $abs = null; } if ($abs && is_file($abs)) { $fp = @fopen($abs, 'rb'); if ($fp !== false) { logger()->info('Document view fallback: serving from absolute storage path', [ 'document_id' => $document->id, 'abs' => str_replace('\\\\', '/', (string) realpath($abs)), ]); return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } // Fallback 3: serve from public path if available $publicFull = public_path($relPath); $real = @realpath($publicFull); $publicRoot = @realpath(public_path()); $realN = $real ? str_replace('\\\\', '/', $real) : null; $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { logger()->info('Document view fallback: serving from public path (post-readStream failure)', [ 'document_id' => $document->id, 'path' => $realN, ]); $fp = @fopen($real, 'rb'); if ($fp !== false) { return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } logger()->warning('Document view 404: all fallbacks failed after readStream failure', [ 'document_id' => $document->id, 'disk' => $disk, 'relPath' => $relPath, ]); abort(404); } return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => 'inline; filename="'.addslashes((($document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME)).'.'.strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)))).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } public function downloadDocument(ClientCase $clientCase, Document $document, Request $request) { $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; $belongsToContractOfCase = false; if ($document->documentable_type === Contract::class) { $belongsToContractOfCase = Contract::withTrashed() ->where('id', $document->documentable_id) ->where('client_case_id', $clientCase->id) ->exists(); } if (! ($belongsToCase || $belongsToContractOfCase)) { logger()->warning('Document download 404: document does not belong to case or its contracts', [ 'document_id' => $document->id, 'document_uuid' => $document->uuid, 'documentable_type' => $document->documentable_type, 'documentable_id' => $document->documentable_id, 'client_case_id' => $clientCase->id, 'client_case_uuid' => $clientCase->uuid, ]); abort(404); } $disk = $document->disk ?: 'public'; // Normalize relative path for Windows and legacy prefixes $relPath = $document->path ?? ''; $relPath = str_replace('\\', '/', $relPath); $relPath = ltrim($relPath, '/'); if (str_starts_with($relPath, 'public/')) { $relPath = substr($relPath, 7); } $candidates = []; $candidates[] = $relPath; $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; if ($raw && $raw !== $relPath) { $candidates[] = $raw; } if (str_starts_with($relPath, 'storage/')) { $candidates[] = substr($relPath, 8); } if ($raw && str_starts_with($raw, 'storage/')) { $candidates[] = substr($raw, 8); } $existsOnDisk = false; foreach ($candidates as $cand) { if (Storage::disk($disk)->exists($cand)) { $existsOnDisk = true; $relPath = $cand; break; } } if (! $existsOnDisk) { // Fallback to public/ direct path if present $publicFull = public_path($relPath); $real = @realpath($publicFull); $publicRoot = @realpath(public_path()); $realN = $real ? str_replace('\\', '/', $real) : null; $rootN = $publicRoot ? str_replace('\\', '/', $publicRoot) : null; if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { logger()->info('Document download fallback: serving from public path', [ 'document_id' => $document->id, 'path' => $realN, ]); $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; $fp = @fopen($real, 'rb'); if ($fp === false) { abort(404); } return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } logger()->warning('Document download 404: file missing on disk and public fallback failed', [ 'document_id' => $document->id, 'document_uuid' => $document->uuid, 'disk' => $disk, 'path' => $document->path, 'normalizedCandidates' => $candidates, 'public_candidate' => $publicFull, ]); abort(404); } $nameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); $name = $ext ? ($nameBase.'.'.$ext) : $nameBase; $stream = Storage::disk($disk)->readStream($relPath); if ($stream === false) { logger()->warning('Document download: readStream failed, attempting fallbacks', [ 'document_id' => $document->id, 'disk' => $disk, 'relPath' => $relPath, ]); $headers = [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]; // Fallback 1: get() the bytes directly try { $bytes = Storage::disk($disk)->get($relPath); } catch (\Throwable $e) { $bytes = null; } if (! is_null($bytes) && $bytes !== false) { return response($bytes, 200, $headers); } // Fallback 2: open via absolute storage path $abs = null; try { if (method_exists(Storage::disk($disk), 'path')) { $abs = Storage::disk($disk)->path($relPath); } } catch (\Throwable $e) { $abs = null; } if ($abs && is_file($abs)) { $fp = @fopen($abs, 'rb'); if ($fp !== false) { logger()->info('Document download fallback: serving from absolute storage path', [ 'document_id' => $document->id, 'abs' => str_replace('\\\\', '/', (string) realpath($abs)), ]); return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } // Fallback 3: serve from public path if available $publicFull = public_path($relPath); $real = @realpath($publicFull); $publicRoot = @realpath(public_path()); $realN = $real ? str_replace('\\\\', '/', $real) : null; $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { logger()->info('Document download fallback: serving from public path (post-readStream failure)', [ 'document_id' => $document->id, 'path' => $realN, ]); $fp = @fopen($real, 'rb'); if ($fp !== false) { return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } logger()->warning('Document download 404: all fallbacks failed after readStream failure', [ 'document_id' => $document->id, 'disk' => $disk, 'relPath' => $relPath, ]); abort(404); } return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => 'attachment; filename="'.addslashes($name).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } /** * View a contract document using contract route binding. */ public function viewContractDocument(Contract $contract, Document $document, Request $request) { // Ensure the document belongs to this contract (including trashed docs) $belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id; if (! $belongs) { abort(404); } // Reuse the existing logic by delegating to a small helper return $this->streamDocumentForDisk($document, inline: true); } /** * Download a contract document using contract route binding. */ public function downloadContractDocument(Contract $contract, Document $document, Request $request) { $belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id; if (! $belongs) { abort(404); } return $this->streamDocumentForDisk($document, inline: false); } /** * Internal helper to stream a document either inline or as attachment with all Windows/public fallbacks. */ protected function streamDocumentForDisk(Document $document, bool $inline = true) { $disk = $document->disk ?: 'public'; $relPath = $document->path ?? ''; $relPath = str_replace('\\', '/', $relPath); $relPath = ltrim($relPath, '/'); if (str_starts_with($relPath, 'public/')) { $relPath = substr($relPath, 7); } // Previews for DOC/DOCX $ext = strtolower(pathinfo($document->original_name ?: $document->file_name, PATHINFO_EXTENSION)); $previewDisk = config('files.preview_disk', 'public'); if ($inline && in_array($ext, ['doc', 'docx'])) { if ($document->preview_path && Storage::disk($previewDisk)->exists($document->preview_path)) { $stream = Storage::disk($previewDisk)->readStream($document->preview_path); if ($stream !== false) { $previewNameBase = $document->name ?: pathinfo($document->original_name ?: $document->file_name, PATHINFO_FILENAME); return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, [ 'Content-Type' => $document->preview_mime ?: 'application/pdf', 'Content-Disposition' => 'inline; filename="'.addslashes($previewNameBase.'.pdf').'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]); } } \App\Jobs\GenerateDocumentPreview::dispatch($document->id); return response('Preview is being generated. Please try again shortly.', 202); } // Try storage candidates $candidates = [$relPath]; $raw = $document->path ? ltrim(str_replace('\\', '/', $document->path), '/') : null; if ($raw && $raw !== $relPath) { $candidates[] = $raw; } if (str_starts_with($relPath, 'storage/')) { $candidates[] = substr($relPath, 8); } if ($raw && str_starts_with($raw, 'storage/')) { $candidates[] = substr($raw, 8); } $found = null; foreach ($candidates as $cand) { if (Storage::disk($disk)->exists($cand)) { $found = $cand; break; } } $headers = [ 'Content-Type' => $document->mime_type ?: 'application/octet-stream', 'Content-Disposition' => ($inline ? 'inline' : 'attachment').'; filename="'.addslashes($document->original_name ?: $document->file_name).'"', 'Cache-Control' => 'private, max-age=0, no-cache', 'Pragma' => 'no-cache', ]; if (! $found) { // public/ fallback $publicFull = public_path($relPath); $real = @realpath($publicFull); $publicRoot = @realpath(public_path()); $realN = $real ? str_replace('\\\\', '/', $real) : null; $rootN = $publicRoot ? str_replace('\\\\', '/', $publicRoot) : null; if ($realN && $rootN && str_starts_with($realN, $rootN) && is_file($real)) { $fp = @fopen($real, 'rb'); if ($fp !== false) { return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } abort(404); } $stream = Storage::disk($disk)->readStream($found); if ($stream !== false) { return response()->stream(function () use ($stream) { fpassthru($stream); }, 200, $headers); } // Fallbacks on readStream failure try { $bytes = Storage::disk($disk)->get($found); if (! is_null($bytes) && $bytes !== false) { return response($bytes, 200, $headers); } } catch (\Throwable $e) { } $abs = null; try { if (method_exists(Storage::disk($disk), 'path')) { $abs = Storage::disk($disk)->path($found); } } catch (\Throwable $e) { $abs = null; } if ($abs && is_file($abs)) { $fp = @fopen($abs, 'rb'); if ($fp !== false) { return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } // public/ again as last try $publicFull = public_path($found); $real = @realpath($publicFull); if ($real && is_file($real)) { $fp = @fopen($real, 'rb'); if ($fp !== false) { return response()->stream(function () use ($fp) { fpassthru($fp); }, 200, $headers); } } abort(404); } /** * Display the specified resource. */ public function show(ClientCase $clientCase) { $case = $clientCase::with([ 'person' => fn ($que) => $que->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']), ])->where('active', 1)->findOrFail($clientCase->id); $types = [ 'address_types' => \App\Models\Person\AddressType::all(), 'phone_types' => \App\Models\Person\PhoneType::all(), ]; // $active = false; // Optional segment filter from query string $segmentId = request()->integer('segment'); // Determine latest archive (non-reactivate) setting for this context to infer archive segment and related tables $latestArchiveSetting = \App\Models\ArchiveSetting::query() ->where('enabled', true) ->where(function ($q) { $q->whereNull('reactivate')->orWhere('reactivate', false); }) ->orderByDesc('id') ->first(); $archiveSegmentId = optional($latestArchiveSetting)->segment_id; // may be null $relatedArchiveTables = []; if ($latestArchiveSetting) { $entities = (array) $latestArchiveSetting->entities; foreach ($entities as $edef) { if (isset($edef['related']) && is_array($edef['related'])) { foreach ($edef['related'] as $rel) { $relatedArchiveTables[] = $rel; } } } $relatedArchiveTables = array_values(array_unique($relatedArchiveTables)); } // Prepare contracts and a reference map. // Only apply active/inactive filtering IF a segment filter is provided. $contractsQuery = $case->contracts() // Only select lean columns to avoid oversize JSON / headers (include description for UI display) ->select(['id', 'uuid', 'reference', 'start_date', 'end_date', 'description', 'meta', 'active', 'type_id', 'client_case_id', 'created_at']) ->with([ 'type:id,name', // Use closure for account to avoid ambiguous column names with latestOfMany join 'account' => function ($q) { $q->select([ 'accounts.id', 'accounts.contract_id', 'accounts.type_id', 'accounts.initial_amount', 'accounts.balance_amount', 'accounts.promise_date', 'accounts.created_at', 'accounts.updated_at', // include updated_at so FE can detect changes & for debugging ])->orderByDesc('accounts.id'); }, 'segments:id,name', // Eager load objects so newly created objects appear without full reload logic issues 'objects:id,contract_id,reference,name,description,type,created_at', ]); $contractsQuery->orderByDesc('created_at'); if (! empty($segmentId)) { // Filter to contracts that are in the provided segment and active on pivot $contractsQuery->whereExists(function ($q) use ($segmentId) { $q->from('contract_segment') ->whereColumn('contract_segment.contract_id', 'contracts.id') ->where('contract_segment.segment_id', $segmentId) ->where('contract_segment.active', true); }); } // NOTE: If a case has an extremely large number of contracts this can still be heavy. // Consider pagination or deferred (Inertia lazy) loading. For now, hard-cap to 500 to prevent // pathological memory / header growth. Frontend can request more via future endpoint. $contracts = $contractsQuery->limit(500)->get(); // TEMP DEBUG: log what balances are being sent to Inertia (remove once issue resolved) try { logger()->info('Show contracts balances', [ 'case_id' => $case->id, 'contract_count' => $contracts->count(), 'contracts' => $contracts->map(fn ($c) => [ 'id' => $c->id, 'uuid' => $c->uuid, 'reference' => $c->reference, 'account_id' => optional($c->account)->id, 'initial_amount' => optional($c->account)->initial_amount, 'balance_amount' => optional($c->account)->balance_amount, 'account_updated_at' => optional($c->account)->updated_at, ])->toArray(), ]); } catch (\Throwable $e) { // swallow } $contractRefMap = []; foreach ($contracts as $c) { $contractRefMap[$c->id] = $c->reference; } // Merge client case and contract documents into a single array and include contract reference when applicable $contractIds = $contracts->pluck('id'); // Include 'uuid' so frontend can build document routes (was causing missing 'document' param error) // IMPORTANT: If there are no contracts for this case we must NOT return all contract documents from other cases. if ($contractIds->isEmpty()) { $contractDocs = collect(); } else { $contractDocs = Document::query() ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) ->where('documentable_type', Contract::class) ->whereIn('documentable_id', $contractIds) ->orderByDesc('created_at') ->limit(300) // cap to prevent excessive payload; add pagination later if needed ->get() ->map(function ($d) use ($contractRefMap) { $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d; $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; $arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid; return $arr; }); } $caseDocs = $case->documents() ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at', 'is_public']) ->orderByDesc('created_at') ->limit(200) ->get() ->map(function ($d) use ($case) { $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d; $arr['client_case_uuid'] = $case->uuid; return $arr; }); $mergedDocs = $caseDocs ->concat($contractDocs) ->sortByDesc('created_at') ->values(); // Resolve current segment for display when filtered $currentSegment = null; if (! empty($segmentId)) { $currentSegment = \App\Models\Segment::query()->select('id', 'name')->find($segmentId); } return Inertia::render('Cases/Show', [ 'client' => $case->client()->with('person', fn ($q) => $q->with(['addresses', 'phones', 'emails', 'bankAccounts', 'client']))->firstOrFail(), 'client_case' => $case, 'contracts' => $contracts, // Active document templates for contracts (latest version per slug) 'contract_doc_templates' => \App\Models\DocumentTemplate::query() ->where('active', true) ->where('core_entity', 'contract') ->orderBy('slug') ->get(['id', 'name', 'slug', 'version', 'tokens', 'meta']) ->groupBy('slug') ->map(fn ($g) => $g->sortByDesc('version')->first()) ->values(), 'archive_meta' => [ 'archive_segment_id' => $archiveSegmentId, 'related_tables' => $relatedArchiveTables, ], 'activities' => tap( (function () use ($case, $segmentId, $contractIds) { $q = $case->activities() ->with(['action', 'decision', 'contract:id,uuid,reference', 'user:id,name']) ->orderByDesc('created_at'); if (! empty($segmentId)) { // Only activities for filtered contracts or unlinked (contract_id null) $q->where(function ($qq) use ($contractIds) { $qq->whereNull('contract_id'); if ($contractIds->isNotEmpty()) { $qq->orWhereIn('contract_id', $contractIds); } }); } return $q->paginate(20, ['*'], 'activities')->withQueryString(); })(), function ($p) { $p->getCollection()->transform(function ($a) { $a->setAttribute('user_name', optional($a->user)->name); return $a; }); } ), 'documents' => $mergedDocs, 'contract_types' => \App\Models\ContractType::whereNull('deleted_at')->get(), 'account_types' => \App\Models\AccountType::all(), // Include decisions with auto-mail metadata and the linked email template entity_types for UI logic 'actions' => \App\Models\Action::query() ->with([ 'decisions' => function ($q) { $q->select('decisions.id', 'decisions.name', 'decisions.color_tag', 'decisions.auto_mail', 'decisions.email_template_id'); }, 'decisions.emailTemplate' => function ($q) { $q->select('id', 'name', 'entity_types', 'allow_attachments'); }, ]) ->get(['id', 'name', 'color_tag', 'segment_id']), 'types' => $types, 'segments' => $case->segments()->wherePivot('active', true)->get(['segments.id', 'segments.name']), 'all_segments' => \App\Models\Segment::query()->where('active', true)->get(['id', 'name']), 'current_segment' => $currentSegment, // SMS helpers for per-case sending UI 'sms_profiles' => \App\Models\SmsProfile::query() ->select(['id', 'name', 'default_sender_id']) ->where('active', true) ->orderBy('name') ->get(), 'sms_senders' => \App\Models\SmsSender::query() ->select(['id', 'profile_id']) ->addSelect(\DB::raw('sname as name')) ->addSelect(\DB::raw('phone_number as phone')) ->orderBy('sname') ->get(), 'sms_templates' => \App\Models\SmsTemplate::query() ->select(['id', 'name', 'content', 'allow_custom_body']) ->orderBy('name') ->get(), ]); } /** * Show the form for editing the specified resource. */ public function edit(string $id) { // } /** * Update the specified resource in storage. */ public function update(Request $request, string $id) { // } /** * Remove the specified resource from storage. */ public function destroy(string $id) { // } /** * Delete a document that belongs either directly to the client case or to one of its (even soft deleted) contracts. */ public function deleteDocument(ClientCase $clientCase, Document $document, Request $request) { // Ownership check: direct case document? $belongsToCase = $document->documentable_type === ClientCase::class && $document->documentable_id === $clientCase->id; // Or document of a contract that belongs to this case (include trashed contracts) $belongsToContractOfCase = false; if ($document->documentable_type === Contract::class) { $belongsToContractOfCase = Contract::withTrashed() ->where('id', $document->documentable_id) ->where('client_case_id', $clientCase->id) ->exists(); } if (! ($belongsToCase || $belongsToContractOfCase)) { abort(404); } // (Optional future) $this->authorize('delete', $document); $document->delete(); // soft delete return $request->wantsJson() ? response()->json(['status' => 'ok']) : back()->with('success', 'Document deleted.'); } /** * Delete a document accessed through a contract route binding. */ public function deleteContractDocument(Contract $contract, Document $document, Request $request) { $belongs = $document->documentable_type === Contract::class && $document->documentable_id === $contract->id; if (! $belongs) { abort(404); } // (Optional future) $this->authorize('delete', $document); $document->delete(); return $request->wantsJson() ? response()->json(['status' => 'ok']) : back()->with('success', 'Document deleted.'); } /** * Manually archive a contract (flag active=0) and optionally its immediate financial relations. */ public function archiveContract(ClientCase $clientCase, string $uuid, Request $request) { $contract = Contract::query()->where('uuid', $uuid)->firstOrFail(); if ($contract->client_case_id !== $clientCase->id) { abort(404); } $reactivateRequested = (bool) $request->boolean('reactivate'); // Determine applicable settings based on intent (archive vs reactivate) if ($reactivateRequested) { $latestReactivate = \App\Models\ArchiveSetting::query() ->where('enabled', true) ->where('reactivate', true) ->whereIn('strategy', ['immediate', 'manual']) ->orderByDesc('id') ->first(); if (! $latestReactivate) { return back()->with('warning', __('contracts.reactivate_not_allowed')); } $settings = collect([$latestReactivate]); $hasReactivateRule = true; } else { $settings = \App\Models\ArchiveSetting::query() ->where('enabled', true) ->whereIn('strategy', ['immediate', 'manual']) ->where(function ($q) { // exclude reactivate-only rules from archive run $q->whereNull('reactivate')->orWhere('reactivate', false); }) ->get(); if ($settings->isEmpty()) { return back()->with('warning', __('contracts.no_archive_settings')); } $hasReactivateRule = false; } $executor = app(\App\Services\Archiving\ArchiveExecutor::class); $context = [ 'contract_id' => $contract->id, 'client_case_id' => $clientCase->id, ]; if ($contract->account) { $context['account_id'] = $contract->account->id; } $overall = []; $hadAnyEffect = false; foreach ($settings as $setting) { $res = $executor->executeSetting($setting, $context, optional($request->user())->id); foreach ($res as $table => $count) { $overall[$table] = ($overall[$table] ?? 0) + $count; if ($count > 0) { $hadAnyEffect = true; } } } if ($reactivateRequested && $hasReactivateRule) { // Reactivation path: ensure contract becomes active and soft-delete cleared. if ($contract->active == 0 || $contract->deleted_at) { $contract->forceFill(['active' => 1, 'deleted_at' => null])->save(); $overall['contracts_reactivated'] = ($overall['contracts_reactivated'] ?? 0) + 1; $hadAnyEffect = true; } } else { // Ensure the contract itself is archived even if rule conditions would have excluded it if (! empty($contract->getAttributes()) && $contract->active) { if (! array_key_exists('contracts', $overall)) { $contract->update(['active' => 0]); $overall['contracts'] = ($overall['contracts'] ?? 0) + 1; } else { $contract->refresh(); } $hadAnyEffect = true; } } // Create an Activity record logging this archive if an action or decision is tied to any setting if ($hadAnyEffect) { $activitySetting = $settings->first(fn ($s) => ! is_null($s->action_id) || ! is_null($s->decision_id)); if ($activitySetting) { try { if ($reactivateRequested) { $note = 'Ponovna aktivacija pogodba '.$contract->reference; } else { $noteKey = 'contracts.archived_activity_note'; $note = __($noteKey, ['reference' => $contract->reference]); if ($note === $noteKey) { $note = \Illuminate\Support\Facades\Lang::get($noteKey, ['reference' => $contract->reference], 'sl'); } } $activityData = [ 'client_case_id' => $clientCase->id, 'action_id' => $activitySetting->action_id, 'decision_id' => $activitySetting->decision_id, 'note' => $note, 'active' => 1, 'user_id' => optional($request->user())->id, ]; if ($reactivateRequested) { // Attach the contract_id when reactivated as per requirement $activityData['contract_id'] = $contract->id; } \App\Models\Activity::create($activityData); } catch (\Throwable $e) { logger()->warning('Failed to create archive/reactivate activity', [ 'error' => $e->getMessage(), 'contract_id' => $contract->id, 'setting_id' => optional($activitySetting)->id, 'reactivate' => $reactivateRequested, ]); } } } // If any archive setting specifies a segment_id, move the contract to that segment (archive bucket) $segmentSetting = $settings->first(fn ($s) => ! is_null($s->segment_id)); // for reactivation this is the single reactivation setting if segment specified if ($segmentSetting && $segmentSetting->segment_id) { try { $segmentId = $segmentSetting->segment_id; \DB::transaction(function () use ($contract, $segmentId, $clientCase) { // Ensure the segment is attached to the client case (activate if previously inactive) $casePivot = \DB::table('client_case_segment') ->where('client_case_id', $clientCase->id) ->where('segment_id', $segmentId) ->first(); if (! $casePivot) { \DB::table('client_case_segment')->insert([ 'client_case_id' => $clientCase->id, 'segment_id' => $segmentId, 'active' => true, 'created_at' => now(), 'updated_at' => now(), ]); } elseif (! $casePivot->active) { \DB::table('client_case_segment') ->where('id', $casePivot->id) ->update(['active' => true, 'updated_at' => now()]); } // Deactivate all current active contract segments \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('active', true) ->update(['active' => false, 'updated_at' => now()]); // Attach or activate the archive segment for this contract $existing = \DB::table('contract_segment') ->where('contract_id', $contract->id) ->where('segment_id', $segmentId) ->first(); if ($existing) { \DB::table('contract_segment') ->where('id', $existing->id) ->update(['active' => true, 'updated_at' => now()]); } else { \DB::table('contract_segment')->insert([ 'contract_id' => $contract->id, 'segment_id' => $segmentId, 'active' => true, 'created_at' => now(), 'updated_at' => now(), ]); } }); } catch (\Throwable $e) { logger()->warning('Failed to move contract to archive segment', [ 'error' => $e->getMessage(), 'contract_id' => $contract->id, 'segment_id' => $segmentSetting->segment_id, 'setting_id' => $segmentSetting->id, ]); } } $message = $reactivateRequested ? __('contracts.reactivated') : __('contracts.archived'); return back()->with('success', $message); } /** * Emergency: recreate a missing / soft-deleted person for a client case and re-link related data. */ public function emergencyCreatePerson(ClientCase $clientCase, Request $request) { $oldPersonId = $clientCase->person_id; /** @var \App\Models\Person\Person|null $existing */ $existing = \App\Models\Person\Person::withTrashed()->find($oldPersonId); if ($existing && ! $existing->trashed()) { return back()->with('flash', [ 'type' => 'info', 'message' => 'Person already exists – emergency creation not needed.', ]); } $data = $request->validate([ 'full_name' => ['nullable', 'string', 'max:255'], 'first_name' => ['nullable', 'string', 'max:255'], 'last_name' => ['nullable', 'string', 'max:255'], 'tax_number' => ['nullable', 'string', 'max:99'], 'social_security_number' => ['nullable', 'string', 'max:99'], 'description' => ['nullable', 'string', 'max:500'], ]); $fullName = $data['full_name'] ?? trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? '')); if ($fullName === '') { $fullName = 'Unknown Person'; } $newPerson = null; \DB::transaction(function () use ($oldPersonId, $clientCase, $fullName, $data, &$newPerson) { $newPerson = \App\Models\Person\Person::create([ 'nu' => null, 'first_name' => $data['first_name'] ?? null, 'last_name' => $data['last_name'] ?? null, 'full_name' => $fullName, 'gender' => null, 'birthday' => null, 'tax_number' => $data['tax_number'] ?? null, 'social_security_number' => $data['social_security_number'] ?? null, 'description' => $data['description'] ?? 'Emergency recreated person (case)', 'group_id' => 2, 'type_id' => 1, ]); // Re-point related data referencing old person $tables = [ 'emails', 'person_phones', 'person_addresses', 'bank_accounts', ]; foreach ($tables as $table) { \DB::table($table)->where('person_id', $oldPersonId)->update(['person_id' => $newPerson->id]); } // Update the client case $clientCase->person_id = $newPerson->id; $clientCase->save(); }); return back()->with('flash', [ 'type' => 'success', 'message' => 'New person created and case re-linked.', 'person_uuid' => $newPerson?->uuid, ]); } /** * Send an SMS to a specific phone that belongs to the client case person. */ public function sendSmsToPhone(ClientCase $clientCase, Request $request, int $phone_id) { $validated = $request->validate([ 'message' => ['required', 'string', 'max:1000'], 'delivery_report' => ['sometimes', 'boolean'], 'template_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_templates,id'], 'profile_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_profiles,id'], 'sender_id' => ['sometimes', 'nullable', 'integer', 'exists:sms_senders,id'], 'contract_uuid' => ['sometimes', 'nullable', 'uuid'], ]); // Ensure the phone belongs to the person of this case /** @var \App\Models\Person\PersonPhone|null $phone */ $phone = \App\Models\Person\PersonPhone::query() ->where('id', $phone_id) ->where('person_id', $clientCase->person_id) ->first(); if (! $phone) { abort(404); } // Resolve explicit profile/sender if provided; otherwise fallback to first active profile and its default sender /** @var \App\Models\SmsProfile|null $profile */ $profile = null; /** @var \App\Models\SmsSender|null $sender */ $sender = null; if (! empty($validated['sender_id']) && empty($validated['profile_id'])) { // Infer profile from sender if not explicitly provided $sender = \App\Models\SmsSender::query()->find($validated['sender_id']); if ($sender) { $profile = \App\Models\SmsProfile::query()->find($sender->profile_id); } } if (! empty($validated['profile_id'])) { $profile = \App\Models\SmsProfile::query()->where('id', $validated['profile_id'])->first(); if (! $profile) { return back()->with('error', 'Izbran SMS profil ne obstaja.'); } if (property_exists($profile, 'active') && ! $profile->active) { return back()->with('error', 'Izbran SMS profil ni aktiven.'); } } if (! empty($validated['sender_id'])) { $sender = \App\Models\SmsSender::query()->find($validated['sender_id']); if (! $sender) { return back()->with('error', 'Izbran pošiljatelj ne obstaja.'); } if ($profile && (int) $sender->profile_id !== (int) $profile->id) { return back()->with('error', 'Izbran pošiljatelj ne pripada izbranemu profilu.'); } } if (! $profile) { $profile = \App\Models\SmsProfile::query() ->where('active', true) ->orderByRaw('CASE WHEN default_sender_id IS NULL THEN 1 ELSE 0 END') ->orderBy('id') ->first(); } if (! $profile) { return back()->with('warning', 'Ni aktivnega SMS profila.'); } if (! $sender && ! empty($profile->default_sender_id)) { $sender = \App\Models\SmsSender::query()->find($profile->default_sender_id); } try { /** @var \App\Services\Sms\SmsService $sms */ $sms = app(\App\Services\Sms\SmsService::class); // Check available credits before enqueueing (fail-closed) try { $raw = (string) $sms->getCreditBalance($profile); $num = null; if ($raw !== '') { $normalized = str_replace(',', '.', trim($raw)); if (preg_match('/-?\d+(?:\.\d+)?/', $normalized, $m)) { $num = (float) ($m[0] ?? null); } } if (! is_null($num) && $num <= 0.0) { return back()->with('error', 'No credits left.'); } } catch (\Throwable $e) { \Log::warning('SMS credit balance check failed', [ 'error' => $e->getMessage(), 'profile_id' => $profile->id, ]); return back()->with('error', 'Unable to verify SMS credits.'); } // Queue the SMS send; activity will be created in the job on success if a template is provided \App\Jobs\SendSmsJob::dispatch( profileId: $profile->id, to: (string) $phone->nu, content: (string) $validated['message'], senderId: $sender?->id, countryCode: $phone->country_code ?: null, deliveryReport: (bool) ($validated['delivery_report'] ?? false), clientReference: null, templateId: $validated['template_id'] ?? null, clientCaseId: $clientCase->id, userId: optional($request->user())->id, ); return back()->with('success', 'SMS je bil dodan v čakalno vrsto.'); } catch (\Throwable $e) { \Log::warning('SMS enqueue failed', [ 'error' => $e->getMessage(), 'case_id' => $clientCase->id, 'phone_id' => $phone_id, ]); return back()->with('error', 'SMS ni bil dodan v čakalno vrsto.'); } } /** * Return contracts for the given client case (for SMS dialog dropdown). */ public function listContracts(ClientCase $clientCase) { $contracts = $clientCase->contracts() ->with('account.type') ->select('id', 'uuid', 'reference', 'active', 'start_date', 'end_date') ->latest('id') ->get() ->map(function ($c) { /** @var SmsService $sms */ $sms = app(SmsService::class); $acc = $c->account; $initialRaw = $acc?->initial_amount !== null ? (string) $acc->initial_amount : null; $balanceRaw = $acc?->balance_amount !== null ? (string) $acc->balance_amount : null; return [ 'uuid' => $c->uuid, 'reference' => $c->reference, 'active' => (bool) $c->active, 'start_date' => (string) ($c->start_date ?? ''), 'end_date' => (string) ($c->end_date ?? ''), 'account' => $acc ? [ 'reference' => $acc->reference, 'type' => $acc->type?->name, 'initial_amount' => $initialRaw !== null ? $sms->formatAmountEu($initialRaw) : null, 'balance_amount' => $balanceRaw !== null ? $sms->formatAmountEu($balanceRaw) : null, 'initial_amount_raw' => $initialRaw, 'balance_amount_raw' => $balanceRaw, ] : null, ]; }); return response()->json(['data' => $contracts]); } /** * Render an SMS template preview with optional contract/account placeholders filled. */ public function previewSms(ClientCase $clientCase, Request $request, SmsService $sms) { $validated = $request->validate([ 'template_id' => ['required', 'integer', 'exists:sms_templates,id'], 'contract_uuid' => ['sometimes', 'nullable', 'uuid'], ]); /** @var \App\Models\SmsTemplate $template */ $template = \App\Models\SmsTemplate::findOrFail((int) $validated['template_id']); $vars = []; $contractUuid = $validated['contract_uuid'] ?? null; if ($contractUuid) { // Ensure the contract belongs to this client case $contract = $clientCase->contracts()->where('uuid', $contractUuid) ->with('account.type') ->first(); if ($contract) { $vars['contract'] = [ 'id' => $contract->id, 'uuid' => $contract->uuid, 'reference' => $contract->reference, 'start_date' => (string) ($contract->start_date ?? ''), 'end_date' => (string) ($contract->end_date ?? ''), ]; if ($contract->account) { $initialRaw = (string) $contract->account->initial_amount; $balanceRaw = (string) $contract->account->balance_amount; $vars['account'] = [ 'id' => $contract->account->id, 'reference' => $contract->account->reference, 'initial_amount' => $sms->formatAmountEu($initialRaw), 'balance_amount' => $sms->formatAmountEu($balanceRaw), 'initial_amount_raw' => $initialRaw, 'balance_amount_raw' => $balanceRaw, 'type' => $contract->account->type?->name, ]; } } } $content = $sms->renderContent($template->content, $vars); return response()->json([ 'content' => $content, 'variables' => $vars, ]); } }