Phase 3 asset storage management
This commit is contained in:
@@ -37,6 +37,11 @@ BROADCAST_CONNECTION=log
|
|||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
TSE_ATTACHMENT_DISK=public
|
||||||
|
TSE_ATTACHMENT_ROOT=attachments
|
||||||
|
TSE_ATTACHMENT_MAX_KB=10240
|
||||||
|
TSE_ATTACHMENT_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re
|
|||||||
- Documented `pdo_sqlite` and `sqlite3` as required PHP extensions for SQLite-backed local tests and first-pass deployments.
|
- Documented `pdo_sqlite` and `sqlite3` as required PHP extensions for SQLite-backed local tests and first-pass deployments.
|
||||||
- Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout.
|
- Documented the preferred local-to-GitHub-to-remote-update workflow and the ChristIT conversion from rsync test deploy to Git checkout.
|
||||||
- Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states.
|
- Added Phase 2 directory quality improvements with advanced provider/buyer filters, sort controls, profile badges, rating-aware cards, result counts, and empty states.
|
||||||
|
- Added Phase 3 asset and storage management with configurable attachment disk/root/size/MIME policy, image previews, file metadata display, attachment deletion, update-script storage linking, and upload policy tests.
|
||||||
- Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts.
|
- Created the Laravel Provider Exchange prototype with Breeze authentication, seeded demo users, and role support for admins, providers, buyers, and hybrid accounts.
|
||||||
- Added provider and buyer profile management with service areas, profile details, and external profile snapshot records.
|
- Added provider and buyer profile management with service areas, profile details, and external profile snapshot records.
|
||||||
- Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
|
- Added social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance.
|
||||||
|
|||||||
@@ -8,32 +8,46 @@ use App\Models\JobPost;
|
|||||||
use App\Models\ProviderProfile;
|
use App\Models\ProviderProfile;
|
||||||
use App\Models\SocialPost;
|
use App\Models\SocialPost;
|
||||||
use App\Models\WorkOrder;
|
use App\Models\WorkOrder;
|
||||||
|
use App\Models\Attachment;
|
||||||
|
use App\Models\BuyerProfile;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class AttachmentController extends Controller
|
class AttachmentController extends Controller
|
||||||
{
|
{
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
$maxKb = (int) config('provider-exchange.attachments.max_kb');
|
||||||
|
$mimeTypes = (array) config('provider-exchange.attachments.allowed_mime_types');
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'],
|
'attachable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,social_post,job_post,work_order,dispute,external_profile_import'],
|
||||||
'attachable_id' => ['required', 'integer'],
|
'attachable_id' => ['required', 'integer'],
|
||||||
'kind' => ['nullable', 'string', 'max:255'],
|
'kind' => ['nullable', 'string', 'max:255'],
|
||||||
'caption' => ['nullable', 'string', 'max:1000'],
|
'caption' => ['nullable', 'string', 'max:1000'],
|
||||||
'file' => ['required', 'file', 'max:10240'],
|
'file' => ['required', 'file', 'max:'.$maxKb, 'mimetypes:'.implode(',', $mimeTypes)],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']);
|
$attachable = $this->resolveAttachable($data['attachable_type'], (int) $data['attachable_id']);
|
||||||
$this->authorizeAttachment($request, $attachable);
|
$this->authorizeAttachment($request, $attachable);
|
||||||
|
|
||||||
$file = $request->file('file');
|
$file = $request->file('file');
|
||||||
$path = $file->store('attachments/'.Str::slug($data['attachable_type']), 'public');
|
$disk = (string) config('provider-exchange.attachments.disk');
|
||||||
|
$root = trim((string) config('provider-exchange.attachments.root'), '/');
|
||||||
|
$directory = implode('/', array_filter([
|
||||||
|
$root ?: 'attachments',
|
||||||
|
Str::slug($data['attachable_type']),
|
||||||
|
now()->format('Y'),
|
||||||
|
now()->format('m'),
|
||||||
|
]));
|
||||||
|
$path = $file->store($directory, $disk);
|
||||||
|
|
||||||
$attachable->attachments()->create([
|
$attachable->attachments()->create([
|
||||||
'user_id' => $request->user()->id,
|
'user_id' => $request->user()->id,
|
||||||
'kind' => $data['kind'] ?? 'general',
|
'kind' => $data['kind'] ?? 'general',
|
||||||
'disk' => 'public',
|
'disk' => $disk,
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'original_name' => $file->getClientOriginalName(),
|
'original_name' => $file->getClientOriginalName(),
|
||||||
'mime_type' => $file->getClientMimeType(),
|
'mime_type' => $file->getClientMimeType(),
|
||||||
@@ -44,11 +58,35 @@ class AttachmentController extends Controller
|
|||||||
return back()->with('status', 'File uploaded.');
|
return back()->with('status', 'File uploaded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Attachment $attachment): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->authorizeAttachment($request, $attachment->attachable);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$attachment->user_id === $request->user()->id
|
||||||
|
|| $request->user()->hasRole('admin')
|
||||||
|
|| $this->ownsAttachable($request, $attachment->attachable)
|
||||||
|
) {
|
||||||
|
$disk = $attachment->disk;
|
||||||
|
$path = $attachment->path;
|
||||||
|
$attachment->delete();
|
||||||
|
|
||||||
|
$stillReferenced = Attachment::where('disk', $disk)->where('path', $path)->exists();
|
||||||
|
if (! $stillReferenced) {
|
||||||
|
Storage::disk($disk)->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', 'File deleted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveAttachable(string $type, int $id): mixed
|
private function resolveAttachable(string $type, int $id): mixed
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
'provider_profile' => ProviderProfile::findOrFail($id),
|
'provider_profile' => ProviderProfile::findOrFail($id),
|
||||||
'buyer_profile' => \App\Models\BuyerProfile::findOrFail($id),
|
'buyer_profile' => BuyerProfile::findOrFail($id),
|
||||||
'social_post' => SocialPost::findOrFail($id),
|
'social_post' => SocialPost::findOrFail($id),
|
||||||
'job_post' => JobPost::findOrFail($id),
|
'job_post' => JobPost::findOrFail($id),
|
||||||
'work_order' => WorkOrder::findOrFail($id),
|
'work_order' => WorkOrder::findOrFail($id),
|
||||||
@@ -63,7 +101,7 @@ class AttachmentController extends Controller
|
|||||||
|
|
||||||
if ($attachable instanceof ProviderProfile) {
|
if ($attachable instanceof ProviderProfile) {
|
||||||
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
|
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
|
||||||
} elseif ($attachable instanceof \App\Models\BuyerProfile) {
|
} elseif ($attachable instanceof BuyerProfile) {
|
||||||
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
|
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
|
||||||
} elseif ($attachable instanceof SocialPost) {
|
} elseif ($attachable instanceof SocialPost) {
|
||||||
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
|
abort_unless($attachable->user_id === $user->id || $user->hasRole('admin'), 403);
|
||||||
@@ -77,4 +115,20 @@ class AttachmentController extends Controller
|
|||||||
abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403);
|
abort_unless($attachable->providerProfile->user_id === $user->id || $user->hasRole('admin'), 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ownsAttachable(Request $request, mixed $attachable): bool
|
||||||
|
{
|
||||||
|
$userId = $request->user()->id;
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$attachable instanceof ProviderProfile => $attachable->user_id === $userId,
|
||||||
|
$attachable instanceof BuyerProfile => $attachable->user_id === $userId,
|
||||||
|
$attachable instanceof SocialPost => $attachable->user_id === $userId,
|
||||||
|
$attachable instanceof JobPost => $attachable->buyer_id === $userId,
|
||||||
|
$attachable instanceof WorkOrder => in_array($userId, [$attachable->buyer_id, $attachable->provider_id], true),
|
||||||
|
$attachable instanceof Dispute => in_array($userId, [$attachable->workOrder->buyer_id, $attachable->workOrder->provider_id], true),
|
||||||
|
$attachable instanceof ExternalProfileImport => $attachable->providerProfile->user_id === $userId,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])]
|
#[Fillable(['user_id', 'kind', 'disk', 'path', 'original_name', 'mime_type', 'size', 'caption'])]
|
||||||
class Attachment extends Model
|
class Attachment extends Model
|
||||||
@@ -19,4 +20,33 @@ class Attachment extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function publicUrl(): string
|
||||||
|
{
|
||||||
|
if ($this->disk === 'public') {
|
||||||
|
return asset('storage/'.$this->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Storage::disk($this->disk)->url($this->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formattedSize(): string
|
||||||
|
{
|
||||||
|
$bytes = max(0, (int) $this->size);
|
||||||
|
|
||||||
|
if ($bytes >= 1048576) {
|
||||||
|
return round($bytes / 1048576, 1).' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bytes >= 1024) {
|
||||||
|
return round($bytes / 1024, 1).' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes.' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isImage(): bool
|
||||||
|
{
|
||||||
|
return str_starts_with((string) $this->mime_type, 'image/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'attachments' => [
|
||||||
|
'disk' => env('TSE_ATTACHMENT_DISK', 'public'),
|
||||||
|
'root' => trim(env('TSE_ATTACHMENT_ROOT', 'attachments'), '/'),
|
||||||
|
'max_kb' => (int) env('TSE_ATTACHMENT_MAX_KB', 10240),
|
||||||
|
'allowed_mime_types' => array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', env('TSE_ATTACHMENT_MIME_TYPES', 'image/jpeg,image/png,image/webp,application/pdf,text/plain,text/csv,application/zip'))
|
||||||
|
))),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -27,6 +27,12 @@ This document records the explicit runtime requirements and server changes for P
|
|||||||
- `storage/`
|
- `storage/`
|
||||||
- `bootstrap/cache/`
|
- `bootstrap/cache/`
|
||||||
- the SQLite database file for SQLite test deployments.
|
- the SQLite database file for SQLite test deployments.
|
||||||
|
- Public attachment URLs require `php artisan storage:link`; the update script now runs it idempotently.
|
||||||
|
- Attachment policy environment variables:
|
||||||
|
- `TSE_ATTACHMENT_DISK`, default `public`.
|
||||||
|
- `TSE_ATTACHMENT_ROOT`, default `attachments`.
|
||||||
|
- `TSE_ATTACHMENT_MAX_KB`, default `10240`.
|
||||||
|
- `TSE_ATTACHMENT_MIME_TYPES`, comma-separated allowlist.
|
||||||
|
|
||||||
## Local Test Requirements
|
## Local Test Requirements
|
||||||
|
|
||||||
@@ -71,6 +77,9 @@ Initial test data/runtime:
|
|||||||
- `CACHE_STORE=database`.
|
- `CACHE_STORE=database`.
|
||||||
- `QUEUE_CONNECTION=database`.
|
- `QUEUE_CONNECTION=database`.
|
||||||
- `MAIL_MAILER=log`.
|
- `MAIL_MAILER=log`.
|
||||||
|
- `TSE_ATTACHMENT_DISK=public`.
|
||||||
|
- `TSE_ATTACHMENT_ROOT=attachments`.
|
||||||
|
- `TSE_ATTACHMENT_MAX_KB=10240`.
|
||||||
|
|
||||||
## ChristIT Deployment Log
|
## ChristIT Deployment Log
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ The preferred working loop for this project is:
|
|||||||
|
|
||||||
- The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer.
|
- The installer is a first-pass Ubuntu/Nginx helper, not a universal hosting panel installer.
|
||||||
- Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`.
|
- Local and CI test runners need PHP SQLite support: `pdo_sqlite` and `sqlite3`.
|
||||||
|
- Public attachment serving needs `php artisan storage:link`; the checked-in update script runs this automatically.
|
||||||
|
- Tune upload policy with `TSE_ATTACHMENT_DISK`, `TSE_ATTACHMENT_ROOT`, `TSE_ATTACHMENT_MAX_KB`, and `TSE_ATTACHMENT_MIME_TYPES`.
|
||||||
- Review `.env` before production use.
|
- Review `.env` before production use.
|
||||||
- Configure real mail before inviting users.
|
- Configure real mail before inviting users.
|
||||||
- Move sessions, cache, and queues to Redis before serious traffic.
|
- Move sessions, cache, and queues to Redis before serious traffic.
|
||||||
|
|||||||
+14
-9
@@ -13,6 +13,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
|||||||
- Quote revisions, quote decline flow, and quote acceptance.
|
- Quote revisions, quote decline flow, and quote acceptance.
|
||||||
- Work-order creation from accepted quotes.
|
- Work-order creation from accepted quotes.
|
||||||
- Work-order status transitions, messages, attachments, deliverables, and completion notes.
|
- Work-order status transitions, messages, attachments, deliverables, and completion notes.
|
||||||
|
- Configurable attachment storage policy with upload root, disk, size cap, MIME allowlist, image previews, file metadata, and deletion.
|
||||||
- Mutual reviews with category metrics.
|
- Mutual reviews with category metrics.
|
||||||
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns.
|
- Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns.
|
||||||
- Universal community ratings for buyers, providers, job posts, work orders, and disputes.
|
- Universal community ratings for buyers, providers, job posts, work orders, and disputes.
|
||||||
@@ -35,15 +36,17 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
|||||||
|
|
||||||
1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries.
|
1. Profile depth: richer galleries, equipment lists, certifications, insurance fields, service coverage maps, and imported review summaries.
|
||||||
2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages.
|
2. Directory quality: saved searches, better filtering, profile badges, availability signals, and stronger buyer/provider discovery pages.
|
||||||
3. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets.
|
3. Asset and storage management: configurable upload roots, file policy, attachment previews, deletion, and update-path storage link handling.
|
||||||
4. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views.
|
4. Work-order depth: checklist templates, required evidence rules, appointment windows, onsite timestamps, change-request records, and print/PDF export for work-order packets.
|
||||||
5. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools.
|
5. Reputation refinement: rating category definitions, anti-abuse controls, edit windows, report flows, and moderation views.
|
||||||
6. Notifications: channel preferences, email templates, and event-specific subscription controls.
|
6. Dispute workflow: better evidence timelines, reason-coded votes, quorum/visibility rules, and admin/community moderation tools.
|
||||||
7. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions.
|
7. Notifications: channel preferences, email templates, and event-specific subscription controls.
|
||||||
8. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history.
|
8. API/mobile prep: token-scoped endpoints, versioned API resources, geolocation check-in groundwork, and mobile-safe work-order actions.
|
||||||
9. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows.
|
9. Import tooling: guided manual imports for Field Nation, WorkMarket, and similar profile/review history.
|
||||||
10. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes.
|
10. Competency tags and levels: provider/buyer tag taxonomies, smart-hands entry lane, specialty tags such as network, POTS, POS, AV, cabling, installer, and troubleshooter, plus earned level badges based on completed work and reputation signals.
|
||||||
11. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability.
|
11. Admin operations: audit logs, queue health, system status, content moderation, and backup/export workflows.
|
||||||
|
12. Deployment hardening: production environment docs, queue worker setup, scheduler setup, storage policy, and server provisioning notes.
|
||||||
|
13. License and runtime audit: generate and review PHP and JavaScript dependency license reports before adding external theme kits, map any risky package to a replacement, and keep required local/runtime extensions such as `pdo_sqlite` documented for testability.
|
||||||
|
|
||||||
## Scaling Risks To Track
|
## Scaling Risks To Track
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
|||||||
- Notifications should move from the database queue to Redis-backed queues before high-volume alerting or email delivery.
|
- Notifications should move from the database queue to Redis-backed queues before high-volume alerting or email delivery.
|
||||||
- Sessions, cache, and queues should move to Redis before running more than one web node.
|
- Sessions, cache, and queues should move to Redis before running more than one web node.
|
||||||
- Uploaded evidence, profile images, and deliverable files should move to shared or object storage before multi-server deployment.
|
- Uploaded evidence, profile images, and deliverable files should move to shared or object storage before multi-server deployment.
|
||||||
|
- Attachment records already track disk/path metadata, but production scaling still needs object-storage lifecycle rules, upload malware scanning, quota policy, and thumbnail generation.
|
||||||
- Work-order timelines, dispute activity, messages, and notification tables need careful indexes before large production use.
|
- Work-order timelines, dispute activity, messages, and notification tables need careful indexes before large production use.
|
||||||
- Realtime chat, websocket presence, mobile push, and geolocation check-ins should be added as deliberate service layers instead of bolted onto normal page requests.
|
- Realtime chat, websocket presence, mobile push, and geolocation check-ins should be added as deliberate service layers instead of bolted onto normal page requests.
|
||||||
- Background imports, image processing, moderation summaries, and reputation recalculation should run through queue workers rather than web requests.
|
- Background imports, image processing, moderation summaries, and reputation recalculation should run through queue workers rather than web requests.
|
||||||
@@ -65,3 +69,4 @@ Provider Exchange is a Laravel application for a provider-centered field-service
|
|||||||
- Real-time chat/websocket presence.
|
- Real-time chat/websocket presence.
|
||||||
- Marketplace-wide dispute binding beyond voluntary community reputation.
|
- Marketplace-wide dispute binding beyond voluntary community reputation.
|
||||||
- Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear.
|
- Automated scraping of third-party platforms where authentication, terms, or privacy boundaries are unclear.
|
||||||
|
- Automated competency promotion rules beyond transparent, reviewable tag and level signals.
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
@props(['type', 'id', 'kind' => 'general'])
|
@props(['type', 'id', 'kind' => 'general'])
|
||||||
|
|
||||||
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data" class="mt-4 space-y-3 rounded border p-4">
|
@php
|
||||||
|
$maxMb = round(((int) config('provider-exchange.attachments.max_kb')) / 1024, 1);
|
||||||
|
$mimeTypes = implode(', ', config('provider-exchange.attachments.allowed_mime_types'));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('attachments.store') }}" enctype="multipart/form-data" class="mt-4 space-y-3 rounded-md border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="attachable_type" value="{{ $type }}">
|
<input type="hidden" name="attachable_type" value="{{ $type }}">
|
||||||
<input type="hidden" name="attachable_id" value="{{ $id }}">
|
<input type="hidden" name="attachable_id" value="{{ $id }}">
|
||||||
<input type="hidden" name="kind" value="{{ $kind }}">
|
<input type="hidden" name="kind" value="{{ $kind }}">
|
||||||
<x-field name="caption" label="Caption" />
|
<x-field name="caption" label="Caption" />
|
||||||
<input type="file" name="file" required class="block w-full text-sm">
|
<div>
|
||||||
|
<label for="file-{{ $type }}-{{ $id }}-{{ $kind }}" class="block text-sm font-medium text-slate-800 dark:text-slate-200">File</label>
|
||||||
|
<input id="file-{{ $type }}-{{ $id }}-{{ $kind }}" type="file" name="file" required class="mt-1 block w-full text-sm text-slate-700 file:me-4 file:rounded-md file:border-0 file:bg-sky-600 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-white hover:file:bg-sky-700 dark:text-slate-300">
|
||||||
|
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">Max {{ $maxMb }} MB. Allowed: {{ $mimeTypes }}.</p>
|
||||||
|
<x-input-error :messages="$errors->get('file')" class="mt-2" />
|
||||||
|
</div>
|
||||||
<x-primary-button>Upload file</x-primary-button>
|
<x-primary-button>Upload file</x-primary-button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,10 +3,33 @@
|
|||||||
@if ($attachments->count())
|
@if ($attachments->count())
|
||||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
@foreach ($attachments as $attachment)
|
@foreach ($attachments as $attachment)
|
||||||
<a href="{{ asset('storage/'.$attachment->path) }}" class="block rounded border p-3 text-sm" target="_blank">
|
<div class="rounded-md border border-slate-200 bg-white p-3 text-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
<span class="font-semibold">{{ $attachment->original_name }}</span>
|
@if ($attachment->isImage())
|
||||||
<span class="block text-gray-500">{{ $attachment->caption }}</span>
|
<a href="{{ $attachment->publicUrl() }}" target="_blank" class="block overflow-hidden rounded-md border border-slate-200 dark:border-slate-800">
|
||||||
</a>
|
<img src="{{ $attachment->publicUrl() }}" alt="{{ $attachment->caption ?: $attachment->original_name }}" class="h-36 w-full object-cover">
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<div class="{{ $attachment->isImage() ? 'mt-3' : '' }} flex items-start justify-between gap-3">
|
||||||
|
<a href="{{ $attachment->publicUrl() }}" class="font-semibold text-sky-700 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-200" target="_blank">
|
||||||
|
{{ $attachment->original_name }}
|
||||||
|
</a>
|
||||||
|
@auth
|
||||||
|
@if (auth()->id() === $attachment->user_id || auth()->user()->hasRole('admin'))
|
||||||
|
<form method="POST" action="{{ route('attachments.destroy', $attachment) }}">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-xs font-semibold text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">Delete</button>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{{ $attachment->kind }} | {{ $attachment->mime_type ?: 'unknown type' }} | {{ $attachment->formattedSize() }}
|
||||||
|
</p>
|
||||||
|
@if ($attachment->caption)
|
||||||
|
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $attachment->caption }}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store');
|
Route::post('/feed', [SocialPostController::class, 'store'])->name('feed.store');
|
||||||
Route::post('/comments', [CommentController::class, 'store'])->name('comments.store');
|
Route::post('/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||||
Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store');
|
Route::post('/attachments', [AttachmentController::class, 'store'])->name('attachments.store');
|
||||||
|
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->name('attachments.destroy');
|
||||||
Route::post('/ratings', [RatingController::class, 'store'])->name('ratings.store');
|
Route::post('/ratings', [RatingController::class, 'store'])->name('ratings.store');
|
||||||
|
|
||||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction
|
|||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
php artisan migrate --force
|
php artisan migrate --force
|
||||||
|
php artisan storage:link || true
|
||||||
php artisan optimize:clear
|
php artisan optimize:clear
|
||||||
php artisan optimize
|
php artisan optimize
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,57 @@ class ExchangeWorkflowTest extends TestCase
|
|||||||
$this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']);
|
$this->assertDatabaseHas('dispute_votes', ['recommendation' => 'split']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_attachment_uploads_follow_storage_policy_and_can_be_deleted(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
config()->set('provider-exchange.attachments.disk', 'public');
|
||||||
|
config()->set('provider-exchange.attachments.root', 'test-assets');
|
||||||
|
config()->set('provider-exchange.attachments.max_kb', 1024);
|
||||||
|
config()->set('provider-exchange.attachments.allowed_mime_types', ['image/jpeg', 'image/png']);
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$profile = $provider->providerProfile()->create(['business_name' => 'Asset Provider']);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->post('/attachments', [
|
||||||
|
'attachable_type' => 'provider_profile',
|
||||||
|
'attachable_id' => $profile->id,
|
||||||
|
'kind' => 'profile',
|
||||||
|
'caption' => 'Profile asset',
|
||||||
|
'file' => UploadedFile::fake()->image('profile.jpg'),
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
$attachment = $profile->attachments()->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertSame('public', $attachment->disk);
|
||||||
|
$this->assertStringStartsWith('test-assets/provider-profile/', $attachment->path);
|
||||||
|
Storage::disk('public')->assertExists($attachment->path);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->delete("/attachments/{$attachment->id}")
|
||||||
|
->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
|
||||||
|
Storage::disk('public')->assertMissing($attachment->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_attachment_upload_rejects_disallowed_mime_types(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
config()->set('provider-exchange.attachments.allowed_mime_types', ['image/jpeg']);
|
||||||
|
|
||||||
|
$provider = $this->userWithRole('provider');
|
||||||
|
$profile = $provider->providerProfile()->create(['business_name' => 'Mime Provider']);
|
||||||
|
|
||||||
|
$this->actingAs($provider)->from('/provider-profile')->post('/attachments', [
|
||||||
|
'attachable_type' => 'provider_profile',
|
||||||
|
'attachable_id' => $profile->id,
|
||||||
|
'kind' => 'profile',
|
||||||
|
'file' => UploadedFile::fake()->create('payload.zip', 1, 'application/zip'),
|
||||||
|
])->assertRedirect('/provider-profile')
|
||||||
|
->assertSessionHasErrors('file');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('attachments', 0);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_everything_rating_layer_accepts_profile_and_work_order_ratings(): void
|
public function test_everything_rating_layer_accepts_profile_and_work_order_ratings(): void
|
||||||
{
|
{
|
||||||
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
|
[$buyer, $provider, $workOrder] = $this->workOrderFixture();
|
||||||
|
|||||||
Reference in New Issue
Block a user