Changes to documents able to edit them now, also support for auto mail attechemnts
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user