Changes to documents able to edit them now, also support for auto mail attechemnts

This commit is contained in:
Simon Pocrnjič
2025-10-18 19:04:10 +02:00
parent 761799bdbe
commit 3b1a24287a
19 changed files with 820 additions and 108 deletions
+109 -10
View File
@@ -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']),
@@ -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);
}
+11 -1
View File
@@ -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'),
]);
@@ -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'],
];
}
@@ -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'],
];
}