Add notification preferences and admin governance
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.'.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('notification_preferences', function (Blueprint $table) {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('audit_logs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('moderation_reports', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
+16
-4
@@ -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.
|
||||
|
||||
@@ -115,5 +115,65 @@
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Moderation reports</h3>
|
||||
<p class="mt-1 text-sm text-gray-600">Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves.</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
@forelse ($moderationReports as $report)
|
||||
<div class="rounded border bg-gray-50 p-4 text-sm">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p class="font-semibold">{{ class_basename($report->reportable_type) }} #{{ $report->reportable_id }} | {{ \App\Models\ModerationReport::REASON_CODES[$report->reason_code] ?? $report->reason_code }}</p>
|
||||
<p class="mt-1 text-gray-600">Reported by {{ $report->reporter->name }} | {{ $report->status }} | {{ $report->created_at->diffForHumans() }}</p>
|
||||
<p class="mt-2 whitespace-pre-line text-gray-700">{{ $report->details }}</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('moderation-reports.moderate', $report) }}" class="grid min-w-72 gap-2">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<label class="text-xs font-semibold text-gray-600" for="report_status_{{ $report->id }}">Report status</label>
|
||||
<select id="report_status_{{ $report->id }}" name="status" class="rounded-md border-gray-300 text-sm">
|
||||
@foreach (\App\Models\ModerationReport::STATUSES as $status)
|
||||
<option value="{{ $status }}" @selected($report->status === $status)>{{ str_replace('_', ' ', ucfirst($status)) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<textarea name="moderation_notes" rows="2" class="rounded-md border-gray-300 text-sm" placeholder="Moderation notes">{{ $report->moderation_notes }}</textarea>
|
||||
<x-primary-button>Save report</x-primary-button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="text-sm text-gray-600">No moderation reports are waiting.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded border bg-white p-6">
|
||||
<h3 class="font-semibold">Recent audit log</h3>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs uppercase text-gray-500">
|
||||
<th class="py-2 pr-4">When</th>
|
||||
<th class="py-2 pr-4">Actor</th>
|
||||
<th class="py-2 pr-4">Action</th>
|
||||
<th class="py-2 pr-4">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse ($auditLogs as $log)
|
||||
<tr>
|
||||
<td class="py-2 pr-4">{{ $log->created_at->format('M j, g:i A') }}</td>
|
||||
<td class="py-2 pr-4">{{ $log->actor?->name ?? 'System' }}</td>
|
||||
<td class="py-2 pr-4">{{ str_replace('.', ' ', $log->action) }}</td>
|
||||
<td class="py-2 pr-4">{{ $log->auditable_type ? class_basename($log->auditable_type).' #'.$log->auditable_id : 'n/a' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="py-3 text-gray-600">No audit records yet.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
<x-rating-summary :ratings="$profile->ratings" />
|
||||
<x-rating-form type="buyer_profile" :id="$profile->id" category="buyer_overall" />
|
||||
<x-moderation-report-form type="buyer_profile" :id="$profile->id" />
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
@if ($attachment->caption)
|
||||
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $attachment->caption }}</p>
|
||||
@endif
|
||||
<x-moderation-report-form type="attachment" :id="$attachment->id" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@props(['type', 'id'])
|
||||
|
||||
@auth
|
||||
<details class="mt-4 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
|
||||
<summary class="cursor-pointer font-semibold text-slate-950 dark:text-white">Report for moderation</summary>
|
||||
<form method="POST" action="{{ route('moderation-reports.store') }}" class="mt-3 space-y-3">
|
||||
@csrf
|
||||
<input type="hidden" name="reportable_type" value="{{ $type }}">
|
||||
<input type="hidden" name="reportable_id" value="{{ $id }}">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-800 dark:text-slate-200">Reason</label>
|
||||
<select name="reason_code" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||
@foreach (\App\Models\ModerationReport::REASON_CODES as $code => $label)
|
||||
<option value="{{ $code }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<x-field name="details" label="Details" textarea />
|
||||
<x-secondary-button type="submit">Submit report</x-secondary-button>
|
||||
</form>
|
||||
</details>
|
||||
@endauth
|
||||
@@ -86,6 +86,7 @@
|
||||
<x-attachments :attachments="$job->attachments" />
|
||||
<x-rating-summary :ratings="$job->ratings" />
|
||||
<x-rating-form type="job_post" :id="$job->id" category="job_quality" />
|
||||
<x-moderation-report-form type="job_post" :id="$job->id" />
|
||||
@auth
|
||||
@if (auth()->id() === $job->buyer_id)
|
||||
<x-attachment-form type="job_post" :id="$job->id" kind="job" />
|
||||
|
||||
@@ -1,27 +1,96 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header"><h2 class="text-xl font-semibold">Notifications</h2></x-slot>
|
||||
<div class="mx-auto max-w-5xl space-y-4 p-6">
|
||||
<form method="POST" action="{{ route('notifications.read-all') }}">
|
||||
@csrf
|
||||
<x-secondary-button>Mark all read</x-secondary-button>
|
||||
</form>
|
||||
<x-slot name="header">
|
||||
<x-page-header
|
||||
title="Notifications"
|
||||
description="Control in-app alert categories now, and store explicit email/push intent for future delivery channels."
|
||||
/>
|
||||
</x-slot>
|
||||
<div class="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<section class="tse-panel p-6">
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Alert preferences</h3>
|
||||
<form method="POST" action="{{ route('notifications.preferences') }}" class="mt-4 space-y-5">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
|
||||
<input type="checkbox" name="in_app_enabled" value="1" @checked($preference->in_app_enabled) class="rounded border-slate-300 text-sky-600">
|
||||
<span class="ml-2 font-semibold">In-app alerts</span>
|
||||
</label>
|
||||
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
|
||||
<input type="checkbox" name="email_enabled" value="1" @checked($preference->email_enabled) class="rounded border-slate-300 text-sky-600">
|
||||
<span class="ml-2 font-semibold">Email intent</span>
|
||||
</label>
|
||||
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
|
||||
<input type="checkbox" name="push_enabled" value="1" @checked($preference->push_enabled) class="rounded border-slate-300 text-sky-600">
|
||||
<span class="ml-2 font-semibold">Push intent</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="digest_frequency" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Digest preference</label>
|
||||
<select id="digest_frequency" name="digest_frequency" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
|
||||
@foreach (['immediate' => 'Immediate', 'daily' => 'Daily digest', 'weekly' => 'Weekly digest'] as $value => $label)
|
||||
<option value="{{ $value }}" @selected($preference->digest_frequency === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<x-field name="quiet_hours_start" label="Quiet hours start" type="time" :value="$preference->quiet_hours_start" />
|
||||
<x-field name="quiet_hours_end" label="Quiet hours end" type="time" :value="$preference->quiet_hours_end" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-200">Category controls</p>
|
||||
<div class="mt-2 grid gap-3 md:grid-cols-2">
|
||||
@foreach ($eventCategories as $field => $label)
|
||||
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
|
||||
<input type="checkbox" name="{{ $field }}" value="1" @checked($preference->{$field}) class="rounded border-slate-300 text-sky-600">
|
||||
<span class="ml-2">{{ $label }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
<details class="rounded-md border border-slate-200 p-4 dark:border-slate-800">
|
||||
<summary class="cursor-pointer font-semibold text-slate-950 dark:text-white">Fine tune event types</summary>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
@foreach ($eventTypes as $event => $category)
|
||||
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
|
||||
<input type="checkbox" name="event_preferences[{{ $event }}]" value="1" @checked(($preference->event_preferences[$event] ?? $preference->{$category}) === true) class="rounded border-slate-300 text-sky-600">
|
||||
<span class="ml-2">{{ str_replace('_', ' ', $event) }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
<x-primary-button>Save preferences</x-primary-button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="tse-panel p-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="font-semibold text-slate-950 dark:text-white">Inbox</h3>
|
||||
<form method="POST" action="{{ route('notifications.read-all') }}">
|
||||
@csrf
|
||||
<x-secondary-button>Mark all read</x-secondary-button>
|
||||
</form>
|
||||
</div>
|
||||
@foreach ($notifications as $notification)
|
||||
<div class="rounded border bg-white p-4 {{ $notification->read_at ? '' : 'border-indigo-400' }}">
|
||||
<p class="font-semibold">{{ $notification->data['title'] ?? 'Notification' }}</p>
|
||||
<p class="mt-1 text-sm text-gray-600">{{ $notification->data['body'] ?? '' }}</p>
|
||||
<div class="mt-4 rounded-md border bg-white p-4 text-sm dark:bg-slate-900 {{ $notification->read_at ? 'border-slate-200 dark:border-slate-800' : 'border-sky-400' }}">
|
||||
<p class="font-semibold text-slate-950 dark:text-white">{{ $notification->data['title'] ?? 'Notification' }}</p>
|
||||
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $notification->data['body'] ?? '' }}</p>
|
||||
<div class="mt-3 flex gap-3 text-sm">
|
||||
@if (! empty($notification->data['url']))
|
||||
<a class="text-indigo-600" href="{{ $notification->data['url'] }}">Open</a>
|
||||
<a class="font-semibold text-sky-700 dark:text-sky-300" href="{{ $notification->data['url'] }}">Open</a>
|
||||
@endif
|
||||
@unless ($notification->read_at)
|
||||
<form method="POST" action="{{ route('notifications.read', $notification->id) }}">
|
||||
@csrf
|
||||
<button class="text-gray-600" type="submit">Mark read</button>
|
||||
<button class="text-slate-600 dark:text-slate-400" type="submit">Mark read</button>
|
||||
</form>
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
{{ $notifications->links() }}
|
||||
</section>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
<x-attachments :attachments="$profile->attachments" />
|
||||
<x-rating-summary :ratings="$profile->ratings" />
|
||||
<x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" />
|
||||
<x-moderation-report-form type="provider_profile" :id="$profile->id" />
|
||||
</section>
|
||||
|
||||
<section class="tse-panel p-6">
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user