send(new TicketReplyMail(…))", so * nobody has ever been told their ticket was answered. * * TWO CATEGORIES, matching the two moments a person is waiting on somebody else: * * - ticketing.replied → the submitter, when an agent answers. Internal notes are * excluded by the caller: the whole point of an internal note is that the * submitter does not see it. * - ticketing.assigned → the agent a ticket has just been handed to. Skipped when * somebody assigns a ticket to themselves, which needs no telling. * * Neither is mandatory. A reply notice is a convenience, not a security or * financial record, so a person may turn it off in their notification preferences. * * SHIP-DARK SAFETY. available() is false when the platform is absent, and every * caller treats a false return as "nothing was sent" rather than an error — there is * no direct-mail fallback to run, because there was never any direct mail here. * DELIBERATELY NOT a manifest dependency: requires.apps is a HARD requirement (a * merely-disabled dep fails dashboard:doctor, which gates update.sh and promote.sh), * and Notifications is a beta app that ships dark. Add it once Notifications is * stability: stable. */ final class TicketingNotifier { public const REPLIED = 'ticketing.replied'; public const ASSIGNED = 'ticketing.assigned'; /** @var array key => [label, description] */ public const CATEGORIES = [ self::REPLIED => [ 'Your help desk ticket was answered', 'Sent to the person who raised a ticket when an agent replies. Internal notes never trigger this.', ['staff', 'student'], ], self::ASSIGNED => [ 'A help desk ticket was assigned to you', 'Sent to an agent when a ticket is assigned to them by somebody else.', ['staff'], ], ]; /** Test hook — behave as though Notifications isn't installed. */ private static bool $pretendMissing = false; public static function available(): bool { return ! self::$pretendMissing && class_exists(\Central\Notifications\Support\Notify::class) && class_exists(\Central\Notifications\Support\Message::class); } public static function pretendPlatformMissing(bool $missing = true): void { self::$pretendMissing = $missing; } /** Declare the categories. Called from TicketingServiceProvider::boot(). */ public static function registerCategories(): void { if (! class_exists(\Central\Notifications\Support\NotificationRegistry::class)) { return; } try { foreach (self::CATEGORIES as $key => [$label, $description, $audience]) { \Central\Notifications\Support\NotificationRegistry::register( $key, $label, $description, ['mail'], mandatory: false, audience: $audience, ); } } catch (\Throwable $e) { Log::warning('Ticketing: could not register notification categories: '.$e->getMessage()); } } /** * Queue one email through the platform. * * PASS THE PERSON, NOT THE ADDRESS, whenever we have one. * * This took a bare string, and `Notify::sendAll()` derives everything * user-shaped from the recipient it is handed: `$user = $recipient instanceof * User ? $recipient : null`. With a string there is no `$user`, so three * things silently went missing at once, all from the same line: * * - `to_user_id` was null, so the send never appeared in that person's * notification history; * - `to_name` was null, so the shared Notification Log fell through to its * "we don't know who this went to" rendering and printed the message TYPE * where the recipient's name belongs — for people we plainly do know, since * both our notifications go to a Central user by definition (a ticket's * submitter, or the agent it was just assigned to); * - the preference branch is guarded by `if ($user && ! $mandatory && …)`, so * it was skipped entirely and **the opt-out did nothing**. Neither of our * categories is mandatory, so My Notifications offered a switch that was * never read. * * A bare string is still accepted, for a future send to an address with no * account behind it. It is simply no longer what our own callers hand over. * * Note we no longer resolve the address by hand either: `Notify` resolves a * User school → personal → login, which is the platform's answer to "where do * we email this person" and is what every other app gets. * * @param User|string $to the recipient — a User wherever one is known * @return array{handled:bool,status:?string,error:?string,id:?int} */ public static function deliver( string $category, User|string $to, string $subject, string $bodyHtml, ?Model $context = null, ): array { $unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null]; if (! self::available() || (is_string($to) && trim($to) === '')) { return $unhandled; } try { $row = \Central\Notifications\Support\Notify::send(new \Central\Notifications\Support\Message( category: $category, to: $to, subject: $subject, body: $bodyHtml, context: $context, )); } catch (\Throwable $e) { // Never let a notification failure break the action that caused it: // sending a reply must succeed even if the email cannot be queued. Log::warning("Ticketing: notification '{$category}' could not be queued: ".$e->getMessage()); return $unhandled; } return [ 'handled' => true, 'status' => $row->status, 'error' => $row->status === 'failed' ? ($row->error ?: 'The notification could not be queued.') : null, 'id' => $row->id, ]; } }