`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.