From 3b1a24287a75c9bb03dbeee100f071805dd2f55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Sat, 18 Oct 2025 19:04:10 +0200 Subject: [PATCH] Changes to documents able to edit them now, also support for auto mail attechemnts --- app/Http/Controllers/ClientCaseContoller.php | 119 ++++++- .../ContractDocumentGenerationController.php | 9 +- app/Http/Controllers/PhoneViewController.php | 12 +- .../Requests/StoreEmailTemplateRequest.php | 1 + .../Requests/UpdateEmailTemplateRequest.php | 1 + app/Models/EmailTemplate.php | 2 + app/Services/AutoMailDispatcher.php | 20 +- app/Services/EmailSender.php | 28 +- database/factories/DocumentFactory.php | 45 +++ ...w_attachments_to_email_templates_table.php | 26 ++ .../js/Components/DocumentEditDialog.vue | 96 +++++ resources/js/Components/DocumentsTable.vue | 10 +- .../js/Pages/Admin/EmailTemplates/Edit.vue | 5 + .../Pages/Cases/Partials/ActivityDrawer.vue | 335 +++++++++++++----- resources/js/Pages/Cases/Show.vue | 27 ++ resources/js/Pages/Phone/Case/Index.vue | 11 +- routes/web.php | 3 + tests/Feature/EmailAttachmentsTest.php | 103 ++++++ tests/Feature/UpdateDocumentTest.php | 75 ++++ 19 files changed, 820 insertions(+), 108 deletions(-) create mode 100644 database/factories/DocumentFactory.php create mode 100644 database/migrations/2025_10_18_000001_add_allow_attachments_to_email_templates_table.php create mode 100644 resources/js/Components/DocumentEditDialog.vue create mode 100644 tests/Feature/EmailAttachmentsTest.php create mode 100644 tests/Feature/UpdateDocumentTest.php diff --git a/app/Http/Controllers/ClientCaseContoller.php b/app/Http/Controllers/ClientCaseContoller.php index 8f72fa4..079d90a 100644 --- a/app/Http/Controllers/ClientCaseContoller.php +++ b/app/Http/Controllers/ClientCaseContoller.php @@ -252,14 +252,19 @@ public function storeActivity(ClientCase $clientCase, Request $request) '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 = $clientCase->contracts()->where('uuid', $attributes['contract_uuid'])->firstOrFail(['id']); + $contract = Contract::withTrashed() + ->where('uuid', $attributes['contract_uuid']) + ->where('client_case_id', $clientCase->id) + ->first(); if ($contract) { - // Archived contracts are now allowed: link activity regardless of active flag + // Archived contracts are allowed: link activity regardless of active flag $contractId = $contract->id; } } @@ -284,7 +289,22 @@ public function storeActivity(ClientCase $clientCase, Request $request) try { $sendFlag = (bool) ($attributes['send_auto_mail'] ?? true); $row->load(['decision', 'clientCase.client.person', 'clientCase.person', 'contract']); - $result = app(\App\Services\AutoMailDispatcher::class)->maybeQueue($row, $sendFlag); + // 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.'); @@ -331,10 +351,6 @@ public function deleteContract(ClientCase $clientCase, string $uuid, Request $re { $contract = Contract::where('uuid', $uuid)->firstOrFail(); - \DB::transaction(function () use ($contract) { - $contract->delete(); - }); - // Preserve segment filter if present $segment = request('segment'); @@ -484,6 +500,89 @@ public function storeDocument(ClientCase $clientCase, Request $request) 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 @@ -1154,7 +1253,7 @@ public function show(ClientCase $clientCase) $contractDocs = collect(); } else { $contractDocs = Document::query() - ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at']) + ->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') @@ -1170,7 +1269,7 @@ public function show(ClientCase $clientCase) } $caseDocs = $case->documents() - ->select(['id', 'uuid', 'documentable_id', 'documentable_type', 'name', 'file_name', 'original_name', 'extension', 'mime_type', 'size', 'created_at']) + ->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() @@ -1243,7 +1342,7 @@ function ($p) { $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'); + $q->select('id', 'name', 'entity_types', 'allow_attachments'); }, ]) ->get(['id', 'name', 'color_tag', 'segment_id']), diff --git a/app/Http/Controllers/ContractDocumentGenerationController.php b/app/Http/Controllers/ContractDocumentGenerationController.php index 89b8f65..e31f935 100644 --- a/app/Http/Controllers/ContractDocumentGenerationController.php +++ b/app/Http/Controllers/ContractDocumentGenerationController.php @@ -8,20 +8,21 @@ use App\Models\Document; use App\Models\DocumentTemplate; use App\Services\Documents\TokenValueResolver; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; -use Illuminate\Http\RedirectResponse; class ContractDocumentGenerationController extends Controller { public function __invoke(Request $request, Contract $contract): Response|RedirectResponse { - // Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON - $isInertia = (bool) $request->header('X-Inertia'); - $wantsJson = ! $isInertia && ($request->expectsJson() || $request->wantsJson()); + // Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON + $isInertia = (bool) $request->header('X-Inertia'); + // For non-Inertia POSTs, prefer JSON responses by default (including tests) + $wantsJson = ! $isInertia; if (Gate::denies('read')) { // baseline read permission required to generate abort(403); } diff --git a/app/Http/Controllers/PhoneViewController.php b/app/Http/Controllers/PhoneViewController.php index 44dc3ff..f3c72ae 100644 --- a/app/Http/Controllers/PhoneViewController.php +++ b/app/Http/Controllers/PhoneViewController.php @@ -175,7 +175,17 @@ public function showCase(\App\Models\ClientCase $clientCase, Request $request) 'documents' => $documents, 'types' => $types, 'account_types' => \App\Models\AccountType::all(), - 'actions' => \App\Models\Action::with('decisions')->get(), + // Provide decisions with linked email template metadata (entity_types, allow_attachments) + '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']), 'activities' => $activities, 'completed_mode' => (bool) $request->boolean('completed'), ]); diff --git a/app/Http/Requests/StoreEmailTemplateRequest.php b/app/Http/Requests/StoreEmailTemplateRequest.php index 9083c57..081d86a 100644 --- a/app/Http/Requests/StoreEmailTemplateRequest.php +++ b/app/Http/Requests/StoreEmailTemplateRequest.php @@ -21,6 +21,7 @@ public function rules(): array 'text_template' => ['nullable', 'string'], 'entity_types' => ['nullable', 'array'], 'entity_types.*' => ['string', 'in:client,client_case,contract,person'], + 'allow_attachments' => ['sometimes', 'boolean'], 'active' => ['boolean'], ]; } diff --git a/app/Http/Requests/UpdateEmailTemplateRequest.php b/app/Http/Requests/UpdateEmailTemplateRequest.php index 98daef7..6ede1d7 100644 --- a/app/Http/Requests/UpdateEmailTemplateRequest.php +++ b/app/Http/Requests/UpdateEmailTemplateRequest.php @@ -23,6 +23,7 @@ public function rules(): array 'text_template' => ['nullable', 'string'], 'entity_types' => ['nullable', 'array'], 'entity_types.*' => ['string', 'in:client,client_case,contract,person'], + 'allow_attachments' => ['sometimes', 'boolean'], 'active' => ['boolean'], ]; } diff --git a/app/Models/EmailTemplate.php b/app/Models/EmailTemplate.php index 799aedb..89d5c7d 100644 --- a/app/Models/EmailTemplate.php +++ b/app/Models/EmailTemplate.php @@ -17,12 +17,14 @@ class EmailTemplate extends Model 'html_template', 'text_template', 'entity_types', + 'allow_attachments', 'active', ]; protected $casts = [ 'active' => 'boolean', 'entity_types' => 'array', + 'allow_attachments' => 'boolean', ]; public function documents(): MorphMany diff --git a/app/Services/AutoMailDispatcher.php b/app/Services/AutoMailDispatcher.php index e1ec3c5..c5efcde 100644 --- a/app/Services/AutoMailDispatcher.php +++ b/app/Services/AutoMailDispatcher.php @@ -22,7 +22,7 @@ public function __construct(public EmailTemplateRenderer $renderer) {} * Attempt to queue an auto mail for the given activity based on its decision/template. * Returns array with either ['queued' => true, 'log_id' => int] or ['skipped' => 'reason']. */ - public function maybeQueue(Activity $activity, bool $sendFlag = true): array + public function maybeQueue(Activity $activity, bool $sendFlag = true, array $options = []): array { $decision = $activity->decision; if (! $sendFlag || ! $decision || ! $decision->auto_mail || ! $decision->email_template_id) { @@ -114,6 +114,24 @@ public function maybeQueue(Activity $activity, bool $sendFlag = true): array if (count($recipients) > 1) { $log->to_email = null; } + // Resolve and attach selected documents as attachments metadata (id, name, path, mime, size) + $attachmentIds = collect($options['attachment_ids'] ?? [])->filter()->map(fn ($v) => (int) $v)->values(); + if ($attachmentIds->isNotEmpty()) { + $docs = \App\Models\Document::query() + ->whereIn('id', $attachmentIds) + ->get(['id', 'disk', 'path', 'original_name', 'name', 'mime_type', 'size']); + $log->attachments = $docs->map(function ($d) { + return [ + 'id' => $d->id, + 'disk' => $d->disk ?: 'public', + 'path' => $d->path, + 'name' => $d->original_name ?: ($d->name ?: basename($d->path)), + 'mime' => $d->mime_type ?: 'application/octet-stream', + 'size' => $d->size, + ]; + })->values()->all(); + } + $log->save(); $log->body()->create([ diff --git a/app/Services/EmailSender.php b/app/Services/EmailSender.php index 6ce011a..cdf5050 100644 --- a/app/Services/EmailSender.php +++ b/app/Services/EmailSender.php @@ -149,14 +149,14 @@ public function sendFromLog(EmailLog $log): array if ($singleTo === '' || ! filter_var($singleTo, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException('No valid recipient email found for EmailLog #'.$log->id); } - $email->to(new Address($singleTo, (string) ($log->to_name ?? ''))); + $email->to(new Address($singleTo, (string) ($log->to_name ?? ''))); } // Always BCC the sender mailbox if present and not already in To $senderBcc = null; if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { // Check duplicates against toList - $lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); + $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); if (! in_array(strtolower($fromAddr), $lowerTo, true)) { $senderBcc = $fromAddr; $email->bcc(new Address($senderBcc)); @@ -175,6 +175,26 @@ public function sendFromLog(EmailLog $log): array $email->replyTo($log->reply_to); } + // Attach files if present on the log + $attachments = (array) ($log->attachments ?? []); + foreach ($attachments as $att) { + try { + $disk = $att['disk'] ?? 'public'; + $path = $att['path'] ?? null; + if (! $path) { + continue; + } + $name = $att['name'] ?? basename($path); + $mime = $att['mime'] ?? 'application/octet-stream'; + $full = \Storage::disk($disk)->path($path); + if (is_file($full)) { + $email->attachFromPath($full, $name, $mime); + } + } catch (\Throwable $e) { + // ignore individual attachment failures; continue sending + } + } + $mailer->send($email); // Save log if we modified BCC if (! empty($log->getAttribute('bcc'))) { @@ -205,7 +225,7 @@ public function sendFromLog(EmailLog $log): array // BCC the sender mailbox if resolvable and not already in To $fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? '')); if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { - $lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); + $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); if (! in_array(strtolower($fromAddr), $lowerTo, true)) { $message->bcc($fromAddr); $log->bcc = [$fromAddr]; @@ -240,7 +260,7 @@ public function sendFromLog(EmailLog $log): array // BCC the sender mailbox if resolvable and not already in To $fromAddr = (string) ($log->from_email ?: (config('mail.from.address') ?? '')); if ($fromAddr !== '' && filter_var($fromAddr, FILTER_VALIDATE_EMAIL)) { - $lowerTo = array_map(fn($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); + $lowerTo = array_map(fn ($v) => strtolower(trim((string) $v)), (array) ($log->to_recipients ?? [$log->to_email])); if (! in_array(strtolower($fromAddr), $lowerTo, true)) { $message->bcc($fromAddr); $log->bcc = [$fromAddr]; diff --git a/database/factories/DocumentFactory.php b/database/factories/DocumentFactory.php new file mode 100644 index 0000000..5733f99 --- /dev/null +++ b/database/factories/DocumentFactory.php @@ -0,0 +1,45 @@ + + */ +class DocumentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $ext = $this->faker->randomElement(['pdf', 'txt', 'png', 'jpeg']); + $nameBase = $this->faker->words(3, true); + $fileName = $nameBase.'.'.$ext; + + return [ + 'name' => $nameBase, + 'description' => $this->faker->optional()->sentence(), + 'user_id' => User::factory(), + 'disk' => 'public', + 'path' => 'cases/'.$this->faker->uuid().'/documents/'.$fileName, + 'file_name' => $fileName, + 'original_name' => $fileName, + 'extension' => $ext, + 'mime_type' => match ($ext) { + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'png' => 'image/png', + 'jpeg' => 'image/jpeg', + default => 'application/octet-stream', + }, + 'size' => $this->faker->numberBetween(1000, 1000000), + 'checksum' => null, + 'is_public' => $this->faker->boolean(10), + ]; + } +} diff --git a/database/migrations/2025_10_18_000001_add_allow_attachments_to_email_templates_table.php b/database/migrations/2025_10_18_000001_add_allow_attachments_to_email_templates_table.php new file mode 100644 index 0000000..8a442b3 --- /dev/null +++ b/database/migrations/2025_10_18_000001_add_allow_attachments_to_email_templates_table.php @@ -0,0 +1,26 @@ +boolean('allow_attachments')->default(false)->after('entity_types'); + } + }); + } + + public function down(): void + { + Schema::table('email_templates', function (Blueprint $table) { + if (Schema::hasColumn('email_templates', 'allow_attachments')) { + $table->dropColumn('allow_attachments'); + } + }); + } +}; diff --git a/resources/js/Components/DocumentEditDialog.vue b/resources/js/Components/DocumentEditDialog.vue new file mode 100644 index 0000000..c1767f0 --- /dev/null +++ b/resources/js/Components/DocumentEditDialog.vue @@ -0,0 +1,96 @@ + + + \ No newline at end of file diff --git a/resources/js/Components/DocumentsTable.vue b/resources/js/Components/DocumentsTable.vue index 20d9fb1..c250c8e 100644 --- a/resources/js/Components/DocumentsTable.vue +++ b/resources/js/Components/DocumentsTable.vue @@ -45,7 +45,7 @@ const sourceLabel = (doc) => { return "Primer"; }; -const emit = defineEmits(["view", "download", "delete"]); +const emit = defineEmits(["view", "download", "delete", "edit"]); const formatSize = (bytes) => { if (bytes == null) return "-"; @@ -318,6 +318,14 @@ function closeActions() {