Skip to content

Commit 7b168cd

Browse files
committed
Notifications system + ...
- migration + factory - model + pivot + User trait HasNotifications - controllers (resource + read/unread) - service - auto-creation on Eloquent model events - UI (types, widget and index) - tests (unit and feature)
1 parent 6bf8e99 commit 7b168cd

53 files changed

Lines changed: 2923 additions & 64 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Enums/NotificationTarget.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
enum NotificationTarget: string
6+
{
7+
/** For a specific User */
8+
case PRIVATE = 'private';
9+
10+
/** For all Users in a Project */
11+
case PROJECT = 'project';
12+
13+
/** For all multi-project Admins (incl. Superadmins) */
14+
case GLOBAL = 'global';
15+
}

app/Enums/NotificationType.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
enum NotificationType: string
6+
{
7+
/** Resource lifecycle events */
8+
case RESOURCE_CREATED = 'resource_created';
9+
case RESOURCE_UPDATED = 'resource_updated';
10+
case RESOURCE_DELETED = 'resource_deleted';
11+
case RESOURCE_RESTORED = 'resource_restored';
12+
13+
/** User/Member events */
14+
case REGISTRATION_CONFIRMED = 'registration_confirmed';
15+
16+
/** Unit/Housing events */
17+
case UNIT_ASSIGNED = 'unit_assigned';
18+
case LOTTERY_COMPLETED = 'lottery_completed';
19+
20+
/** Event/RSVP events */
21+
case RSVP_CONFIRMED = 'rsvp_confirmed';
22+
case EVENT_REMINDER = 'event_reminder';
23+
24+
/** Project/Construction events */
25+
case CONSTRUCTION_UPDATE = 'construction_update';
26+
case MILESTONE_REACHED = 'milestone_reached';
27+
28+
/** General/Admin events */
29+
case NEWS_POSTED = 'news_posted';
30+
case SYSTEM_ANNOUNCEMENT = 'system_announcement';
31+
case SYSTEM_MAINTENANCE = 'system_maintenance';
32+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Notification;
6+
use App\Models\NotificationRead;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Gate;
10+
11+
class NotificationReadController
12+
{
13+
/**
14+
* Mark a notification as read.
15+
*/
16+
public function read(Request $request, Notification $notification): JsonResponse
17+
{
18+
Gate::authorize('view', $notification);
19+
20+
$notification->markAsReadBy($request->user());
21+
22+
return response()->json([
23+
'success' => true,
24+
]);
25+
}
26+
27+
/**
28+
* Mark a notification as unread.
29+
*/
30+
public function unread(Request $request, Notification $notification): JsonResponse
31+
{
32+
Gate::authorize('view', $notification);
33+
34+
$notification->markAsUnreadBy($request->user());
35+
36+
return response()->json([
37+
'success' => true,
38+
]);
39+
}
40+
41+
/**
42+
* Mark all user notifications as read.
43+
*/
44+
public function readAll(Request $request): JsonResponse
45+
{
46+
$notifications = Notification::forUser($request->user())->get();
47+
48+
NotificationRead::markManyAsReadBy($notifications, $request->user());
49+
50+
return response()->json([
51+
'success' => true,
52+
]);
53+
}
54+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Resources;
4+
5+
use App\Http\Requests\FilteredIndexRequest;
6+
use Inertia\Inertia;
7+
use Inertia\Response;
8+
9+
class NotificationController extends Controller
10+
{
11+
/**
12+
* List all notifications accessible to the current user.
13+
*/
14+
public function index(FilteredIndexRequest $request): Response
15+
{
16+
$notifications = $request->user()
17+
->notifications()
18+
->when($request->q, fn ($q, $search) => $q->search($search));
19+
20+
return inertia('Notifications', [
21+
'notifications' => Inertia::defer(fn () => $notifications->paginate(30))->deepMerge(),
22+
'q' => $request->q ?? '',
23+
]);
24+
}
25+
}

app/Http/Middleware/HandleInertiaRequests.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public function share(Request $request): array
3838
...$this->transient($request),
3939

4040
'name' => config('app.name'),
41-
'env' => config('app.env'),
41+
'env' => config('app.env'),
4242

4343
'auth' => $this->auth($request),
4444

@@ -58,7 +58,7 @@ protected function auth(Request $request): ?array
5858
$user = $request->user();
5959

6060
if (! $user) {
61-
return compact('user');
61+
return ['user' => null, 'notifications' => []];
6262
}
6363

6464
// Convert to the concrete logged-in type of User: Member or Admin
@@ -67,7 +67,13 @@ protected function auth(Request $request): ?array
6767
return [
6868
'user' => [
6969
...$user->toResource()->withoutAbilities()->resolve(),
70-
'can' => $this->policies(),
70+
'can' => $this->policies(),
71+
'projects' => Project::pluck('id'),
72+
],
73+
74+
'notifications' => [
75+
'recent' => $user->recentNotifications(),
76+
'unread' => $user->unreadNotifications()->count(),
7177
],
7278
];
7379
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Http\Resources;
4+
5+
use App\Models\Notification;
6+
use Illuminate\Http\Request;
7+
8+
/**
9+
* @property-read Notification $resource
10+
*
11+
* @mixin Notification
12+
*/
13+
class NotificationResource extends JsonResource
14+
{
15+
public function toArray(Request $request): array
16+
{
17+
return [
18+
...$this->commonResourceData(),
19+
20+
'id' => $this->id,
21+
'data' => $this->data,
22+
'target' => $this->target->value,
23+
'target_id' => $this->target_id,
24+
'is_read' => $this->whenHas('read', fn () => $this->read),
25+
];
26+
}
27+
}

app/Models/Admin.php

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,6 @@
77

88
class Admin extends User
99
{
10-
/**
11-
* The table associated with the model.
12-
*
13-
* @var string|null
14-
*/
15-
protected $table = 'users';
16-
1710
/**
1811
* Get events created by this admin.
1912
*/
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
namespace App\Models\Concerns;
4+
5+
use App\Enums\NotificationTarget;
6+
use App\Models\Notification;
7+
use App\Services\Notifications\NotificationCollection;
8+
use Illuminate\Database\Eloquent\Builder;
9+
10+
/**
11+
* @mixin \App\Models\User
12+
*/
13+
trait HasNotifications
14+
{
15+
public const RECENT_NOTIFICATIONS_COUNT = 4;
16+
17+
/**
18+
* Query builder for all notifications visible to this user (private, project, and global).
19+
*/
20+
public function notifications(): Builder
21+
{
22+
return Notification::forUser($this);
23+
}
24+
25+
/**
26+
* Fetch recent notifications for this user.
27+
*/
28+
public function recentNotifications(int $limit = self::RECENT_NOTIFICATIONS_COUNT): NotificationCollection
29+
{
30+
return $this->notifications()->limit($limit)->get();
31+
}
32+
33+
/**
34+
* Private-channel notifications for this user.
35+
*/
36+
public function privateNotifications(): Builder
37+
{
38+
return Notification::forUser($this)->whereTarget(NotificationTarget::PRIVATE);
39+
}
40+
41+
/**
42+
* Query builder for all notifications visible to this user (private, project, and global).
43+
*/
44+
public function projectNotifications(): Builder
45+
{
46+
return Notification::forUser($this)->whereTarget(NotificationTarget::PROJECT);
47+
}
48+
49+
/**
50+
* Private-channel notifications for this user.
51+
*/
52+
public function globalNotifications(): Builder
53+
{
54+
return Notification::forUser($this)->whereTarget(NotificationTarget::GLOBAL);
55+
}
56+
57+
/**
58+
* Query builder for notifications read by this user.
59+
*/
60+
public function readNotifications(): Builder
61+
{
62+
return $this->notifications()->readBy($this);
63+
}
64+
65+
/**
66+
* Query builder for notifications NOT read by this user.
67+
*/
68+
public function unreadNotifications(): Builder
69+
{
70+
return $this->notifications()->unreadBy($this);
71+
}
72+
73+
/**
74+
* Pseudo-attributees to make the Builder functions act more like Relations.
75+
*/
76+
77+
public function getNotificationsAttribute(): NotificationCollection
78+
{
79+
return $this->notifications()->get();
80+
}
81+
82+
public function getPrivateNotificationsAttribute(): NotificationCollection
83+
{
84+
return $this->privateNotifications()->get();
85+
}
86+
87+
public function getProjectNotificationsAttribute(): NotificationCollection
88+
{
89+
return $this->projectNotifications()->get();
90+
}
91+
92+
public function getGlobalNotificationsAttribute(): NotificationCollection
93+
{
94+
return $this->globalNotifications()->get();
95+
}
96+
97+
public function getReadNotificationsAttribute(): NotificationCollection
98+
{
99+
return $this->readNotifications()->get();
100+
}
101+
102+
public function getUnreadNotificationsAttribute(): NotificationCollection
103+
{
104+
return $this->unreadNotifications()->get();
105+
}
106+
}

app/Models/Member.php

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,6 @@
1010

1111
class Member extends User
1212
{
13-
/**
14-
* The table associated with the model.
15-
*
16-
* @var string|null
17-
*/
18-
protected $table = 'users';
19-
2013
/**
2114
* Get the project that the member is currently member of (one or none).
2215
*/

app/Models/Model.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
use App\Models\Concerns\DerivedRelations;
66
use App\Models\Concerns\ExtendedRelations;
77
use App\Models\Concerns\HasPolicy;
8+
use App\Observers\ModelObserver;
89
use Devvir\ResourceTools\Concerns\ConvertsToJsonResource;
10+
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
911
use Illuminate\Database\Eloquent\Factories\HasFactory;
1012
use Illuminate\Database\Eloquent\Model as EloquentModel;
1113

14+
#[ObservedBy(ModelObserver::class)]
1215
class Model extends EloquentModel
1316
{
1417
use ConvertsToJsonResource;

0 commit comments

Comments
 (0)