feat: AfterSales CRM - SOP compliant real-estate customer care system

- Departments + cross-department transfer workflow
- Role-based closing (staff→resolved, manager→closed) with proof_of_resolution
- Private file storage with collection system + temporary signed URLs
- Dashboard: resolved tickets table, stats, handler performance bar chart
- Spatie RBAC (admin/manager/staff)
- Notifications: TicketTransferred, TicketClosed (database + mail)
- Pest tests: 8 tests, 21 assertions
- Docker production-ready (Dockerfile + compose + entrypoint)
This commit is contained in:
2026-04-27 05:29:48 +00:00
parent 8ad7826f56
commit 887765bbd7
134 changed files with 17416 additions and 45 deletions

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Services;
use App\Models\FeedbackAttachment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class FileService
{
protected array $allowedMimes = [
'image/jpeg',
'image/jpg',
'image/png',
'application/pdf',
'video/mp4',
'video/quicktime',
'video/mov',
];
protected int $maxSize = 20480;
protected string $defaultDisk = 'local';
/**
* Upload files and create FeedbackAttachment records.
*/
public function upload(
array $files,
string $collection,
Model $model,
int $userId,
?int $interactionId = null,
string $disk = 'local',
): array {
$records = [];
foreach ($files as $file) {
if (! $file instanceof UploadedFile || ! $file->isValid()) {
continue;
}
$this->validate($file);
$path = $file->store('feedback-attachments', $disk);
$records[] = FeedbackAttachment::create([
'uuid' => (string) Str::uuid(),
'feedback_id' => $model instanceof \App\Models\Feedback ? $model->id : null,
'feedback_interaction_id' => $interactionId,
'user_id' => $userId,
'name' => $file->getClientOriginalName(),
'path' => $path,
'disk' => $disk,
'collection' => $collection,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
}
return $records;
}
/**
* Generate a temporary URL for a file on a private disk.
*/
public function getTemporaryUrl(FeedbackAttachment $attachment, int $minutes = 10): string
{
if ($attachment->disk === 'public') {
return Storage::disk('public')->url($attachment->path);
}
return URL::temporarySignedRoute(
'storage.local',
now()->addMinutes($minutes),
['path' => $attachment->path]
);
}
/**
* Get all attachments of a specific collection for a model.
*/
public function getByCollection(Model $model, string $collection)
{
$query = FeedbackAttachment::byCollection($collection);
if ($model instanceof \App\Models\Feedback) {
$query->where('feedback_id', $model->id);
}
return $query->orderBy('created_at', 'desc')->get();
}
/**
* Check if a model has at least one attachment in the given collection.
*/
public function hasCollection(Model $model, string $collection): bool
{
return $this->getByCollection($model, $collection)->isNotEmpty();
}
/**
* Validate file type and size.
*/
protected function validate(UploadedFile $file): void
{
$errors = [];
if (! in_array($file->getMimeType(), $this->allowedMimes)) {
$errors['file'] = __('File type :type is not allowed. Allowed: jpg, png, pdf, mp4, mov.', ['type' => $file->getMimeType()]);
}
if ($file->getSize() > $this->maxSize * 1024) {
$errors['file'] = __('File size exceeds :max KB.', ['max' => $this->maxSize]);
}
if (! empty($errors)) {
throw ValidationException::withMessages($errors);
}
}
}