QA, reading the dropdown back after the picker became chips: typing `an`
listed "Toews, Fast, Ivanov, Mwangi, Bergman, Anderson" — to an operator,
no order at all.
It was in fact perfectly ordered. `searchUsers()` came out of the chip
conversion with `->orderBy('name')`, and `users.name` is the cached
"First Last" ("Abigail Toews", "Andrew Fast", "Avery Ivanov") — the one
string a chip row no longer prints. A list labelled "Last, First" and
collated by "First Last" reads as unsorted, which is #524's own reported
defect; the sibling conversion on [central-staff-employment] #1597 names
it as the thing it had to fix at the same moment.
So the collation moves with the label: `PersonNames::orderUsers()`, the
platform's single answer, which the reference search
(`admin.settings.users.search`) and the other chipped pickers already
use, and which honours the operator's `name_format` rather than assuming
surname-first.
Not cosmetic: the sort runs before `limit(10)`, so on a common surname
the two collations offer a different set of people, not the same set
shuffled.
Three tests in the shell suite (TicketParticipantsAreChipsTest) —
the collation, the `name_format` setting, and which ten survive the
limit. The first and third are red against `orderBy('name')`.
The operator, 11:46 PDT 2026-09-10, having just had the device picker
converted: "are there other places we can search users where we're not
using the chips? please go through and check." The lead's audit found ten
person searches on the platform — four drawing chips, six not — and this
is central-ticketing's.
`ticketing.users.search` answered `DB::table('users')->get(['id', 'name',
'email'])`, and `show()` handed the Participants card the same three
columns for the submitter and for everybody already on the ticket. So the
one card on this page that is entirely about WHO SOMEBODY IS drew three
text rows in three different shapes: a name over an address in the
dropdown, a name-or-address string per participant, and a third spelling
for the submitter. An agent adding a participant to a ticket about a
child chooses between the child and the parent of the same surname with
no role dot to tell them apart.
And it was worse than untidy. Name and email were the SHIPPED DEFAULT for
a chip, so a school that configured the student chip to grade + homeroom
kept being shown an address here — on the one people surface the
operator's own setting could not reach. That is the defect #871 exists to
remove, and this endpoint was still carrying it.
Both halves now go through `PersonChip::rows()`, so what a row SAYS is
the setting rather than a select list in this file, and the person PICKED
is byte-for-byte the person picked FROM — one serializer, one answer. The
search's payload is now the same, key for key, as
`admin.settings.users.search`; the test compares the two endpoints against
each other rather than against a literal list, because a literal passes on
the day it is written and then lets this surface fall behind the next key
the shared row grows.
`PersonChip::columns()` is the select list, not `['id', 'name', 'email']`:
a chip line may name any user column, and a constrained select hands the
helper a model whose attribute is simply ABSENT — which it cannot tell
from a person who has no value, so the line goes blank for everybody with
nothing failing.
`rows()` and NOT the model, in `show()`. That map also feeds the message
bylines and the assignee, so handing the page a `User` would put every
column of `users` — `preferred_pin`, `preferred_password`,
`date_of_birth` — in front of any agent who opens a ticket. `rows()` is
`{id, name, email, person}` and `person` is the curated allow-list. There
is a test that a PIN cannot reach the page.
WHO IS SEARCHABLE DOES NOT CHANGE, per the card. Same two-character
floor, same name-or-email `like`, same limit of 10, same exclusion of the
people already on the ticket, same agent gate; `User` carries no global
scope or soft delete `DB::table('users')` did not. The name-or-email
clause stays inside its own closure on purpose — flattened beside the
exclusion it is `(name) OR (email AND NOT IN)` by SQL's own precedence,
so a name match jumps the exclusion, which is the hole the same conversion
opened on [central-inventory] #1594. `orderBy('name')` is the one visible
difference and it is new: the old query had no order at all, so the same
two letters could list the same ten people differently twice running.
The Vue half, the source guard and the feature tests are on
dashboard-shell's branch of the same name.
`tickets.merged_into_id` is `nullOnDelete()` and `destroy()` was a one-liner, so
deleting the surviving ticket of a merge blanked the pointer on every ticket
merged into it and left `status = 'merged'` behind. The row that came out was
read-only for good (#920 guards on the status), carried a banner naming a ticket
that no longer existed, reported `merged_inconsistent = false` — the flag that
exists to catch exactly this — and dropped out of the Merged view, which was
scoped by the pointer, leaving it visible only under Resolved.
`StrandedMerge::handleTargetDeletion()` runs inside destroy()'s transaction,
before the row goes: sources are RE-POINTED at the doomed ticket's own merge
target when it has one (the messages really did travel that far up the chain),
otherwise UN-MERGED to `closed` — out of the work queues, as they were, but
editable, reopenable and answerable again — with a system note naming what was
deleted and why. #938's dangling pointers are simply cleared: those rows are
somebody's open work. The flash names every ticket that changed, and the delete
confirm names them before the click instead of the generic "cannot be undone".
The shape prod already holds gets a name: `scopeStrandedMerge()`, reported as
`merged_inconsistent` (now the union of both broken shapes) plus
`merged_stranded` so a page can tell which fix to offer, listed by the Merged
view via `scopeEverMerged()`, and released one at a time by `unmerge`. #938's
repair scope is deliberately untouched — it writes `status = 'merged'`, which a
stranded row already has, and matching it would break its idempotency.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The count and the list were the same question spelled two ways in one
method: the sidebar counts scope by $closedSlugs, read from
ticketing_statuses, while the `unassigned` and `mine` list branches
carried a hand-written ["resolved", "closed"] that predates the `merged`
status. So a correctly merged ticket — status merged, merged_into_id set,
its own Show page already calling it read-only — stayed in the queue
agents pull new work from, and the page contradicted itself on screen:
"Unassigned 4" beside five rendered rows.
Both queues now scope by $closedSlugs, so they exclude exactly what their
own counts exclude. The scoping is by STATUS and not by merged_into_id on
purpose: #938 rows (pointer set, status still open) are live, editable
work until a human repairs them, and dropping them out of the queue would
hide the rows that most need one. There is a NEGCTRL test for that.
Also, the secondary finding: ?filter=closed was accepted, silently
returned the All-Open set and was echoed back in props.filters as though
a Closed view existed. The sidebar has six views and that is not one of
them, so an unrecognised filter normalises to `all` and says so. The list
is now a constant that savePrefs() validates against too, rather than the
same six slugs written out twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#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>
The Show page announced "This ticket has been merged and is read-only.",
hid the reply composer and withheld the Merge button — and that was the
whole guard. It was presentation, on one page.
The Edit link and the Split button were still rendered, `edit()` still
served the form (with its Status select blank, because `merged` was not
one of the five slugs the form hard-coded), and `update()` took the save
with `errors: {}`: status went back to `open` while `merged_into_id` kept
pointing at the merge target. The row that came out was live and
assignable in the All Open queue AND merged into another ticket, holding
none of its own messages because the merge had moved them — an empty
shell that reads as a real duplicate of the ticket it was folded into.
So the rule now lives on the server, once — `Ticket::isMerged()` and
`refuseIfMerged()` — and every write the page presents as unavailable
calls it: update, edit, split, participants add/remove, and a reply or an
attachment (both hidden by the banner, neither refused by its route).
Two deliberate choices:
- the guard reads `status === 'merged'`, NOT `merged_into_id !== null`.
Reading the pointer would look stricter and would freeze every row
this bug already minted (`status = open, merged_into_id = 56`), so
the only way to correct one would be SQL.
- it refuses with a redirect carrying the reason, not `abort(403)`.
The realistic way to arrive here is a page that was already open when
somebody else merged the ticket, and that operator is better served
by the sentence than by an error screen.
The blank Status select was the report's second symptom, and it was the
same defect one layer up: the five hard-coded options meant any ticket
whose status was not among them — `merged`, or any status added on the
Statuses settings tab — rendered with nothing selected, and the first
save silently rewrote the status to whatever happened to be showing. The
options now come from the same table `update()` validates against.
Merge and Split both 500ed. The audit note each writes uses `source = 'system'`
and the column is `enum('web','email')`, so the insert threw inside the
transaction and nothing committed — which is why the operator saw no state change
at all rather than a half-done merge.
Widened to an enum rather than a varchar, and the reasoning is the same test that
made `tickets.status` a varchar in `2026_04_17_000002`: statuses became
operator-definable rows, so their domain is open. `source` is the opposite — all
three values are set by code, an operator can never add a fourth, and the enum is
what keeps a typo'd source loud instead of a silently unfilterable row.
`TicketMessage::system()` is now the only place a system note is written. Three
scattered literals is how this reopens.
A SECOND 500 ON THE SAME ACTION, unreported and one longer title away. `split()`
appended ' (split)' to a title the product validates at max:255, so splitting a
full-length title produced 263 characters and the identical hard failure. Bounded
at the write rather than widened, and the distinction is written down: widen when
the data is right and the column is wrong; bound when the column expresses a real
product rule. 255 is the product's own limit, so a 263-character title would be
un-editable forever — the edit form would bounce it every time.
And `split()` now honours its own comment. Line 682 has always said "exclude
system notes" while the code copied them; it never mattered because no system row
could exist. The moment the enum accepts them, splitting a merged ticket would
carry "Merged from ticket QAX-0005." onto a ticket that was never merged.
#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
Setting a ticket's Due Date returned 303 with "Ticket updated." and never saved
the value. Reproduced by the QA sweep on all three paths that expose the field —
the edit form, the update endpoint and creation.
A success message covering a silent discard is worse than an error: nobody goes
looking, and the operator believes a date is set that no query will ever find.
2,862 passed across the affected suites. Committed by the lead — the authoring
agent stopped without reporting, so the code is the account of record.
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>
`stability` is what dashboard:doctor and the ship-dark contract read, and it said
"stable" for an app that is not running in production. That is worse than redundant —
it is doctor reporting the wrong thing about the deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
`group_id: [14]` — a one-element array of a REAL group id — returned a 500.
The reporter's analysis was right: `exists` given an array validates that
EVERY element exists, which is multi-select behaviour, so an array of one
valid id passed the rule and reached the controller. findOrFail() then
returned a Collection and ->nextTicketNumber() on it fatalled.
What hid it is that the rule looked like it was working: `['a','b']` was
correctly rejected by `exists` with a clean 422. Only arrays made of valid
ids got through.
Adding `integer` ahead of `exists` fixes the reported field, but the hole
was a property of `exists`, not of group_id — so every id on the endpoint
had it. Fixed all nine sites across both controllers: store()'s group_id
and priority_id, update()'s priority_id/project_id/group_id, and
storeAgent/storeProject/storeEmailConnection's group_id (plus user_id,
which fed straight into an access check).
Tests pin the reported repro, the equivalent on priority_id, and both
shapes that must keep behaving: a nonexistent id still 422s (so the exists
check cannot be dropped later while the array tests still pass) and a
normal scalar create still succeeds. Verified the array test FAILS with the
rules reverted — it reproduces the 500 rather than merely passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
my-tickets and prefs.save carried only `app.access:ticketing`, which means "you hold ANY
ticketing.* permission" — so ticketing.settings alone opened the submitter's own surface.
They now require ticketing.view or ticketing.manage, matching the either-or this route
file already uses everywhere else; ticketing.view is literally described as "Submit
tickets and view own ticket history", so it is the right key.
attachments.show stays UNGATED on purpose. TicketAttachmentController::show already
authorises submitter-or-agent for that specific attachment, which is finer than any
permission can be — a key in front of it would either duplicate the check or contradict
it, and contradiction is what this consolidation exists to remove.
Part of consolidating Central onto one authorisation mechanism: permissions, held by
groups.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
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
There was no destroy route at all — only settings.groups.store and .update — so
the e2e CRUD spec, which creates an ephemeral group as its documented setup step,
could never clean up (a DELETE-spoofed POST returned 405). Every sweep left litter
behind, and an operator who mistyped a group was stuck with it forever.
Deleting a group cannot be unconditional: tickets are the audit record of real
correspondence, and tickets.group_id is a RESTRICT FK. So destroyGroup counts the
tickets and picks:
0 tickets -> real delete (agent access / projects / email connections are
cascadeOnDelete, so nothing dangles). This is the teardown and
undo-a-typo path.
>=1 ticket -> archived_at = now(). Tickets untouched and still readable.
The caller is TOLD which happened rather than inferring it from a 302: an
expectsJson caller gets {"outcome":"deleted"|"archived","ticket_count":N}, the UI
gets distinct flash wording, and the confirm modal says which it will be BEFORE
you click ("This group has 1 ticket and will be archived, not deleted").
Archiving is idempotent; restore is a separate route.
Archived groups leave the PICKERS but nothing else: index() and create() use
scopeActive, store() validation adds whereNull('archived_at') so a stale id cannot
bypass the picker, and update() rejects transfers INTO an archived group while
always allowing the ticket's own group — otherwise a ticket stranded in an
archived group would become uneditable. Index.vue re-adds that own group to the
transfer select labelled "(archived)" so the select is never blank. Deliberately
NOT filtered: the Settings groups list (archived rows show a badge + Restore), and
agentGroupIds()/isAgent()/isManager(), which read ticketing_agent_access — so
existing agents keep working the tickets that remain. No global scope; exclusion
is per call site.
Gating is byte-identical to updateGroup: inside the existing
permission:ticketing.settings group plus requireManagerAccess(), so a super admin
with no manager row on that group is still refused (asserted).
Migration is additive — one nullable timestamp. down() is a deliberate NO-OP,
matching the myedpuller-sync warnings-column precedent: DashboardDoctor greps the
whole file for drop/rename patterns, so a dropColumn even in down() would raise a
"pending destructive migration" advisory on every host where it is still pending,
which is a confusing warning for a purely additive change. Verified against
doctor's exact regex, not a looser grep.
Tests: 9 (69 assertions). 10 mutations, all 10 detected — including forcing a hard
delete on a group with tickets, which throws a QueryException from the RESTRICT FK,
i.e. the database backs up the application check.
Browser-verified on dev: the confirm modal wording for both cases, the ARCHIVED
badge and Restore, the archived group vanishing from both the create picker and the
All-Tickets switcher while its ticket stays listed, and ZERO native
confirm/alert dialogs. doctor exit 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1. App\Mail\LoginCodeMail + its Blade view. Login codes go through
SystemEmail::send('login_code', …), whose TEMPLATES entry carries its own
plain-text body — the `emails.login-code` view was referenced ONLY from
LoginCodeMail::content(). Checked all 30 repos: the remaining hits were the
class itself, two now-unused imports in LoginCodeService, its own render test,
and one historical plan doc (prose, left).
2. CustomFieldsController::index(). It rendered 'Admin/Tables', and **no
Tables.vue exists anywhere in the workspace** — so it would have blank-screened
even if something routed to it, and nothing does. The live editor is
Admin → User Settings → Fields (UserSettingsController::index builds a strict
superset of that payload), and /admin/settings/tables already redirects there.
All four registered actions (store/update/destroy/reorder) still route and were
re-verified by name after the change. A docblock now records where the UI lives
so the method does not get re-added.
3. Thirteen stale Vue duplicates in dashboard-expenses and dashboard-ticketing.
Those pages are served FROM THE SHELL — app.js resolves ./Pages/** before any
packages/* glob, Ticketing was never globbed at all, and the built manifest
carried the packages/expenses copies only as shadowed dead weight. All 13 were
diffed against the shell before deletion: every one is the April original plus
the July full-width sweep. Two were not merely stale but BROKEN —
Expenses/Budgets called route('expenses.budgets.index'), a name that does not
exist, and BudgetEdit still used fiscal_year after the controller moved to
schoolYears. One genuine difference is noted rather than lost: the snap-in's
Ticketing/Create gated its "Go to Settings" link on isSiteAdmin while the
shell's shows it unconditionally, so a non-admin sees a link that 403s — a
pre-existing shell behaviour (Index.vue does the same), not caused by this.
This matters because config/inertia.php now registers snap-in page roots for
tests: a page added ONLY to a snap-in copy would have PASSED tests while 404ing
in a browser. resources/js/app.js drops the dangling packages/expenses glob,
with a comment tying its list to config/inertia.php so the two cannot drift.
Bonus: the same trap existed in dashboard-unifi, whose resources/js/Pages was an
empty untracked scaffold that app.js never globbed. Removed. The glob now returns
exactly the four roots app.js resolves — admissions, forms, tuition, verification.
Tests: 83 (was 84; -1 is the deleted mailable render test), doctor exit 0, and a
browser pass over all seven Expenses/Ticketing pages plus /admin/user-settings/fields
and /admin/settings/tables — 200, mounted, zero console errors. /app/ticketing/create
renders the attachments input, proving the shell copy is what serves.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three reads were `config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'))`,
but this package had no config file at all — so the config() lookup was always
null and env() was the only reader.
Once `config:cache` runs in production, a runtime env() returns NULL. Every
ticket attachment would have silently gone to the 'local' disk, ignoring
whatever the operator had configured — and previously-stored attachments would
404 because show() looked on the wrong disk.
Verified rather than assumed: with TICKETING_STORAGE_DISK=s3proof set,
config() returned 's3proof' both cached and uncached, while env() returned
's3proof' uncached and NULL cached.
Adds config/ticketing.php + mergeConfigFrom so the value is captured at
cache time, and drops the env() fallbacks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ship-dark and the dashboard:doctor deploy gate only protect apps that DECLARE
what they are; this app was falling through to defaults. Pure metadata, additive
only, no version bump (repo convention: manifest-only changes do not bump).
bosch is BETA deliberately: production last deployed 2026-07-16 and this app was
built on the 19th, so the next prod deploy is its FIRST install — it talks to a
real alarm panel and its supervised cutover day has not happened. Ship-dark means
it lands disabled until an operator turns it on. signinout is beta for the same
reason (planned, scaffold only). Everything long-live in prod is stable, because
wrongly tagging a live app beta could dark it, while stable never disables
anything.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Page wrappers drop their max-w cap in line with the shell's style guide: the
page uses the whole content area. Single-column create/edit forms keep a capped
column (w-full max-w-2xl) so their inputs stay readable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes dashboard-ticketing #5: sidebar and group switcher overflowed on 375px viewport.
Adds min-w-0 to aside and inner content div, truncates header labels. Settings tab nav
already had overflow-x-auto with shrink-0 tabs; no additional changes needed there.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isSiteAdmin() used the dropped role field. Now checks is_super_admin
or ticketing.settings permission. Closes#2.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Route ordering caused /settings to be captured by the /{ticket} wildcard,
returning 404. Also pass safe empty pagination object when no groups exist
to prevent TypeError reading .total on null. Closes#2#3.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Show 'Go to Settings' bootstrap link only for admin/super_admin users
- Pass isSiteAdmin prop to Create.vue to control settings CTA visibility
- Require site admin for updatePriority/destroyPriority when priority is global (group_id = null)
- Closes: non-admin users seeing forbidden settings link; agents mutating global priorities
nextTicketNumber() was using count()+1 which collides when tickets
have been deleted. Now queries MAX number via SUBSTRING ordering so
the generated ticket number is always unique.
- Bootstrap (critical): settings/create/index no longer 403 on fresh install.
Site admins (admin/super_admin) can access settings when 0 groups exist.
First group creation seeds default priorities (Low/Medium/High/Urgent).
Index shows friendly first-run splash. Create shows warning + settings link.
- Internal notes leak (high): submitters can no longer receive is_internal
messages via ticket show, index detail panel, or any Inertia prop.
filterMessagesForRole() strips internal notes for non-agents.
- Arbitrary assignee (med/high): update() now validates assigned_to against
actual agent-access users for the ticket's group server-side.
- Cross-group priority/project forgery (medium): store() and update() now
verify priority_id and project_id belong to the ticket's own group (or
are global for priorities).
- Foreign message_id on attachment upload (medium): message_id is now
validated to belong to the current ticket, not just any message row.