#224 — the next number came from the live rows, so deleting a ticket freed its reference and two tickets could both be QAX-0004. An old email then points at a different ticket than it did. The sequence is now held on the group and only ever moves forward. #228 — ticketing notifications carried no user, so the Notification Log showed the message type where the recipient belongs and the per-person opt-out was ignored: somebody who had opted out still received them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ec3REU4LQmhuchRRn6dZHk
162 lines
6.8 KiB
PHP
162 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Ticketing\Support;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* The ONE place Ticketing reaches into the Notifications platform app (bridge
|
|
* shape: Dashboard\Tools\Support\ToolsNotifier).
|
|
*
|
|
* Issue #8: ticketing had NO notification integration at all — no `ticketing.*`
|
|
* category existed, so the shared Notification Log listed a category for every
|
|
* other app and nothing for this one. Note this was not a mail sweep like the
|
|
* other apps: there was no existing Mail:: call to redirect.
|
|
* TicketMessageController::notifySubmitter() was an empty stub with the comment
|
|
* "In production: Mail::to($submitterEmail)->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<string, array{0:string,1:string}> 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,
|
|
];
|
|
}
|
|
}
|