Commit Graph

13 Commits

Author SHA1 Message Date
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
2026-07-28 12:58:24 -04:00
1175c20a57 Ticketing groups can be retired (e2e dashboard-ticketing#7)
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>
2026-07-26 19:35:04 -04:00
0be06694b2 Read the attachment disk from config, not env (secrets S3)
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>
2026-07-26 14:00:15 -04:00
Joel Wedemire
dd0a458250 feat: ticket views, statuses, participants, merge; mobile layout fixes (#5)
- New migrations: ticket views, statuses, participants, merge support
- New models: TicketView, TicketStatus, TicketParticipant
- New seeder: EmailTemplatesSeeder
- Console commands for ticketing
- Mobile: sidebar min-w-0/overflow-hidden, tab nav overflow-x-auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:22:45 -07:00
Joel Wedemire
1bc34ff16f fix: move /settings route before /{ticket} wildcard; fix null tickets on bootstrap
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>
2026-04-09 21:31:50 -07:00
Joel Wedemire
bce98c0d4b fix(ticketing): restrict settings link to admins and protect global priorities
- 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
2026-04-09 14:32:19 -07:00
Joel Wedemire
24dae5837d Accept attachments on ticket create 2026-04-08 21:58:33 -07:00
Joel Wedemire
f2b614abb7 fix: scope create form priorities to selected group; reset on group change 2026-04-08 19:06:38 -07:00
Joel Wedemire
652829ab90 fix: bootstrap blocker + 4 security bugs
- 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.
2026-04-08 18:31:51 -07:00
Joel Wedemire
615c091f88 Build hybrid ticketing inbox 2026-04-08 17:46:38 -07:00
Joel Wedemire
aa6177cf62 fix: orWhereIn typo; broaden illuminate/support and inertia-laravel version constraints 2026-04-08 17:16:29 -07:00
Joel Wedemire
391699220f feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages 2026-04-08 17:10:30 -07:00
Joel Wedemire
81d0d54f50 feat: initial dashboard-ticketing scaffold 2026-04-08 14:17:26 -07:00