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
+
+ Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves. {{ 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 }} No moderation reports are waiting.Moderation reports
+ Recent audit log
+
+
+
+
+
+
+
+ @forelse ($auditLogs as $log)
+ When
+ Actor
+ Action
+ Target
+
+
+ @empty
+ {{ $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' }}
+
+ @endforelse
+
+ No audit records yet.
{{ $attachment->caption }}
@endif +{{ $notification->data['title'] ?? 'Notification' }}
-{{ $notification->data['body'] ?? '' }}
+ @endforeach {{ $notifications->links() }} +