with(['person', '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(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' => date('Y-m-d', strtotime($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(); \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') ? date('Y-m-d', strtotime($request->input('start_date'))) : $contract->start_date, ]); $initial = $request->input('initial_amount'); $balance = $request->input('balance_amount'); $shouldUpsertAccount = (! is_null($initial)) || (! is_null($balance)) || $request->has('account_type_id'); if ($shouldUpsertAccount) { $accountData = []; // Track old balance before applying changes $oldBalance = (float) optional($contract->account)->balance_amount; if (! is_null($initial)) { $accountData['initial_amount'] = $initial; } if (! is_null($balance)) { $accountData['balance_amount'] = $balance; } if ($request->has('account_type_id')) { $accountData['type_id'] = $request->input('account_type_id'); } if ($contract->account) { $contract->account->update($accountData); } else { // For create, ensure defaults exist if not provided $accountData = array_merge(['initial_amount' => 0, 'balance_amount' => 0], $accountData); $contract->account()->create($accountData); } // After update/create, if balance_amount changed (and not through a payment), log an activity with before/after if (array_key_exists('balance_amount', $accountData)) { $newBalance = (float) optional($contract->account)->fresh()->balance_amount; if ($newBalance !== $oldBalance) { try { $currency = optional(\App\Models\PaymentSetting::query()->first())->default_currency ?? 'EUR'; $beforeStr = number_format($oldBalance, 2, ',', '.').' '.$currency; $afterStr = number_format($newBalance, 2, ',', '.').' '.$currency; $note = 'Sprememba stanja (Stanje pred: '.$beforeStr.', Stanje po: '.$afterStr.'; Izvor: sprememba)'; \App\Models\Activity::create([ 'due_date' => null, 'amount' => null, 'note' => $note, 'action_id' => null, 'decision_id' => null, 'client_case_id' => $contract->client_case_id, 'contract_id' => $contract->id, ]); } catch (\Throwable $e) { // non-fatal } } } } }); // Preserve segment filter if present $segment = request('segment'); return to_route('clientCase.show', ['client_case' => $clientCase, 'segment' => $segment]); } 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', ]); // Map contract_uuid to contract_id within the same client case, if provided $contractId = null; if (! empty($attributes['contract_uuid'])) { $contract = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail('id'); if ($contract) { $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); // 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(); \DB::transaction(function () use ($contract) { $contract->delete(); }); // 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(); \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(); \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(); } $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 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']), ])->where('active', 1)->findOrFail($clientCase->id); $types = [ 'address_types' => \App\Models\Person\AddressType::all(), 'phone_types' => \App\Models\Person\PhoneType::all(), ]; // Optional segment filter from query string $segmentId = request()->integer('segment'); // Prepare contracts and a reference map $contractsQuery = $case->contracts() ->with(['type', 'account', 'objects', 'segments:id,name']) ->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); }); } $contracts = $contractsQuery->get(); $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'); $contractDocs = Document::query() ->where('documentable_type', Contract::class) ->when($contractIds->isNotEmpty(), fn ($q) => $q->whereIn('documentable_id', $contractIds)) ->orderByDesc('created_at') ->get() ->map(function ($d) use ($contractRefMap) { $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d; $arr['contract_reference'] = $contractRefMap[$d->documentable_id] ?? null; $arr['documentable_type'] = Contract::class; $arr['contract_uuid'] = optional(Contract::withTrashed()->find($d->documentable_id))->uuid; return $arr; }); $caseDocs = $case->documents()->orderByDesc('created_at')->get()->map(function ($d) use ($case) { $arr = method_exists($d, 'toArray') ? $d->toArray() : (array) $d; $arr['documentable_type'] = ClientCase::class; $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', 'bankAccounts']))->firstOrFail(), 'client_case' => $case, 'contracts' => $contracts, '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(), 'actions' => \App\Models\Action::with('decisions')->get(), '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, ]); } /** * 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.'); } }