Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.26% covered (success)
96.26%
103 / 107
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
InAppNotificationService
96.26% covered (success)
96.26%
103 / 107
50.00% covered (danger)
50.00%
3 / 6
26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAndMarkForUser
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
14.07
 updateNotificationStatus
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 assignCampaignToUsers
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
5
 toPayload
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 getDailyCap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace App\Http\Services;
4
5use App\Enums\InAppNotificationStatus;
6use App\Http\Models\Auth\User;
7use App\Http\Models\InAppNotification;
8use App\Http\Repositories\interfaces\IInAppNotificationCampaignRepository;
9use App\Http\Repositories\interfaces\IInAppNotificationRepository;
10use Carbon\Carbon;
11
12/**
13 * Service for in-app notification delivery and status management.
14 *
15 * Handles core notification logic:
16 * - Fetching and marking pending notifications for users
17 * - Status transitions (viewed, clicked, dismissed)
18 * - Campaign assignment to users
19 */
20class InAppNotificationService
21{
22    /**
23     * Default daily cap when no remote config value is set.
24     */
25    private const DEFAULT_DAILY_CAP = 3;
26
27    public function __construct(
28        private readonly IInAppNotificationRepository $notificationRepository,
29        private readonly IInAppNotificationCampaignRepository $campaignRepository,
30        private readonly RemoteConfigService $remoteConfigService
31    ) {}
32
33    /**
34     * Fetch pending notifications for a user, filter by plan/expiry, mark as sent, and return payload.
35     *
36     * @param  string  $userId  The user's ID
37     * @param  string  $planIdentifier  The user's current plan identifier
38     * @return array<int, array<string, mixed>> Notification payloads for the extension
39     */
40    public function getAndMarkForUser(string $userId, string $planIdentifier): array
41    {
42        $pendingNotifications = $this->notificationRepository->getPendingForUser($userId);
43
44        if ($pendingNotifications->isEmpty()) {
45            return [];
46        }
47
48        $dailyCap = $this->getDailyCap();
49        $sentToday = $this->notificationRepository->countSentTodayForUser($userId);
50        $remaining = max(0, $dailyCap - $sentToday);
51
52        if ($remaining === 0) {
53            return [];
54        }
55
56        $now = Carbon::now();
57        $eligible = [];
58
59        foreach ($pendingNotifications as $notification) {
60            $campaign = $notification->campaign;
61
62            if (! $campaign || ! $campaign->is_active) {
63                continue;
64            }
65
66            // Filter expired campaigns
67            if ($campaign->end_date && $campaign->end_date->lt($now)) {
68                continue;
69            }
70
71            // Filter campaigns that haven't started
72            if ($campaign->start_date && $campaign->start_date->gt($now)) {
73                continue;
74            }
75
76            // Filter by target plans
77            if (! empty($campaign->target_plans) && ! in_array($planIdentifier, $campaign->target_plans)) {
78                continue;
79            }
80
81            $eligible[] = $notification;
82
83            if (count($eligible) >= $remaining) {
84                break;
85            }
86        }
87
88        if (empty($eligible)) {
89            return [];
90        }
91
92        $ids = array_map(fn (InAppNotification $n) => (string) $n->_id, $eligible);
93        $this->notificationRepository->markAsSent($ids);
94
95        return array_map(fn (InAppNotification $n) => $this->toPayload($n), $eligible);
96    }
97
98    /**
99     * Update a notification's status.
100     *
101     * @param  string  $notificationId  The notification ID
102     * @param  string  $userId  The authenticated user's ID
103     * @param  string  $status  The new status (viewed, clicked, dismissed)
104     * @return InAppNotification|null Updated notification or null if not found/invalid transition
105     */
106    public function updateNotificationStatus(string $notificationId, string $userId, string $status): ?InAppNotification
107    {
108        $notification = $this->notificationRepository->getByIdAndUser($notificationId, $userId);
109
110        if (! $notification) {
111            return null;
112        }
113
114        $currentStatus = $notification->getStatusEnum();
115        $newStatus = InAppNotificationStatus::from($status);
116
117        if (! $currentStatus->canTransitionTo($newStatus)) {
118            return null;
119        }
120
121        $data = ['status' => $newStatus->value];
122        $timestampField = match ($newStatus) {
123            InAppNotificationStatus::VIEWED => 'viewed_at',
124            InAppNotificationStatus::CLICKED => 'clicked_at',
125            InAppNotificationStatus::DISMISSED => 'dismissed_at',
126            default => null,
127        };
128
129        if ($timestampField) {
130            $data[$timestampField] = Carbon::now();
131        }
132
133        return $this->notificationRepository->updateStatus($notification, $data);
134    }
135
136    /**
137     * Assign a campaign to matching users by creating per-user notification records.
138     *
139     * @param  string  $campaignId  The campaign ID
140     * @return int Number of new records created
141     */
142    public function assignCampaignToUsers(string $campaignId): int
143    {
144        $campaign = $this->campaignRepository->getById($campaignId);
145
146        if (! $campaign) {
147            return 0;
148        }
149
150        // Get all user IDs; plan filtering happens at delivery time via target_plans
151        $userIds = User::pluck('_id')->map(fn ($id) => (string) $id)->toArray();
152
153        if (empty($userIds)) {
154            return 0;
155        }
156
157        // Get user_ids that already have records for this campaign (dedup)
158        $existingUserIds = InAppNotification::where('campaign_id', $campaignId)
159            ->pluck('user_id')
160            ->map(fn ($id) => (string) $id)
161            ->toArray();
162
163        $newUserIds = array_diff($userIds, $existingUserIds);
164
165        if (empty($newUserIds)) {
166            return 0;
167        }
168
169        $now = Carbon::now();
170        $records = array_map(fn (string $userId) => [
171            'campaign_id' => $campaignId,
172            'user_id' => $userId,
173            'status' => InAppNotificationStatus::PENDING->value,
174            'sent_at' => null,
175            'viewed_at' => null,
176            'clicked_at' => null,
177            'dismissed_at' => null,
178            'impression_count' => 0,
179            'created_at' => $now,
180            'updated_at' => $now,
181        ], array_values($newUserIds));
182
183        // Insert in chunks to avoid memory issues
184        foreach (array_chunk($records, 500) as $chunk) {
185            $this->notificationRepository->createBatch($chunk);
186        }
187
188        return count($records);
189    }
190
191    /**
192     * Transform a notification + campaign into the extension payload shape.
193     *
194     * @return array<string, mixed>
195     */
196    private function toPayload(InAppNotification $notification): array
197    {
198        $campaign = $notification->campaign;
199
200        return [
201            'id' => (string) $notification->_id,
202            'priority' => $campaign->priority,
203            'title' => $campaign->title,
204            'body' => $campaign->body,
205            'icon_url' => $campaign->icon_url,
206            'image_url' => $campaign->image_url,
207            'iframe_url' => $campaign->iframe_url,
208            'iframe_width' => $campaign->iframe_width,
209            'iframe_height' => $campaign->iframe_height,
210            'cta_text' => $campaign->cta_text,
211            'cta_url' => $campaign->cta_url,
212            'cta_action' => $campaign->cta_action,
213            'secondary_cta_text' => $campaign->secondary_cta_text,
214            'secondary_cta_url' => $campaign->secondary_cta_url,
215            'accent_color' => $campaign->accent_color,
216            'display_mode' => $campaign->display_mode,
217            'position' => $campaign->position,
218            'target_urls' => $campaign->target_urls,
219            'requires_auth' => $campaign->requires_auth,
220            'target_plans' => $campaign->target_plans,
221            'max_impressions' => $campaign->max_impressions,
222            'cooldown_hours' => $campaign->cooldown_hours,
223            'start_date' => $campaign->start_date?->timestamp,
224            'end_date' => $campaign->end_date?->timestamp,
225            'auto_dismiss_seconds' => $campaign->auto_dismiss_seconds,
226            'tracking_event' => $campaign->tracking_event,
227        ];
228    }
229
230    /**
231     * Get the daily notification cap from remote config.
232     */
233    private function getDailyCap(): int
234    {
235        $config = $this->remoteConfigService->getCurrent();
236
237        $featureFlags = $config?->feature_flags;
238
239        return $featureFlags['notification_daily_cap'] ?? self::DEFAULT_DAILY_CAP;
240    }
241}