Notification categories now declare who they are FOR

A parent opening their notification settings saw every category in the system -
Bosch, Inbox, Inventory, Oncall, Signage, Ticketing, Tools - and an ALWAYS SENT
list including "Impersonation attempts locked out", "Notice of Employment
posted" and "New school account". None of it could ever reach them. The registry
was a static catalogue with no per-user filter and the page rendered 100% of it
to 100% of users.

Permission answers "may you administer this". It never answered "might this be
about you". Categories now declare an audience - everyone / staff / student /
family - resolved RECORD-AWARE via the shell's App\Support\Audience, so on-call
staff count as staff without naming a role and a stale role with no record does
not.

ONE RESOLVER, NOT TWO. NotificationAudience delegates cohortsFor/matches/
familyRoles to the shell behind a class_exists guard, with a legacy path so this
ship-dark beta still degrades rather than fataling on an older shell. The local
FAMILY_ROLES_WITHOUT_ADMISSIONS = ['parent'] fallback is DELETED - it had already
drifted, and on a shell without admissions it would have silently stopped
counting applicant/open-house/homeschool as family.

FILTERED IN selfServe(), NOT THE CONTROLLER. ChannelFloor counts the same list
the page shows to enforce "you cannot switch off your last channel". Filtering
in the controller instead would have let a parent be REFUSED A SAVE because of a
Bosch category they cannot see. A test pins the two lists as the same set for
parent, staff and student, with a non-empty guard so the comparison cannot pass
vacuously.

THE THROW COULD NOT DO ITS JOB. Registering without an audience throws outside
production and falls back to staff (fail-closed) inside it - but every snap-in
wraps register() in a bare catch(\Throwable) and most register in a LOOP, so a
missing argument is swallowed AND silently drops every remaining category for
that app. Mutation M8 proves it: deleting one audience argument loses all six
task categories with nothing logged. EXPECTED_CATEGORY_COUNT = 43 is the only
thing that catches it, because a per-app try/catch guarantees this never
surfaces as an error.

Classification corrected three of my proposed defaults against the evidence:
ticketing.replied is staff+student, not family - a parent cannot raise a ticket
(submitter_id is hardcoded to Auth::id(), there is no inbound-email path, and
canLogin() bars parents). system.school_account_created is staff+student - its
own description says "a new staff member OR STUDENT". forms.* is everyone,
because forms go to staff self-service AND to families.

13 mutations, 12 red. M11 SURVIVED and is reported rather than papered over: the
explicit unknown-cohort loop in normalise() is genuinely redundant, since the
trailing array_filter also drops a typo. M11b removes both and goes red, so the
behaviour is pinned by something.

Super-admin: no bypass in the send path, deliberately - a staff-targeted notice
must not reach the diagnostic super accounts. A bypass exists ONLY in catalogue
visibility, because impersonation_lockout is addressed to administrators rather
than to a staff record, so a super would otherwise be hidden mail they do get.
It sits in the shared filter so the floor stays in lockstep.

Also: the digest registers with coalesce: false. It goes out through Notify like
anything else, so the 5-minute coalescer could hold it and fold it into a
summary-of-summaries. A digest is already a batch.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-05 21:36:19 -04:00
parent 82892deafe
commit e24124b6d4

View File

@@ -47,10 +47,12 @@ final class TicketingNotifier
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'],
],
];
@@ -77,9 +79,9 @@ final class TicketingNotifier
}
try {
foreach (self::CATEGORIES as $key => [$label, $description]) {
foreach (self::CATEGORIES as $key => [$label, $description, $audience]) {
\Central\Notifications\Support\NotificationRegistry::register(
$key, $label, $description, ['mail'], mandatory: false,
$key, $label, $description, ['mail'], mandatory: false, audience: $audience,
);
}
} catch (\Throwable $e) {