From de9e473d3b919360d011421f4f1d52ee15e07e32 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 10 Jul 2026 18:33:39 -0500 Subject: [PATCH] Add notification preferences and admin governance --- CHANGELOG.md | 2 + app/Http/Controllers/AdminController.php | 11 +++ .../Api/V1/WorkOrderActionController.php | 9 ++ app/Http/Controllers/AttachmentController.php | 5 + .../ExternalProfileImportController.php | 5 + .../ModerationReportController.php | 77 ++++++++++++++++ .../Controllers/NotificationController.php | 47 ++++++++++ app/Http/Controllers/QuoteController.php | 6 ++ app/Http/Controllers/ReviewController.php | 9 ++ app/Http/Controllers/WorkOrderController.php | 19 ++++ app/Models/AuditLog.php | 49 ++++++++++ app/Models/ModerationReport.php | 53 +++++++++++ app/Models/NotificationPreference.php | 55 ++++++++++- .../ExchangeEventNotification.php | 6 ++ ...001000_expand_notification_preferences.php | 32 +++++++ ...6_07_11_001100_create_audit_logs_table.php | 30 ++++++ ...001200_create_moderation_reports_table.php | 31 +++++++ docs/FORWARD_PHASE_PLAN.md | 30 +++++- docs/ROADMAP.md | 20 +++- resources/views/admin/index.blade.php | 60 ++++++++++++ resources/views/buyers/show.blade.php | 1 + .../views/components/attachments.blade.php | 1 + .../moderation-report-form.blade.php | 22 +++++ resources/views/jobs/show.blade.php | 1 + resources/views/notifications/index.blade.php | 91 ++++++++++++++++--- resources/views/providers/show.blade.php | 1 + routes/web.php | 4 + tests/Feature/ExchangeWorkflowTest.php | 77 ++++++++++++++++ 28 files changed, 734 insertions(+), 20 deletions(-) create mode 100644 app/Http/Controllers/ModerationReportController.php create mode 100644 app/Models/AuditLog.php create mode 100644 app/Models/ModerationReport.php create mode 100644 database/migrations/2026_07_11_001000_expand_notification_preferences.php create mode 100644 database/migrations/2026_07_11_001100_create_audit_logs_table.php create mode 100644 database/migrations/2026_07_11_001200_create_moderation_reports_table.php create mode 100644 resources/views/components/moderation-report-form.blade.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d252a6b..e370017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - Added the guided imported-history wizard with structured work categories, imported endorsements, operational metrics, selected review excerpts, public visibility controls, proof attachments, and admin verification status. - Added post-work provider tag verification so buyers can confirm/dispute observed technician level and declared provider tags after completed work orders, with buyer-endorsed tag evidence shown on provider profiles. - Added Phase 11A mobile-safe API actions with versioned work-order endpoints, token ability checks, participant authorization, checklist/status/message/evidence/contact/running-late/schedule/dispute actions, mobile event audit records, optional geolocation evidence, API throttling, and baseline security headers. +- Added Phase 12A/13A notification and admin governance controls with notification preference UI, event-level notification gating, moderation reports for profiles/jobs/attachments, reusable audit logs, and expanded admin triage panels. - 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 social posts, buyer job posts, provider quotes, quote revisions, quote decline flow, and buyer quote acceptance. @@ -43,6 +44,7 @@ This project uses Git as the primary rollback trail. The `.old` convention is re - `php artisan test` passed under Windows PHP with 49 tests and 239 assertions after the guided imported-history wizard. - `php artisan test` passed under Windows PHP with 51 tests and 256 assertions after post-work provider tag verification. - `php artisan test` passed under Windows PHP with 55 tests and 285 assertions after Phase 11A mobile API and security hardening. +- `php artisan test` passed under Windows PHP with 57 tests and 301 assertions after Phase 12A/13A notification preferences and admin governance. - `npm run build` passed with a production Vite build. - `https://christit.com/tse/login` loads publicly. - Seeded admin login succeeds on `https://christit.com/tse` and reaches `/tse/dashboard`. diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 933e763..9ff92e2 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers; use App\Models\Attachment; +use App\Models\AuditLog; use App\Models\Dispute; use App\Models\ExternalProfileImport; use App\Models\JobPost; +use App\Models\ModerationReport; use App\Models\Review; use App\Models\User; use App\Models\WorkOrder; @@ -40,6 +42,15 @@ class AdminController extends Controller ->latest() ->limit(10) ->get(), + 'moderationReports' => ModerationReport::with('reporter', 'reportable') + ->whereIn('status', ['open', 'reviewing']) + ->latest() + ->limit(15) + ->get(), + 'auditLogs' => AuditLog::with('actor', 'auditable') + ->latest() + ->limit(20) + ->get(), ]); } } diff --git a/app/Http/Controllers/Api/V1/WorkOrderActionController.php b/app/Http/Controllers/Api/V1/WorkOrderActionController.php index c6ab892..c980ad6 100644 --- a/app/Http/Controllers/Api/V1/WorkOrderActionController.php +++ b/app/Http/Controllers/Api/V1/WorkOrderActionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; use App\Models\Dispute; +use App\Models\AuditLog; use App\Models\JobPost; use App\Models\WorkOrder; use App\Models\WorkOrderChangeRequest; @@ -97,6 +98,7 @@ class WorkOrderActionController extends Controller $workOrder->update($updates); $this->recordMobileEvent($request, $workOrder, 'status_transition', ['status' => $status]); + AuditLog::record($request, 'api.work_order.transitioned', $workOrder, ['status' => $status]); $this->notifyOtherParticipant($request, $workOrder, 'Work order updated', $request->user()->name.' changed '.$workOrder->jobPost->title.' to '.str_replace('_', ' ', $status).'.', 'work_order_status'); return response()->json(['data' => $this->payload($workOrder->fresh())]); @@ -119,6 +121,7 @@ class WorkOrderActionController extends Controller $workOrder->update(['checklist_completed' => $completed]); $this->recordMobileEvent($request, $workOrder, 'checklist_update', ['checklist_completed' => $completed]); + AuditLog::record($request, 'api.work_order.checklist_updated', $workOrder); return response()->json(['data' => $this->payload($workOrder->fresh())]); } @@ -139,6 +142,7 @@ class WorkOrderActionController extends Controller ]); $this->recordMobileEvent($request, $workOrder, 'message_sent', ['message_id' => $message->id]); + AuditLog::record($request, 'api.work_order.message_sent', $message, ['work_order_id' => $workOrder->id]); $this->notifyOtherParticipant($request, $workOrder, 'Work order message', $request->user()->name.' sent a message on '.$workOrder->jobPost->title.'.', 'work_order_message'); return response()->json(['data' => $message->load('user:id,name')], 201); @@ -181,6 +185,7 @@ class WorkOrderActionController extends Controller ]); $this->recordMobileEvent($request, $workOrder, 'evidence_uploaded', ['attachment_id' => $attachment->id]); + AuditLog::record($request, 'api.work_order.evidence_uploaded', $attachment, ['work_order_id' => $workOrder->id]); return response()->json(['data' => $attachment], 201); } @@ -210,6 +215,7 @@ class WorkOrderActionController extends Controller ]); $this->recordMobileEvent($request, $workOrder, 'contact_event', ['contact_event_id' => $event->id, 'event_type' => $event->event_type]); + AuditLog::record($request, 'api.work_order.contact_event', $event, ['work_order_id' => $workOrder->id]); $workOrder->buyer->notify(new ExchangeEventNotification( WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? 'Contact event logged', $request->user()->name.' logged '.str_replace('_', ' ', $event->event_type).' on '.$workOrder->jobPost->title.'.', @@ -236,6 +242,7 @@ class WorkOrderActionController extends Controller 'estimated_arrival_at' => $data['estimated_arrival_at'], 'reason' => $data['reason'] ?? null, ]); + AuditLog::record($request, 'api.work_order.running_late', $event, ['work_order_id' => $workOrder->id]); $workOrder->buyer->notify(new ExchangeEventNotification( 'Provider running late', @@ -269,6 +276,7 @@ class WorkOrderActionController extends Controller ]); $this->recordMobileEvent($request, $workOrder, 'schedule_update_requested', ['change_request_id' => $change->id]); + AuditLog::record($request, 'api.work_order.schedule_update_requested', $change, ['work_order_id' => $workOrder->id]); $this->notifyOtherParticipant($request, $workOrder, 'Schedule update requested', $request->user()->name.' requested a schedule update for '.$workOrder->jobPost->title.'.', 'schedule_update_requested'); return response()->json(['data' => $change->load('requester:id,name')], 201); @@ -301,6 +309,7 @@ class WorkOrderActionController extends Controller } $this->recordMobileEvent($request, $workOrder, 'dispute_opened', ['dispute_id' => $dispute->id]); + AuditLog::record($request, 'api.dispute.opened', $dispute, ['work_order_id' => $workOrder->id]); $this->notifyOtherParticipant($request, $workOrder, 'Dispute opened', $request->user()->name.' opened a dispute for '.$workOrder->jobPost->title.'.', 'dispute_opened'); return response()->json(['data' => $dispute->load('openedBy:id,name')], 201); diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 50af7ad..c6ad2cc 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -9,6 +9,7 @@ use App\Models\ProviderProfile; use App\Models\SocialPost; use App\Models\WorkOrder; use App\Models\Attachment; +use App\Models\AuditLog; use App\Models\BuyerProfile; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -69,6 +70,10 @@ class AttachmentController extends Controller ) { $disk = $attachment->disk; $path = $attachment->path; + AuditLog::record($request, 'attachment.deleted', $attachment, [ + 'path' => $path, + 'disk' => $disk, + ]); $attachment->delete(); $stillReferenced = Attachment::where('disk', $disk)->where('path', $path)->exists(); diff --git a/app/Http/Controllers/ExternalProfileImportController.php b/app/Http/Controllers/ExternalProfileImportController.php index f193a10..8912bfa 100644 --- a/app/Http/Controllers/ExternalProfileImportController.php +++ b/app/Http/Controllers/ExternalProfileImportController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\ExternalProfileImport; +use App\Models\AuditLog; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -75,6 +76,10 @@ class ExternalProfileImportController extends Controller 'verified_at' => now(), ]); + AuditLog::record($request, 'provider_import.verified', $import, [ + 'verification_status' => $data['verification_status'], + ]); + return back()->with('status', 'Imported history verification updated.'); } diff --git a/app/Http/Controllers/ModerationReportController.php b/app/Http/Controllers/ModerationReportController.php new file mode 100644 index 0000000..4593db3 --- /dev/null +++ b/app/Http/Controllers/ModerationReportController.php @@ -0,0 +1,77 @@ +validate([ + 'reportable_type' => ['required', 'string', 'in:provider_profile,buyer_profile,job_post,attachment'], + 'reportable_id' => ['required', 'integer'], + 'reason_code' => ['required', 'string', 'in:'.implode(',', array_keys(ModerationReport::REASON_CODES))], + 'details' => ['required', 'string', 'max:4000'], + ]); + + $reportable = $this->resolveReportable($data['reportable_type'], (int) $data['reportable_id']); + + $report = new ModerationReport([ + 'reporter_id' => $request->user()->id, + 'reason_code' => $data['reason_code'], + 'details' => $data['details'], + 'status' => 'open', + ]); + $report->reportable()->associate($reportable); + $report->save(); + + AuditLog::record($request, 'moderation.reported', $report, [ + 'reportable_type' => $data['reportable_type'], + 'reportable_id' => $data['reportable_id'], + 'reason_code' => $data['reason_code'], + ]); + + return back()->with('status', 'Report submitted for moderation.'); + } + + public function moderate(Request $request, ModerationReport $report): RedirectResponse + { + abort_unless($request->user()->hasRole('admin'), 403); + + $data = $request->validate([ + 'status' => ['required', 'string', 'in:'.implode(',', ModerationReport::STATUSES)], + 'moderation_notes' => ['nullable', 'string', 'max:4000'], + ]); + + $report->update([ + 'status' => $data['status'], + 'moderated_by_id' => $request->user()->id, + 'moderated_at' => now(), + 'moderation_notes' => $data['moderation_notes'] ?? null, + ]); + + AuditLog::record($request, 'moderation.report_moderated', $report, [ + 'status' => $data['status'], + ]); + + return redirect()->route('admin.index')->with('status', 'Moderation report updated.'); + } + + private function resolveReportable(string $type, int $id): mixed + { + return match ($type) { + 'provider_profile' => ProviderProfile::findOrFail($id), + 'buyer_profile' => BuyerProfile::findOrFail($id), + 'job_post' => JobPost::findOrFail($id), + 'attachment' => Attachment::findOrFail($id), + }; + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index a6bac62..87fd575 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +use App\Models\NotificationPreference; class NotificationController extends Controller { @@ -12,9 +13,55 @@ class NotificationController extends Controller { return view('notifications.index', [ 'notifications' => $request->user()->notifications()->latest()->paginate(30), + 'preference' => $request->user()->notificationPreference()->firstOrCreate([]), + 'eventCategories' => NotificationPreference::EVENT_CATEGORIES, + 'eventTypes' => NotificationPreference::EVENT_TYPES, ]); } + public function updatePreferences(Request $request): RedirectResponse + { + $data = $request->validate([ + 'job_alerts' => ['nullable', 'boolean'], + 'quote_updates' => ['nullable', 'boolean'], + 'work_order_updates' => ['nullable', 'boolean'], + 'review_updates' => ['nullable', 'boolean'], + 'dispute_updates' => ['nullable', 'boolean'], + 'in_app_enabled' => ['nullable', 'boolean'], + 'email_enabled' => ['nullable', 'boolean'], + 'push_enabled' => ['nullable', 'boolean'], + 'digest_frequency' => ['required', 'string', 'in:immediate,daily,weekly'], + 'quiet_hours_start' => ['nullable', 'date_format:H:i'], + 'quiet_hours_end' => ['nullable', 'date_format:H:i'], + 'event_preferences' => ['nullable', 'array'], + 'event_preferences.*' => ['nullable', 'boolean'], + ]); + + $events = collect(array_keys(NotificationPreference::EVENT_TYPES)) + ->mapWithKeys(fn (string $event): array => [$event => $request->boolean("event_preferences.$event")]) + ->all(); + + $request->user()->notificationPreference()->updateOrCreate( + ['user_id' => $request->user()->id], + [ + 'job_alerts' => $request->boolean('job_alerts'), + 'quote_updates' => $request->boolean('quote_updates'), + 'work_order_updates' => $request->boolean('work_order_updates'), + 'review_updates' => $request->boolean('review_updates'), + 'dispute_updates' => $request->boolean('dispute_updates'), + 'in_app_enabled' => $request->boolean('in_app_enabled'), + 'email_enabled' => $request->boolean('email_enabled'), + 'push_enabled' => $request->boolean('push_enabled'), + 'event_preferences' => $events, + 'digest_frequency' => $data['digest_frequency'], + 'quiet_hours_start' => $data['quiet_hours_start'] ?? null, + 'quiet_hours_end' => $data['quiet_hours_end'] ?? null, + ] + ); + + return back()->with('status', 'Notification preferences saved.'); + } + public function markRead(Request $request, string $id): RedirectResponse { $notification = $request->user()->notifications()->whereKey($id)->firstOrFail(); diff --git a/app/Http/Controllers/QuoteController.php b/app/Http/Controllers/QuoteController.php index 7d2fc36..8daf169 100644 --- a/app/Http/Controllers/QuoteController.php +++ b/app/Http/Controllers/QuoteController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\JobPost; use App\Models\Quote; +use App\Models\AuditLog; use App\Models\WorkOrder; use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; @@ -140,6 +141,11 @@ class QuoteController extends Controller ]], ]); + AuditLog::record($request, 'quote.accepted', $workOrder, [ + 'quote_id' => $quote->id, + 'job_post_id' => $quote->job_post_id, + ]); + $quote->provider->notify(new ExchangeEventNotification( 'Quote accepted', $quote->jobPost->buyer->name.' accepted your quote for '.$quote->jobPost->title.'.', diff --git a/app/Http/Controllers/ReviewController.php b/app/Http/Controllers/ReviewController.php index 89d1e7c..03efc8e 100644 --- a/app/Http/Controllers/ReviewController.php +++ b/app/Http/Controllers/ReviewController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Review; +use App\Models\AuditLog; use App\Models\WorkOrder; use App\Notifications\ExchangeEventNotification; use Illuminate\Http\RedirectResponse; @@ -102,6 +103,10 @@ class ReviewController extends Controller 'moderation_status' => 'reported', ]); + AuditLog::record($request, 'review.reported', $review, [ + 'work_order_id' => $review->work_order_id, + ]); + return redirect()->route('work-orders.show', $review->work_order_id)->with('status', 'Review reported for moderation.'); } @@ -121,6 +126,10 @@ class ReviewController extends Controller 'moderated_at' => now(), ]); + AuditLog::record($request, 'review.moderated', $review, [ + 'moderation_status' => $data['moderation_status'], + ]); + return redirect()->route('admin.index')->with('status', 'Review moderation updated.'); } } diff --git a/app/Http/Controllers/WorkOrderController.php b/app/Http/Controllers/WorkOrderController.php index 3fabda0..a3abd3d 100644 --- a/app/Http/Controllers/WorkOrderController.php +++ b/app/Http/Controllers/WorkOrderController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\WorkOrder; +use App\Models\AuditLog; use App\Models\WorkOrderChangeRequest; use App\Models\WorkOrderContactEvent; use App\Notifications\ExchangeEventNotification; @@ -113,6 +114,10 @@ class WorkOrderController extends Controller $workOrder->update($updates); + AuditLog::record($request, 'work_order.transitioned', $workOrder, [ + 'status' => $status, + ]); + $recipient = $request->user()->id === $workOrder->buyer_id ? $workOrder->provider : $workOrder->buyer; @@ -186,6 +191,11 @@ class WorkOrderController extends Controller 'status' => 'open', ]); + AuditLog::record($request, 'work_order.change_requested', $changeRequest, [ + 'work_order_id' => $workOrder->id, + 'reason_code' => $changeRequest->reason_code, + ]); + $recipient = $request->user()->id === $workOrder->buyer_id ? $workOrder->provider : $workOrder->buyer; @@ -218,6 +228,10 @@ class WorkOrderController extends Controller 'responded_at' => now(), ]); + AuditLog::record($request, 'work_order.change_resolved', $changeRequest, [ + 'status' => $data['status'], + ]); + return redirect()->route('work-orders.show', $workOrder)->with('status', 'Change request updated.'); } @@ -239,6 +253,11 @@ class WorkOrderController extends Controller 'attempted_at' => $data['attempted_at'] ?? now(), ]); + AuditLog::record($request, 'work_order.contact_event', $event, [ + 'work_order_id' => $workOrder->id, + 'event_type' => $event->event_type, + ]); + $workOrder->buyer->notify(new ExchangeEventNotification( WorkOrderContactEvent::EVENT_TYPES[$event->event_type] ?? 'Contact event logged', $request->user()->name.' logged '.str_replace('_', ' ', $event->event_type).' on '.$workOrder->jobPost->title.'.', diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..2f4de27 --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,49 @@ + 'array', + ]; + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } + + public function auditable(): MorphTo + { + return $this->morphTo(); + } + + public static function record(Request $request, string $action, ?Model $auditable = null, array $metadata = []): self + { + $log = new self([ + 'actor_id' => $request->user()?->id, + 'action' => $action, + 'metadata' => $metadata, + 'ip_address' => $request->ip(), + 'user_agent' => substr((string) $request->userAgent(), 0, 500), + ]); + + if ($auditable) { + $log->auditable()->associate($auditable); + } + + $log->save(); + + return $log; + } +} diff --git a/app/Models/ModerationReport.php b/app/Models/ModerationReport.php new file mode 100644 index 0000000..0190134 --- /dev/null +++ b/app/Models/ModerationReport.php @@ -0,0 +1,53 @@ + 'Spam or solicitation', + 'misleading' => 'Misleading or false information', + 'private_info' => 'Private or sensitive information', + 'abuse' => 'Abuse or harassment', + 'unsafe' => 'Unsafe work or conduct', + 'other' => 'Other', + ]; + + public const STATUSES = ['open', 'reviewing', 'action_taken', 'dismissed']; + + protected function casts(): array + { + return [ + 'moderated_at' => 'datetime', + ]; + } + + public function reporter(): BelongsTo + { + return $this->belongsTo(User::class, 'reporter_id'); + } + + public function moderatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'moderated_by_id'); + } + + public function reportable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php index acb521a..aca4117 100644 --- a/app/Models/NotificationPreference.php +++ b/app/Models/NotificationPreference.php @@ -8,10 +8,37 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'user_id', 'job_alerts', 'quote_updates', 'work_order_updates', - 'review_updates', 'dispute_updates', 'email_enabled', 'push_enabled', + 'review_updates', 'dispute_updates', 'in_app_enabled', 'email_enabled', 'push_enabled', + 'event_preferences', 'digest_frequency', 'quiet_hours_start', 'quiet_hours_end', ])] class NotificationPreference extends Model { + public const EVENT_CATEGORIES = [ + 'job_alerts' => 'Matching jobs', + 'quote_updates' => 'Quote updates', + 'work_order_updates' => 'Work-order activity', + 'review_updates' => 'Reviews and reputation', + 'dispute_updates' => 'Disputes and moderation', + ]; + + public const EVENT_TYPES = [ + 'new_matching_job' => 'job_alerts', + 'quote_submitted' => 'quote_updates', + 'quote_revised' => 'quote_updates', + 'quote_declined' => 'quote_updates', + 'quote_accepted' => 'quote_updates', + 'work_order_status' => 'work_order_updates', + 'work_order_change_request' => 'work_order_updates', + 'work_order_message' => 'work_order_updates', + 'contact_event' => 'work_order_updates', + 'running_late' => 'work_order_updates', + 'schedule_update_requested' => 'work_order_updates', + 'provider_tag_verification' => 'review_updates', + 'review_received' => 'review_updates', + 'dispute_opened' => 'dispute_updates', + 'moderation_action' => 'dispute_updates', + ]; + protected function casts(): array { return [ @@ -20,8 +47,10 @@ class NotificationPreference extends Model 'work_order_updates' => 'boolean', 'review_updates' => 'boolean', 'dispute_updates' => 'boolean', + 'in_app_enabled' => 'boolean', 'email_enabled' => 'boolean', 'push_enabled' => 'boolean', + 'event_preferences' => 'array', ]; } @@ -29,4 +58,28 @@ class NotificationPreference extends Model { return $this->belongsTo(User::class); } + + public function allows(string $eventType, string $channel = 'database'): bool + { + if ($channel === 'database' && $this->in_app_enabled === false) { + return false; + } + + if ($channel === 'mail' && ! $this->email_enabled) { + return false; + } + + if ($channel === 'push' && ! $this->push_enabled) { + return false; + } + + $eventPreferences = $this->event_preferences ?? []; + if (array_key_exists($eventType, $eventPreferences)) { + return (bool) $eventPreferences[$eventType]; + } + + $category = self::EVENT_TYPES[$eventType] ?? null; + + return $category ? $this->{$category} !== false : true; + } } diff --git a/app/Notifications/ExchangeEventNotification.php b/app/Notifications/ExchangeEventNotification.php index 4cddf94..df78790 100644 --- a/app/Notifications/ExchangeEventNotification.php +++ b/app/Notifications/ExchangeEventNotification.php @@ -27,6 +27,12 @@ class ExchangeEventNotification extends Notification */ public function via(object $notifiable): array { + $preferences = $notifiable->notificationPreference()->firstOrCreate([]); + + if (! $preferences->allows($this->type, 'database')) { + return []; + } + return ['database']; } diff --git a/database/migrations/2026_07_11_001000_expand_notification_preferences.php b/database/migrations/2026_07_11_001000_expand_notification_preferences.php new file mode 100644 index 0000000..506dbf1 --- /dev/null +++ b/database/migrations/2026_07_11_001000_expand_notification_preferences.php @@ -0,0 +1,32 @@ +boolean('in_app_enabled')->default(true)->after('dispute_updates'); + $table->json('event_preferences')->nullable()->after('push_enabled'); + $table->string('digest_frequency')->default('immediate')->after('event_preferences'); + $table->time('quiet_hours_start')->nullable()->after('digest_frequency'); + $table->time('quiet_hours_end')->nullable()->after('quiet_hours_start'); + }); + } + + public function down(): void + { + Schema::table('notification_preferences', function (Blueprint $table) { + $table->dropColumn([ + 'in_app_enabled', + 'event_preferences', + 'digest_frequency', + 'quiet_hours_start', + 'quiet_hours_end', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_11_001100_create_audit_logs_table.php b/database/migrations/2026_07_11_001100_create_audit_logs_table.php new file mode 100644 index 0000000..4e92e77 --- /dev/null +++ b/database/migrations/2026_07_11_001100_create_audit_logs_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('action'); + $table->nullableMorphs('auditable'); + $table->json('metadata')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent', 500)->nullable(); + $table->timestamps(); + + $table->index(['action', 'created_at']); + $table->index(['actor_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/database/migrations/2026_07_11_001200_create_moderation_reports_table.php b/database/migrations/2026_07_11_001200_create_moderation_reports_table.php new file mode 100644 index 0000000..731eaeb --- /dev/null +++ b/database/migrations/2026_07_11_001200_create_moderation_reports_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('reporter_id')->constrained('users')->cascadeOnDelete(); + $table->morphs('reportable'); + $table->string('reason_code')->default('other'); + $table->text('details'); + $table->string('status')->default('open'); + $table->foreignId('moderated_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('moderated_at')->nullable(); + $table->text('moderation_notes')->nullable(); + $table->timestamps(); + + $table->index(['status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('moderation_reports'); + } +}; diff --git a/docs/FORWARD_PHASE_PLAN.md b/docs/FORWARD_PHASE_PLAN.md index 97213f1..b03e134 100644 --- a/docs/FORWARD_PHASE_PLAN.md +++ b/docs/FORWARD_PHASE_PLAN.md @@ -599,6 +599,17 @@ Provider-protection rules: - Critical work-order events should remain visible in app even if email/push is off. - No modal begging for notifications. +Implementation status: + +- Completed first implementation pass alongside Phase 13A. +- Added notification preference editing on the notifications page. +- Added channel intent controls for in-app, email, and future push without enabling email/push delivery prematurely. +- Added category controls for matching jobs, quote updates, work-order activity, reviews/reputation, and disputes/moderation. +- Added event-level controls for specific notification types. +- Added digest preference and quiet-hours fields for future email/push delivery policy. +- Added notification gating in `ExchangeEventNotification` so existing send paths respect user preferences. +- Verified with Windows `php artisan test`. + ## Phase 13 - Moderation, Admin Operations, And Audit Logs Purpose: @@ -648,6 +659,17 @@ Provider-protection rules: - A buyer should not be able to silently damage a provider's reputation. - A provider should be able to see what changed and why. +Implementation status: + +- Completed first implementation pass alongside Phase 12A. +- Added reusable `audit_logs` for admin and operational actions. +- Added audit writes for review reports/moderation, imported-history verification, attachment deletion, work-order transitions, change requests, contact events, quote acceptance, and mobile API actions. +- Added reusable `moderation_reports` for provider profiles, buyer profiles, jobs, and attachments. +- Added report forms to public profile, job, and attachment surfaces. +- Expanded the admin console with open moderation reports and a recent audit log panel. +- Added admin triage controls for moderation reports. +- Verified with Windows `php artisan test`. + ## Phase 14 - Deployment, Scaling, And Reliability Hardening Purpose: @@ -729,10 +751,10 @@ Provider-protection rules: ## Recommended Immediate Sequence -1. Phase 12A: notification preference UI and event-channel controls. -2. Phase 13A: audit logs and moderation/operations queues. -3. Phase 14A: deployment/scaling hardening. -4. Phase 15A: UX polish and accessibility pass. +1. Phase 14A: deployment/scaling hardening. +2. Phase 15A: UX polish and accessibility pass. +3. Future mobile/API expansion after native app requirements are clearer. +4. Future email/push delivery implementation after the sender and push providers are explicitly selected. ## Key Design Commitments diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f424cc8..80ea68d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,6 +26,8 @@ Provider Exchange is a Laravel application for a provider-centered field-service - Peer-review disputes with comments, evidence, structured recommendations, and public vote breakdowns. - Universal community ratings for buyers, providers, job posts, work orders, and disputes. - Database notification events and notification inbox. +- Notification preference UI with category controls, event-level gates, digest/quiet-hours settings, and stored email/push intent for future channels. +- Admin governance with moderation reports for profiles/jobs/attachments, expanded moderation queues, and reusable audit logs. - Versioned Sanctum API surface for future app clients, including mobile-safe work-order actions, token ability checks, action audit records, and optional geolocation evidence. - Baseline API/security hardening with rate limiting and HTTP security headers. - MIT license and no-payment/no-rate-setting guardrails. @@ -59,11 +61,11 @@ Provider Exchange is a Laravel application for a provider-centered field-service ## Phase Checkpoint -Phases 1-11A have been implemented locally through mobile-safe API actions and baseline security hardening. The next active planning block is: +Phases 1-13A have been implemented locally through notification preferences, moderation reporting, and audit logging. The next active planning block is: -8. Notification preference UI and event-channel controls. -9. Admin operations, audit logs, and moderation queues. -10. Deployment/scaling hardening and UX/accessibility polish. +8. Deployment/scaling hardening. +9. UX/accessibility polish. +10. Future email/push delivery and native-app API expansion once providers are selected. Work-order safeguards to carry into the next implementation passes: @@ -145,6 +147,16 @@ Phase 11A mobile API and security hardening completed: - API routes are throttled through an explicit `api` rate limiter. - Application responses include baseline security headers: content-type sniffing protection, same-origin frame policy, strict origin referrer policy, limited permissions policy, and HSTS on secure requests. +Phase 12A/13A notification and admin governance completed: + +- Notification settings now live on the notifications page with in-app, email intent, push intent, digest, quiet-hours, category, and event-level controls. +- `ExchangeEventNotification` gates database notifications through user preferences while defaulting existing users to enabled. +- Email and push settings are stored as explicit intent, but no email/push delivery is enabled until those providers are selected. +- Users can report provider profiles, buyer profiles, jobs, and attachments for moderation. +- Admins can triage moderation reports from the admin console. +- Audit logs record selected operational and moderation events, including review moderation, imported-history verification, attachment deletion, work-order actions, quote acceptance, and mobile API actions. +- Admins can review recent audit activity from the admin console. + Available-work safeguards to carry into directory and job-list phases: - Available jobs should expose pay type, provider terms, schedule type, work type, rough location, buyer reliability, support/contact certification, scope clarity status, and request/quote count. diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index 6fe60df..9e13e66 100644 --- a/resources/views/admin/index.blade.php +++ b/resources/views/admin/index.blade.php @@ -115,5 +115,65 @@ @endforelse + +
+

Moderation reports

+

Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves.

+
+ @forelse ($moderationReports as $report) +
+
+
+

{{ class_basename($report->reportable_type) }} #{{ $report->reportable_id }} | {{ \App\Models\ModerationReport::REASON_CODES[$report->reason_code] ?? $report->reason_code }}

+

Reported by {{ $report->reporter->name }} | {{ $report->status }} | {{ $report->created_at->diffForHumans() }}

+

{{ $report->details }}

+
+
+ @csrf + @method('PATCH') + + + + Save report +
+
+
+ @empty +

No moderation reports are waiting.

+ @endforelse +
+
+ +
+

Recent audit log

+
+ + + + + + + + + + + @forelse ($auditLogs as $log) + + + + + + + @empty + + @endforelse + +
WhenActorActionTarget
{{ $log->created_at->format('M j, g:i A') }}{{ $log->actor?->name ?? 'System' }}{{ str_replace('.', ' ', $log->action) }}{{ $log->auditable_type ? class_basename($log->auditable_type).' #'.$log->auditable_id : 'n/a' }}
No audit records yet.
+
+
diff --git a/resources/views/buyers/show.blade.php b/resources/views/buyers/show.blade.php index d8dcf83..fa49172 100644 --- a/resources/views/buyers/show.blade.php +++ b/resources/views/buyers/show.blade.php @@ -65,6 +65,7 @@ + diff --git a/resources/views/components/attachments.blade.php b/resources/views/components/attachments.blade.php index f8c3401..cc9412d 100644 --- a/resources/views/components/attachments.blade.php +++ b/resources/views/components/attachments.blade.php @@ -29,6 +29,7 @@ @if ($attachment->caption)

{{ $attachment->caption }}

@endif + @endforeach diff --git a/resources/views/components/moderation-report-form.blade.php b/resources/views/components/moderation-report-form.blade.php new file mode 100644 index 0000000..bb08c77 --- /dev/null +++ b/resources/views/components/moderation-report-form.blade.php @@ -0,0 +1,22 @@ +@props(['type', 'id']) + +@auth +
+ Report for moderation +
+ @csrf + + +
+ + +
+ + Submit report + +
+@endauth diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index 1aa55db..34d7072 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -86,6 +86,7 @@ + @auth @if (auth()->id() === $job->buyer_id) diff --git a/resources/views/notifications/index.blade.php b/resources/views/notifications/index.blade.php index 8f6b370..adff31a 100644 --- a/resources/views/notifications/index.blade.php +++ b/resources/views/notifications/index.blade.php @@ -1,27 +1,96 @@ -

Notifications

-
-
- @csrf - Mark all read -
+ + + +
+
+

Alert preferences

+
+ @csrf + @method('PATCH') +
+ + + +
+
+
+ + +
+
+ + +
+
+
+

Category controls

+
+ @foreach ($eventCategories as $field => $label) + + @endforeach +
+
+
+ Fine tune event types +
+ @foreach ($eventTypes as $event => $category) + + @endforeach +
+
+ Save preferences +
+
+ +
+
+

Inbox

+
+ @csrf + Mark all read +
+
@foreach ($notifications as $notification) -
-

{{ $notification->data['title'] ?? 'Notification' }}

-

{{ $notification->data['body'] ?? '' }}

+
+

{{ $notification->data['title'] ?? 'Notification' }}

+

{{ $notification->data['body'] ?? '' }}

@if (! empty($notification->data['url'])) - Open + Open @endif @unless ($notification->read_at)
@csrf - +
@endunless
@endforeach {{ $notifications->links() }} +
diff --git a/resources/views/providers/show.blade.php b/resources/views/providers/show.blade.php index a703254..8647e4e 100644 --- a/resources/views/providers/show.blade.php +++ b/resources/views/providers/show.blade.php @@ -89,6 +89,7 @@ +
diff --git a/routes/web.php b/routes/web.php index f80ad78..be78d60 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\DisputeController; use App\Http\Controllers\DisputeVoteController; use App\Http\Controllers\ExternalProfileImportController; use App\Http\Controllers\JobPostController; +use App\Http\Controllers\ModerationReportController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\ProviderProfileController; use App\Http\Controllers\ProviderTagVerificationController; @@ -66,8 +67,11 @@ Route::middleware('auth')->group(function () { 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('/moderation-reports', [ModerationReportController::class, 'store'])->name('moderation-reports.store'); + Route::patch('/moderation-reports/{report}', [ModerationReportController::class, 'moderate'])->name('moderation-reports.moderate'); Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::patch('/notifications/preferences', [NotificationController::class, 'updatePreferences'])->name('notifications.preferences'); Route::post('/notifications/read-all', [NotificationController::class, 'markAllRead'])->name('notifications.read-all'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markRead'])->name('notifications.read'); diff --git a/tests/Feature/ExchangeWorkflowTest.php b/tests/Feature/ExchangeWorkflowTest.php index 9e253a3..0d6fab7 100644 --- a/tests/Feature/ExchangeWorkflowTest.php +++ b/tests/Feature/ExchangeWorkflowTest.php @@ -3,6 +3,9 @@ namespace Tests\Feature; use App\Models\JobPost; +use App\Models\AuditLog; +use App\Models\ModerationReport; +use App\Models\NotificationPreference; use App\Models\ProviderProfile; use App\Models\SocialPost; use App\Models\TaxonomyTerm; @@ -885,6 +888,80 @@ class ExchangeWorkflowTest extends TestCase $this->assertNotNull($notification->fresh()->read_at); } + public function test_notification_preferences_can_gate_event_notifications(): void + { + $user = $this->userWithRole('provider'); + + $this->actingAs($user)->patch('/notifications/preferences', [ + 'digest_frequency' => 'daily', + 'quiet_hours_start' => '22:00', + 'quiet_hours_end' => '06:00', + 'in_app_enabled' => '1', + 'work_order_updates' => '1', + 'event_preferences' => [ + 'work_order_status' => '1', + ], + ])->assertRedirect(); + + $preference = $user->notificationPreference()->firstOrFail(); + $this->assertSame('daily', $preference->digest_frequency); + $this->assertTrue($preference->allows('work_order_status')); + $this->assertFalse($preference->allows('review_received')); + + $user->notify(new ExchangeEventNotification('Review muted', 'Body text', '/work-orders', 'review_received')); + $this->assertDatabaseCount('notifications', 0); + + $user->notify(new ExchangeEventNotification('Work order visible', 'Body text', '/work-orders', 'work_order_status')); + $this->assertDatabaseCount('notifications', 1); + } + + public function test_moderation_reports_admin_triage_and_audit_logs_work(): void + { + $reporter = $this->userWithRole('provider'); + $admin = $this->userWithRole('admin'); + $profileOwner = $this->userWithRole('provider'); + $profile = $profileOwner->providerProfile()->create([ + 'business_name' => 'Reported Provider', + ]); + + $this->actingAs($reporter)->post('/moderation-reports', [ + 'reportable_type' => 'provider_profile', + 'reportable_id' => $profile->id, + 'reason_code' => 'misleading', + 'details' => 'The certification claim needs review.', + ])->assertRedirect(); + + $report = ModerationReport::firstOrFail(); + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'moderation.reported', + 'auditable_type' => ModerationReport::class, + 'auditable_id' => $report->id, + ]); + + $this->actingAs($admin)->get('/admin') + ->assertOk() + ->assertSee('Moderation reports') + ->assertSee('The certification claim needs review.') + ->assertSee('Recent audit log'); + + $this->actingAs($admin)->patch("/moderation-reports/{$report->id}", [ + 'status' => 'action_taken', + 'moderation_notes' => 'Asked provider for proof.', + ])->assertRedirect('/admin'); + + $this->assertDatabaseHas('moderation_reports', [ + 'id' => $report->id, + 'status' => 'action_taken', + 'moderated_by_id' => $admin->id, + 'moderation_notes' => 'Asked provider for proof.', + ]); + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'moderation.report_moderated', + 'auditable_type' => ModerationReport::class, + 'auditable_id' => $report->id, + ]); + } + private function userWithRole(string $role): User { Role::findOrCreate($role);