420ef786039772b9538a8b96e7455385226b6032
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
420ef78603 |
A row the merge bug minted is visible, fixable, and repairable in bulk (#938)
#920 made a merged ticket read-only on the server and shipped NO data change, deliberately: its guard reads `status === 'merged'` and not `merged_into_id !== null`, so the rows the bug had already produced — the pointer set, the status back to `open` — stayed EDITABLE and could be corrected in the product instead of in SQL. What it left behind was rows that are simultaneously live in the All Open queue and merged into another ticket, with nothing on any screen saying so and nothing offering to fix them. Both halves of the card ship, because option A is a BEHAVIOUR RULE and a behaviour rule is a setting: B — the office fixes them one at a time. `merged_inconsistent` rides on the model (Ticket::$appends), so the list row, the index detail pane and the Show header all draw the same amber badge — "merged_into set but status open — fix?" — off one predicate rather than three copies of an expression. The badge IS the button: it opens a modal that says what will change and what will not, and posts to a new `mark-merged` verb that writes `status` and nothing else. A new "Merged" view lists every ticket folded into another one BY THE POINTER, so the consistent and the broken appear side by side; a status filter would show one or the other and never the pair worth comparing. A — the migration re-marks all of them at once, and it is INERT unless `ticketing.auto_repair_merged_on_deploy` is on. It defaults OFF, which is also what a box that has never heard of the setting does, so this can ship ahead of the operator's decision and change nothing unasked. The toggle is on the Automation tab with the live count beside it, because nobody should arm a bulk data change without seeing how many rows it touches. `MergedTicketRepair` is one implementation with three callers for one reason: "which rows are broken" and "what fixing one means" must not drift, or the badge offers a fix on a row the migration skips. THE PROD COUNT THE CARD ASKS FOR IS NOT IN HERE, and it could not be: this worker box holds no ssh private key, so central-prod is unreachable from it and there is no prod container locally. `php artisan ticketing:repair-merged --dry-run` is the read-only answer for whoever does have access — it prints the card's own SELECT COUNT and lists the rows, and `--force` (not the bare form) is the only spelling that writes. Repairing NEVER reverts. Messages, participants, assignee and title edits made to a row while it was wrongly editable are somebody's work and the only copy of it; there is no record of what the fields held before the bad save, so "reverting" would invent a previous state rather than restore one. Proved by tests/Feature/Ticketing/MergedTicketInconsistentRowsTest.php (22 tests) in dashboard-shell, over the fixture the card names — one inconsistent row beside a genuinely merged one, both produced by the real merge endpoint. The NEGCTRL half is what keeps it honest: the consistent row and an ordinary open ticket must come out of every path untouched, and a repaired row must keep the reply and the rename it was given after the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| 0e48949114 |
A ticket reference is never re-issued, and notifications name their recipient (#224, #228)
#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 |
|||
| e24124b6d4 |
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> |
|||
| 4719cbab8b |
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
|