Add notification preferences and admin governance

This commit is contained in:
Codex
2026-07-10 18:33:39 -05:00
parent eb9456e1bd
commit de9e473d3b
28 changed files with 734 additions and 20 deletions
+11
View File
@@ -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(),
]);
}
}
@@ -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);
@@ -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();
@@ -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.');
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\AuditLog;
use App\Models\JobPost;
use App\Models\ModerationReport;
use App\Models\ProviderProfile;
use App\Models\BuyerProfile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ModerationReportController extends Controller
{
public function store(Request $request): RedirectResponse
{
$data = $request->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),
};
}
}
@@ -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();
+6
View File
@@ -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.'.',
@@ -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.');
}
}
@@ -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.'.',
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\Request;
#[Fillable(['actor_id', 'action', 'metadata', 'ip_address', 'user_agent'])]
class AuditLog extends Model
{
protected function casts(): array
{
return [
'metadata' => '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;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'reporter_id',
'reason_code',
'details',
'status',
'moderated_by_id',
'moderated_at',
'moderation_notes',
])]
class ModerationReport extends Model
{
public const REASON_CODES = [
'spam' => '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();
}
}
+54 -1
View File
@@ -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;
}
}
@@ -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'];
}