Ticketing joins the notification platform (closes #8)

No ticketing.* category existed, so the shared Notification Log offered a filter for
every other app and nothing for this one. Verified after this change: 2 categories
register, 39 total.

THIS WAS NOT A MAIL SWEEP. Every other app's integration redirected an existing Mail::
call; here there was nothing to redirect. TicketMessageController::notifySubmitter()
was an empty stub:

    // Placeholder for email notification
    // In production: Mail::to($submitterEmail)->send(new TicketReplyMail(…));

So nobody has ever been told their ticket was answered. The reporter read the absence
of a Notifications directory as "not wired up", and it was more literal than that.

TWO CATEGORIES, matching the two moments somebody is waiting on another person:

  ticketing.replied  → the submitter, when an agent replies. Internal notes are
    excluded at the call site, which already distinguishes them — the entire point of
    an internal note is that the submitter does not see it.

  ticketing.assigned → the agent a ticket is handed to. The previous assignee is
    captured BEFORE the write so a real hand-off is distinguishable from a save that
    merely touched other fields, and assigning to yourself sends nothing.

Neither is mandatory: a reply notice is a convenience, not a security or financial
record, so it is opt-out in a person's preferences.

Bridge shape copied from Dashboard\Tools\Support\ToolsNotifier: one class owning the
platform contact, available() false when the platform is absent or dark, and a queue
failure logged rather than thrown — sending a reply must succeed even when the email
cannot be queued. DELIBERATELY NOT a manifest dependency, for the documented reason:
requires.apps is a HARD requirement and Notifications is still beta and ships dark.

There is no direct-mail fallback, unlike the swept apps, because there was never any
direct mail to fall back to. With Notifications dark, ticketing behaves exactly as it
does today: silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
This commit is contained in:
2026-07-28 12:58:24 -04:00
parent 1175c20a57
commit 4719cbab8b
4 changed files with 188 additions and 2 deletions

View File

@@ -0,0 +1,132 @@
<?php
namespace Dashboard\Ticketing\Support;
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.',
],
self::ASSIGNED => [
'A help desk ticket was assigned to you',
'Sent to an agent when a ticket is assigned to them by somebody else.',
],
];
/** 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]) {
\Central\Notifications\Support\NotificationRegistry::register(
$key, $label, $description, ['mail'], mandatory: false,
);
}
} catch (\Throwable $e) {
Log::warning('Ticketing: could not register notification categories: '.$e->getMessage());
}
}
/**
* Queue one email through the platform.
*
* @param string $to recipient address, verbatim
* @return array{handled:bool,status:?string,error:?string,id:?int}
*/
public static function deliver(
string $category,
string $to,
string $subject,
string $bodyHtml,
?Model $context = null,
): array {
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
if (! self::available() || 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,
];
}
}