Compare commits

...

79 Commits

Author SHA1 Message Date
Gjermund Høsøien Wiggen
1d4dc38d06 docs: update CLAUDE.md, redesign AGENTS.md, add design-system.md
- CLAUDE.md: updated API endpoints, dev workflow, current stack
- web/AGENTS.md: replaced auto-generated text with Tessera design rules
- docs/design-system.md: new — component patterns, typography scale,
  color tokens, anti-patterns to avoid

Ensures design consistency across all future work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:07:32 +02:00
Gjermund Høsøien Wiggen
f12b24e042 redesign: admin tab inner content — softer borders, cleaner items
- List items: border/50, bg-primary/5 on active, hover:bg-accent/30
- Side panels: border/50 dividers
- Form panels: border/50 on section headers
- Section title bars: bg-muted/20 instead of /35
- Softer list item dividers (border/30)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:05:53 +02:00
Gjermund Høsøien Wiggen
96a71a34fe redesign: admin pages — clean 2026 style, no heavy boxes
- AdminHeader: stripped backdrop-blur, cleaner typography, shorter
- Tabs: softer pill bar, no outer border, rounded-lg background
- Section wrappers: removed shadow-sm, bg-card/82, border→border/50
- ScripFlowNode: softened borders, removed card bg, cleaner label
- All 8 section wrappers updated with replace_all

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:04:02 +02:00
Gjermund Høsøien Wiggen
1c780be710 redesign: sidebar nav — clean 2026 style
- Softer sidebar border (50% opacity)
- Cleaner nav items: subtle accent bg on active, no inset shadow
- Icons dimmed when inactive, brighten on hover
- Section headers: simple text labels, no chevron toggles
- Removed collapsible sections (all always visible)
- Cleaner brand: single-line name, shorter height
- Bottom: removed hardcoded User placeholder, just Admin + Theme
- Softer border dividers throughout

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 23:01:42 +02:00
Gjermund Høsøien Wiggen
dfcdbc623a fix: SetTeam shows team name, TransactionCard receives teams
- Pass teams list to TransactionCard
- Resolve team name from tx.new_value (team_id)
- Shows 'Team -> Support' instead of just 'Team changed'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:58:29 +02:00
Gjermund Høsøien Wiggen
6f1d7bfa9b fix: SetTeam transaction now shows as system event in timeline
- Added SetTeam to isSystem check
- Shows 'Team changed' in the timeline instead of raw 'SetTeam' text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:52:25 +02:00
Gjermund Høsøien Wiggen
c023079a1a chore: remove useless Work mode filler box from ticket sidebar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:51:24 +02:00
Gjermund Høsøien Wiggen
cee263944b redesign: ticket detail sidebar — flat, modern, no boxed sections
- Status selector: colored capsule with ring inset (visual, prominent)
- Assignment: simple label+select, no bordered dl/dt/dd grid
- Details: clean justify-between lines, no box container
- Custom fields: flat spacing, no bordered wrapper
- Removed PropertyRow component (no longer used)
- Removed heavy rounded-md border border-border bg-background/60 boxes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:48:53 +02:00
Gjermund Høsøien Wiggen
8b371ae3c2 redesign: ticket detail — cleaner transaction cards, system events as timeline
- System events now render as subtle timeline entries (icon dot + text)
  instead of heavy bordered boxes
- Message cards are cleaner: rounded avatars, no card borders,
  just typography and spacing. Internal badge is subtler.
- Removed border/shadow from message cards — cleaner, more modern

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:47:00 +02:00
Gjermund Høsøien Wiggen
5308ee8653 redesign: clean ticket list with status dot + hover checkbox
- Colored status dot per row for instant status recognition
- Checkbox appears on hover (group-hover) for batch selection
- No side panel — full width, clean list
- Click row → navigate to ticket detail
- Removed hover status change dots per user feedback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:40:56 +02:00
Gjermund Høsøien Wiggen
667979c4b2 cleanup: remove side panel and broken inline expansion
Stripped back to clean table before redesign. Removed selectedId tracking,
transactions fetching, and all side-panel related code. Row click now
navigates directly to ticket detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:38:54 +02:00
Gjermund Høsøien Wiggen
1f308b4342 redesign: triage panel — compact, visual, actionable
- Colored status dot with glow ring in header (at-a-glance)
- Status change via colored dots (click circles, not text pills)
- Subject + ID + queue + owner in one compact line
- Open full view is now a subtle chevron button, not a giant CTA
- Take it button appears inline when unassigned
- Activity feed as a timeline with connecting line and dots
- Shorter transaction labels with inline values

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:25:17 +02:00
Gjermund Høsøien Wiggen
ed5d96a74b fix: add-filter button uses ref + state for positioning
Store button position in state on click, then pass to the portal
popover via style. Eliminates getElementById race condition where
the portal hadn't rendered yet when trying to set DOM styles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:23:56 +02:00
Gjermund Høsøien Wiggen
dd747946ea fix: wider resize handles, table minWidth instead of width 100%
- table-layout: fixed with minWidth instead of width:100% to avoid
  browser recalculating explicit column widths
- Wider resize handles (w-3, 12px) for easier grabbing
- Better visibility on hover

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:18:49 +02:00
Gjermund Høsøien Wiggen
dde19f5fab fix: table-layout fixed + consistent column widths
- table-layout: fixed on the table wrapper so browser respects explicit widths
- All cells (header + rows) now use the same col.width consistently
- Subject column no longer special-cased for width

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:16:49 +02:00
Gjermund Høsøien Wiggen
5970e3fe9d fix: resize adjusts both adjacent columns (left expands, right shrinks)
Left column gets wider, right column gets narrower by same amount.
Subject column now has fixed width (not flexible) so the table
doesn't redistribute space unpredictably.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:12:44 +02:00
Gjermund Høsøien Wiggen
7f91a51e32 fix: resize handle now adjusts column to its left
Handle on the left edge resizes the PREVIOUS column, not itself.
This matches the mental model: dragging the boundary between Subject
and Status changes Subject's width. First column has no handle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:10:14 +02:00
Gjermund Høsøien Wiggen
30108c7600 fix: move resize handles to left edge of columns
Handles now sit on the left edge (the boundary between columns).
Dragging feels natural — you pull the dividing line between two columns.
Wider hit area (w-2) for easier grabbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:08:36 +02:00
Gjermund Høsøien Wiggen
d7a5b5ba1d fix: use CSS table layout for column alignment
Replaced flex containers with display: table/table-row/table-cell.
This guarantees column widths are shared between header and all rows,
fixing the misalignment. Subject column gets remaining width, all
others use fixed pixel widths. Resize handles on header cells still work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:06:12 +02:00
Gjermund Høsøien Wiggen
b2fb69ffc5 fix: add checkbox spacer to column header for alignment
Column header was missing the w-9 spacer that ticket rows have
for the batch checkbox. Added spacer so columns align properly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:03:40 +02:00
Gjermund Høsøien Wiggen
dd7bd867bf fix: remove duplicate column header, portal column picker, clean widths
- Remove old fixed column header (was showing above dynamic one)
- Column picker now renders via createPortal (no longer behind elements)
- Remove forced inline widths on header and rows (flex naturally)
- Cleaner column header styling (subtle muted, no forced min-width)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:01:48 +02:00
Gjermund Høsøien Wiggen
e486558309 feat: batch ticket operations — multi-select, bulk status, bulk assign
- Checkbox column on every ticket row
- Select multiple tickets via checkboxes
- Floating sticky action bar at bottom when tickets selected:
  - Shows count: "3 selected" with Clear button
  - Quick status change buttons
  - Assign to me button
- Checkbox click stops propagation (doesn't select ticket for triage panel)
- Batch operations run sequentially via API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:58:09 +02:00
Gjermund Høsøien Wiggen
38a82ad0d8 feat: persist columns to localStorage, custom fields as columns
- Column config saves to localStorage on every change
- Load from localStorage on mount (survive reloads without saved view)
- Custom fields appear as column options in picker
- Custom field values render in ticket rows
- Backend now always includes custom_fields in GET /tickets response

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:51:16 +02:00
Gjermund Høsøien Wiggen
7ddf82f93f feat: customizable, resizable columns on ticket list, saved per view
- Column picker dropdown (grid icon next to sort/density)
  - Check/uncheck columns: ID, Subject, Status, Queue, Owner, Created, Updated
  - Subject column auto-expands (flex), others have fixed width
- Column resize handles: drag right edge of any column header
  - Min 50px, max 800px, body gets select-none during drag
- Columns persist with saved views (columns jsonb field)
- Reset to defaults when navigating away from a saved view
- Sticky column header row with muted background

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:57:34 +02:00
Gjermund Høsøien Wiggen
d5d6a209bd fix: All tickets link, redesign side panel into triage panel
- Fix sidebar All tickets link: /?view=all instead of / (avoids dashboard redirect)
- Replace useless side panel with triage command center:
  - Quick status change buttons (click to transition inline)
  - Assign to me button (appears when unassigned)
  - Mini activity feed showing last 8 transactions with type labels,
    status badges, old→new values, and comment previews
  - Relative timestamps
  - Open full view button
- Fetches ticket transactions on selection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:51:51 +02:00
Gjermund Høsøien Wiggen
4e285f8c4d feat: queues have default team, tickets inherit it
- team_id on queues table (optional, can be overridden per-ticket)
- Ticket creation auto-sets team_id from the queue's default
- Queue admin form has team selector (scrip flow node 03)
- Queue API (POST/PATCH) accepts team_id

No enforcement — just a helpful default. Teams and queues
are loosely coupled, not hierarchically locked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:47:20 +02:00
Gjermund Høsøien Wiggen
3d7ba0d6a7 feat: team assignment on tickets + My team's tickets view
Backend:
- team_id column on tickets table
- team_id filter in GET /tickets (resolves team members)
- team_id in UpdateTicketSchema + PATCH handler
- SetTeam transaction type

Frontend:
- Team selector in ticket detail properties sidebar
- My team's tickets in sidebar (when user belongs to a team)
- team_id passed through to API from ticket list page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:40:00 +02:00
Gjermund Høsøien Wiggen
4157a7b0af fix: replace HTML5 DnD with mouse-based drag for smooth widget movement
- Grip handle now uses mousedown/mousemove/mouseup (same as resize)
- Widget position updates in real-time as you drag — no ghost image
- Grid snapping from actual mouse coordinates
- Overlap resolution on mouseup
- Cleaner: no draggable attribute, no dataTransfer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:31:46 +02:00
Gjermund Høsøien Wiggen
6a277f9c36 fix: free-form drag positioning instead of swap-only
Widgets can now be dragged anywhere on the grid, not just swapped.
Drop position is calculated from mouse coordinates relative to the grid,
snapped to grid units. Overlapping widgets are pushed down automatically.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:18:00 +02:00
Gjermund Høsøien Wiggen
a2005d007e fix: resize now uses functional setState to avoid stale closure
The onUp handler was capturing stale widgets from the render closure,
overwriting the resize dimensions. Now uses setWidgets(current => ...)
to read latest state and apply overlap resolution correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:50:03 +02:00
Gjermund Høsøien Wiggen
b3da204bd0 fix: resolve widget overlaps after resize
When resizing a widget, any widget that gets overlapped is automatically
pushed down to clear the collision. Multi-pass overlap detection ensures
cascading widgets are all resolved. Positions persisted to API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:48:07 +02:00
Gjermund Høsøien Wiggen
41fb10120c feat: add drag-to-rearrange and resize handles to dashboard widgets
Edit mode now supports:
- Drag handle (grip icon, top-left) to rearrange widgets via HTML5 DnD
  (drops swap widget positions, persists via API)
- Resize handle (corner icon, bottom-right) with mousedown→mousemove→mouseup
  tracking to change widget width/height in grid units, persists via API
- Cursor feedback: grab cursor on draggable widgets, se-resize on handle
- Visual feedback: dragging widget shows 50% opacity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:41:04 +02:00
Gjermund Høsøien Wiggen
6ca8974eb9 feat: add edit mode toggle to dashboard page
- Edit/Done toggle button in header (pencil icon)
- Widget delete buttons only visible in edit mode
- Add widget button only visible in edit mode
- Empty state prompts to enter edit mode instead of adding directly
- Default is view mode — clean, no accidental deletes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:38:28 +02:00
Gjermund Høsøien Wiggen
9938c7a7ad feat: add team selector to dashboard page header
- Dropdown to assign/unassign dashboard to a team
- Updates immediately via PATCH
- createDashboard and updateDashboard API now accept team_id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:35:40 +02:00
Gjermund Høsøien Wiggen
3616046b78 feat: add teams/groups with dashboard scoping
Schema:
- teams table (name unique, description)
- team_members table (team_id, user_id, unique constraint)
- team_id column on dashboards

API:
- GET/POST/PATCH/DELETE /teams
- POST /teams/:id/members (add user)
- DELETE /teams/:id/members/:userId (remove user)
- dashboards support team_id on create/update

Frontend:
- Teams tab in admin: CRUD + member management with add/remove
- Sidebar: dashboards filtered to user's teams
  (unassigned dashboards visible to all)
- Compact dashboard picker dropdown in sidebar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:32:39 +02:00
Gjermund Høsøien Wiggen
c79cd183d4 refactor: replace dashboard sidebar list with compact dropdown
- Single-line select dropdown instead of one list item per dashboard
- Scales to any number of teams without clutter
- "+ New dashboard" as last option in dropdown
- Preserves the create flow with inline name input

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:24:33 +02:00
Gjermund Høsøien Wiggen
35b7f49518 fix: add-filter popover renders via portal to avoid stacking context
- Popover now renders via createPortal into document.body with z-index 9999
- This avoids the header backdrop-blur stacking context trapping it
- Add + button in Dashboards sidebar section to create dashboards
- Inline input on Dashboards section header, Enter to create/Escape to cancel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:19:22 +02:00
Gjermund Høsøien Wiggen
f7e34f1690 feat: dashboard auto-refresh, collapsible sidebar, error retry
- Dashboard: auto-refresh toggle (30s interval, spins when live)
- Dashboard: responsive grid (6 cols mobile, 12 cols desktop)
- Sidebar: Dashboards, Saved views, Queues sections now collapsible
  with chevron toggle
- Error banner: added Retry button next to error message

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:14:47 +02:00
Gjermund Høsøien Wiggen
6263ce1332 feat: seed dashboard, fix My tickets filter
- Add demo dashboard with 7 widgets to seed script
- Dashboard is_default=true — appears as home page on fresh seed
- Add views/dashboards/dashboardWidgets to seed reset
- Fix My tickets: now filters by first non-system user (not any owner)
- Pass owner param in sidebar My tickets link
- Update page.tsx view=my to respect owner URL param
- Scrip engine already sorts by sort_order (verified)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:09:02 +02:00
Gjermund Høsøien Wiggen
c6c5272e50 feat: SQL filtering, Users admin tab, dashboard polish
- Move ticket filtering from in-memory to SQL WHERE clauses
  (queue_id, status, owner use Drizzle eq/isNull; text search uses ilike;
  custom field filters use EXISTS subqueries)
- Add limit param to GET /tickets
- Add POST/PATCH/DELETE /users routes
- Add Users tab to admin page with create/edit/delete
- Smart widget positioning in dashboard (3-column grid fill)
- Show pattern hint below CF inputs in New Ticket dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:04:10 +02:00
Gjermund Høsøien Wiggen
affbbdaa46 feat: add template delete — backend DELETE route, frontend trash button
- DELETE /templates/:id — backend route
- deleteTemplate() API client function
- Trash icon on each template list item (shows on hover)
- Confirms inline, no dialog needed
- Resets builder if the deleted template was being edited

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:49:45 +02:00
Gjermund Høsøien Wiggen
7be90684fb fix: replace broken add-filter button with stepped filter builder
- Fixed popover z-index: uses fixed positioning with z-50 above backdrop
- Stepped flow: select field → set operator (is/is_not) → choose/write value → Apply
- Removed old inline CF value inputs (handled inline in the new flow)
- Fixed filter persistence: clear filters when navigating away from saved view
- Fixed home redirect: check for default dashboard on load

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:30:17 +02:00
Gjermund Høsøien Wiggen
b70a133ea2 feat: add dashboards — tables, CRUD API, widget data endpoint
- New dashboards table (name, description, layout, is_default)
- New dashboard_widgets table (view_id, title, widget_type, position, config)
- GET/POST/PATCH/DELETE /dashboards
- GET/POST/PATCH/DELETE /dashboards/:id/widgets
- GET /dashboards/:id/widgets/:id/data — runs saved view filters,
  returns pre-aggregated data for count/ticket_list/status_chart/grouped_counts
- is_default uniqueness enforced on PATCH

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:26:22 +02:00
Gjermund Høsøien Wiggen
aa90b88991 feat: add saved views — database table, CRUD API, migration
- New views table (id, name, filters jsonb, sort_key, is_public, creator_id)
- GET/POST/PATCH/DELETE /views endpoints
- Register views router in server

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:10:25 +02:00
Gjermund Høsøien Wiggen
000e97e1bd feat: implement inline editing for custom fields in ticket sidebar
- Show CF values as read-only text with edit affordance (pencil icon on hover)
- Click to enter edit mode: inline input (free-text) or select (choice fields)
- Save on blur or Enter, cancel on Escape — reverts to original value
- Auto-save for select fields on change
- Loading spinner while saving
- Remove now-unused customFieldValue helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:55:45 +02:00
Gjermund Høsøien Wiggen
2501bcbad1 chore: add .codegraph to .gitignore, untrack daemon files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:51:43 +02:00
Gjermund Høsøien Wiggen
aa808f1d3f feat: return scrip results on ticket create, update frontend types
- POST /tickets now returns { ticket, scrip_results } matching PATCH pattern
- createTicket API function returns UpdateResult instead of Ticket
- Update call site to use data.ticket.id

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:51:14 +02:00
Gjermund Høsøien Wiggen
60d2196e51 chore: exclude web and node_modules from tsconfig
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:52 +02:00
Gjermund Høsøien Wiggen
ade966ace7 docs: update CLAUDE.md with current project state and workflow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:34 +02:00
Gjermund Høsøien Wiggen
06cc7c79a3 feat: enhance frontend UI — command palette, admin redesign, API coverage
Types + API:
- Add User, TemplatePreview, QueueCustomField types
- Add getUsers, getTemplates, createTemplate, updateTemplate,
  previewTemplate, updateQueue, updateLifecycle, updateCustomField API functions

UI:
- Command palette: keyboard-first navigation with fuzzy ticket search
- Admin: comprehensive redesign with tab-based layout (Queues, Lifecycles,
  Scrips, Custom Fields, Templates, Users)
- Ticket list: improved inbox-style rows with quick actions
- Ticket detail: enhanced conversation thread and properties sidebar
- App shell: sidebar visual refinement with active indicator bar
- Theme toggle: smoother transitions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:28 +02:00
Gjermund Høsøien Wiggen
b96ba21e99 feat: add database seed script and utility scripts
- src/db/seed.ts: comprehensive seed data with idempotent upserts
  - 5 users (system, gjermund, operator, technician, analyst)
  - 5 queues (Support Desk, Operations, IT Infrastructure, Facilities, Field Ops)
  - 1 lifecycle (Demo service lifecycle with new→open→in_progress→resolved→closed)
  - 5 custom fields (impact, location, channel, urgency, outcome) with short keys
  - 10 realistic support tickets with varied statuses, custom fields, and history
  - 3 scrips (OnCreate email, OnResolve custom field, customer notification)
  - 2 templates (auto-response, resolve notification)
  - --reset flag to truncate all data before seeding
- scripts/smoke-test.ts: API smoke tests
- scripts/watch-frontend.sh: frontend dev helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:18 +02:00
Gjermund Høsøien Wiggen
54ef6fcc5b feat: add users and templates routes, enhance existing API routes
New routes:
- GET /users — list all users
- GET/POST /templates — list and create templates
- PATCH /templates/:id — update template
- POST /templates/preview — render template with ticket/demo context

Enhanced routes:
- tickets: custom field support on create, status classification helper
- custom-fields: PATCH endpoint, auto-generate short key from name
- lifecycles: PATCH endpoint
- queues: PATCH endpoint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:08 +02:00
Gjermund Høsøien Wiggen
e960df61ad feat: implement full scrip action engine with real executors
- SendEmail: real nodemailer transport with SMTP config, dynamic recipient resolution
  (static + ticket creator/owner lookup), Handlebars template support
- Webhook: HTTP POST/any method with configurable headers and JSON body
- FetchMetadata: external HTTP fetch, Handlebars URL/body templating,
  auto-adds result as comment/correspondence on ticket
- RunScript: arbitrary async JS execution with helpers (addComment,
  createTransaction, updateTicket, touchTicket), ticket context, and
  Drizzle ORM access
- SetCustomField: lookup by id/key/name, clear+insert value, record
  CustomFieldChange transaction
- CreateTransaction: insert arbitrary transaction record
- Add OnCustomFieldChange condition
- Pass condition_config to evaluator in engine

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:42:59 +02:00
Gjermund Høsøien Wiggen
9e884546f2 feat: add infrastructure foundation — scripts, schema key, new routes, model fields
- Add npm scripts for dev, migrate, seed, smoke
- Add key column to scrips table (unique short identifier)
- Register users and templates routes in server
- Set development: false in Bun.serve for production mode
- Add description and custom_fields to CreateTicketSchema
- Make owner_id nullable/optional for unassigned tickets
- Add migration for custom field key column

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:42:42 +02:00
Gjermund Høsøien Wiggen
599ca75fc4 Add RT architecture deep-dive analysis reference 2026-06-07 23:32:04 +02:00
Gjermund Høsøien Wiggen
087b8cdce7 Replace auto-generated CLAUDE.md with proper project documentation
- Architecture overview (backend + frontend)
- Stack details (Bun/Hono/Drizzle + Next.js/shadcn)
- How to run locally (backend, frontend, migrations)
- API endpoint reference
- Key design decisions (sequential IDs, transaction-centric, scrip engine)
- Git workflow and common issues
2026-06-07 23:31:28 +02:00
Gjermund Høsøien Wiggen
08b52426b0 Implement ticket reply functionality
Backend:
- POST /:id/comment endpoint accepting {body, internal?, creator_id?}
- internal=false → Correspond (public reply), internal=true → Comment
- Runs scrip engine on the new transaction so notifications fire
- CommentSchema zod validation

Frontend:
- sendComment() API function in lib/api.ts
- Send button wired with onClick, sending spinner, disabled state
- Error display below reply box, clears on new typing
- Refreshes transaction list after successful send
- Reply/Internal note mode passed as internal flag
2026-06-07 23:28:46 +02:00
Gjermund Høsøien Wiggen
04b4e28d21 Change ticket IDs from UUID to sequential integers
Backend:
- tickets.id: uuid → integer GENERATED ALWAYS AS IDENTITY
- transactions.ticket_id, custom_field_values.ticket_id: uuid → integer
- Routes convert string params to Number() for DB queries
- ScripEngine.prepare takes ticketId: number
- ActionPayload.ticketId: string → number

Frontend:
- Ticket.id: string → number, Transaction.ticket_id: string → number
- API functions accept number params
- formatTicketId() helper returns TKT-0001 format
- Ticket rows display TKT-XXXX, detail page uses formatTicketId

Migration: drops FKs, clears data, alters column types, re-adds FKs
2026-06-07 23:23:05 +02:00
Gjermund Høsøien Wiggen
7da52dfff6 Revert to single-column ticket list with inbox-style rows
- Remove three-column layout, inline detail panel, and properties sidebar
- Click a ticket navigates to /tickets/[id] via router.push
- Redesign TicketRow as inbox-style: status dot, bold subject on top line,
  muted ID/queue/owner meta on second line, time right-aligned
- Cleaner visual hierarchy with increased padding and gap
2026-06-07 23:14:59 +02:00
Gjermund Høsøien Wiggen
86e00b076a Add properties sidebar to inline ticket detail panel
- Two-column layout inside TicketDetailPanel: conversation (left) + sidebar (right)
- Status section: Select dropdown with all statuses, previewTicket + updateTicket flow with Apply/Cancel
- Assignment section: read-only assignee display with avatar initial
- Details section: queue name, created/updated/resolved dates
- Custom fields section: name:value pairs when present
- Sidebar skeleton during loading
- Fetches queue info alongside ticket data for display
2026-06-07 23:13:00 +02:00
Gjermund Høsøien Wiggen
88ab30a7fd Fix transaction_type case mismatch in both ticket pages
Backend returns PascalCase (Create, StatusChange, SetOwner, Comment, Correspond).
Frontend was checking lowercase, causing transaction rendering to fall through to raw type strings.
2026-06-07 23:06:25 +02:00
Gjermund Høsøien Wiggen
737e8942f6 Redesign ticket list to three-column layout with proportional widths
- Replace Sheet slide-over with inline peer detail column
- List column: 40% width (min 360px) when ticket selected, flex-1 otherwise
- Detail column: 60% width (min 480px), slides in from right (300ms)
- Mobile: list hidden when ticket selected, detail becomes full-width
- Subtle border-r divider on list column
- Taller ticket rows (py-4) with smooth hover transitions
- width transition on list column resize
2026-06-07 23:00:45 +02:00
Gjermund Høsøien Wiggen
10962f795f feat: three-column ticket list layout (list + detail as peers, no Sheet)
- Replace Sheet slide-over with persistent right-column detail panel
- Ticket list shrinks to w-80 when ticket selected, detail takes flex-1
- Animated transition (300ms ease-out) when selecting/deselecting
- Kept existing conversation thread, properties sidebar, reply box inline
2026-06-07 22:58:50 +02:00
Gjermund Høsøien Wiggen
784d30acbd fix: wrap TicketListPage in Suspense boundary for useSearchParams 2026-06-07 22:46:34 +02:00
Gjermund Høsøien Wiggen
6f2b0f39f7 feat: breadcrumb nav, grouped properties sidebar, larger status selector, transitions 2026-06-07 22:34:31 +02:00
Gjermund Høsøien Wiggen
8175b05b23 feat: fuzzy ticket search in command palette, improved styling 2026-06-07 22:34:28 +02:00
Gjermund Høsøien Wiggen
b2423f2821 feat: inbox-style ticket rows, Sheet detail slide-over, gradient New Ticket button 2026-06-07 22:34:27 +02:00
Gjermund Høsøien Wiggen
b05eb8b2d4 feat: add sidebar collapse/expand, theme-toggle, theme-aware colors 2026-06-07 22:34:26 +02:00
Gjermund Høsøien Wiggen
10005799fb feat: add theme-toggle component with next-themes sun/moon icons 2026-06-07 22:34:26 +02:00
Gjermund Høsøien Wiggen
87bd6997e3 Add light mode support (next-themes), JetBrains Mono font, OpenType features
- layout.tsx: ThemeProvider from next-themes, light mode DEFAULT, JetBrains_Mono font
- globals.css: font-mono pointing to correct variable, font-feature-settings cv01+ss03 on body
- next-themes package installed
- Build passes with zero errors
2026-06-07 22:29:52 +02:00
Gjermund Høsøien Wiggen
77860eb6c4 Redesign: Linear-inspired dark mode frontend
Complete rewrite of all pages:
- layout.tsx: App shell with 240px sidebar (saved views, queue list, admin link)
- app-shell.tsx: Client sidebar component with route highlighting + counts
- page.tsx: Sleek ticket list with filter chips (All/Open/In progress/Resolved), search bar, status dots, assignee avatars, skeleton loading
- tickets/[id]/page.tsx: Two-panel conversation layout — message thread (left) + properties sidebar (right) with status change, scrip preview, reply box
- admin/page.tsx: Suspense-wrapped admin with tabs in sheet panels
- command-palette.tsx: Cmd+K search with keyboard navigation

Design tokens from Linear:
- bg-[#08090a] canvas, bg-[#0f1011] panels, bg-[#191a1b] cards
- text-[#f7f8f8] primary, text-[#d0d6e0] secondary, text-[#8a8f98] tertiary
- borders: rgba(255,255,255,0.08) standard, rgba(255,255,255,0.05) subtle
- accent: #5e6ad2 primary, #7170ff interactive
- status colors: new=gray, open=indigo, in_progress=amber, resolved=green
- Inter font, weights 400/510/590, no pure white

Fixed: Suspense boundaries for useSearchParams in layout and admin pages
Build: passes with zero errors
2026-06-07 22:16:18 +02:00
Gjermund Høsøien Wiggen
df677cb37f Add web redesign spec: Linear-inspired UX, conversation-centric, two-panel layout 2026-06-07 22:07:03 +02:00
Gjermund Høsøien Wiggen
49834f5215 fix: API proxy — strip /api prefix in rewrite (backend routes don't use /api) 2026-06-07 22:05:30 +02:00
Gjermund Høsøien Wiggen
73cf283f06 Add admin page with 4 tabs for managing queues, lifecycles, scrips, and custom fields 2026-06-07 22:02:11 +02:00
Gjermund Høsøien Wiggen
1029176873 Add ticket detail page with transaction timeline and status change 2026-06-07 22:02:08 +02:00
Gjermund Høsøien Wiggen
a49e888011 Add ticket list page with filters, status badges, create dialog 2026-06-07 22:02:06 +02:00
Gjermund Høsøien Wiggen
f69678db4b Add dark theme root layout with Inter font and nav bar 2026-06-07 22:02:03 +02:00
Gjermund Høsøien Wiggen
00dd21f4dd Add typed fetch API client with 15 endpoint functions 2026-06-07 22:02:01 +02:00
Gjermund Høsøien Wiggen
59d66b3392 Add TypeScript interfaces for Tessera domain types 2026-06-07 22:01:58 +02:00
64 changed files with 18038 additions and 295 deletions

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ bun.lock
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
# Codegraph index (MCP tool)
.codegraph

180
CLAUDE.md
View File

@@ -1,106 +1,110 @@
# Tessera
Default to using Bun instead of Node.js. Open-source ticketing system — Request Tracker's paradigm rebuilt in modern TypeScript.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` ## Architecture
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs ```
tessera/
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. ├── src/ # Backend: Bun + Hono + Drizzle ORM
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. │ ├── index.ts # Hono server entry (port 9876)
- `Bun.redis` for Redis. Don't use `ioredis`. │ ├── config.ts # Zod-validated env config
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. │ ├── db/ # Drizzle ORM schema + migrations
- `WebSocket` is built-in. Don't use `ws`. │ ├── routes/ # REST API endpoints
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile │ ├── models/ # TypeScript types + Zod schemas
- Bun.$`ls` instead of execa. │ ├── scrip/ # Scrip engine (prepare/commit two-phase)
│ └── lifecycle/ # State machine validator
## Testing ├── web/ # Frontend: Next.js 16 + shadcn/ui
│ ├── src/app/ # App Router pages
Use `bun test` to run tests. │ ├── src/components/ # Reusable components + widgets
│ └── src/lib/ # API client + types + utils
```ts#index.test.ts ├── drizzle/ # SQL migration files
import { test, expect } from "bun:test"; └── docs/ # Architecture + design specs
test("hello world", () => {
expect(1).toBe(1);
});
``` ```
## Frontend ## Stack
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. **Backend:** Bun runtime, Hono web framework, Drizzle ORM, PostgreSQL 17, Zod validation, Handlebars templates, nodemailer
Server: **Frontend:** Next.js 16 App Router (Turbopack), shadcn/ui (Tailwind CSS), next-themes, date-fns, lucide-react icons
```ts#index.ts **Fonts:** Inter (variable), JetBrains Mono
import index from "./index.html"
Bun.serve({ ## Running Locally
routes: {
"/": index, ### Prerequisites
"/api/users/:id": { - Bun (`nix-shell -p bun` or install globally)
GET: (req) => { - Node.js 22+ (`nix-shell -p nodejs_22`)
return new Response(JSON.stringify({ id: req.params.id })); - Docker (for PostgreSQL)
}, - PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=*** -e POSTGRES_DB=tessera -p 127.0.0.1:5433:5432 postgres:17-alpine`
},
}, ### Start backend
// optional websocket support ```bash
websocket: { cd ~/projects/tessera
open: (ws) => { cp .env.example .env
ws.send("Hello, world!"); npm run dev:backend # Starts API on port 9876
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
``` ```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. ### Run migrations
```bash
```html#index.html npm run db:migrate
<html> npm run db:seed # Demo data
<body> npm run db:seed:reset # Reset + re-seed
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
``` ```
With the following `frontend.tsx`: ### Start frontend
```bash
```tsx#frontend.tsx cd web
import React from "react"; npm install # Use npm, NOT bun
import { createRoot } from "react-dom/client"; bun run dev # Dev server on 127.0.0.1:3100 (HMR)
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
``` ```
Then, run index.ts ## API Endpoints
```sh All endpoints on port 9876. Frontend proxies `/api/*` via `next.config.ts`.
bun --hot ./index.ts
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| GET | /tickets | List tickets (?queue_id=&status=&owner_id=&team_id=&q=&limit=&cf.*=) |
| POST | /tickets | Create ticket |
| GET | /tickets/:id | Get ticket with custom fields |
| PATCH | /tickets/:id | Update ticket (validates lifecycle, runs scrips, returns scrip_results) |
| POST | /tickets/:id/preview | Dry-run scrips for status change |
| POST | /tickets/:id/comment | Add comment to ticket |
| GET | /tickets/:id/transactions | List ticket transactions |
| GET/POST/PATCH | /queues | CRUD queues |
| GET/POST/PATCH/DELETE | /scrips | CRUD scrips |
| GET/POST/PATCH | /custom-fields | CRUD custom fields |
| GET/POST/PATCH | /lifecycles | CRUD lifecycles |
| GET/POST/PATCH/DELETE | /users | CRUD users |
| GET/POST/PATCH/DELETE | /templates | CRUD templates + POST /preview |
| GET/POST/PATCH/DELETE | /views | CRUD saved views |
| GET/POST/PATCH/DELETE | /teams | CRUD teams + POST/DELETE members |
| GET/POST/PATCH/DELETE | /dashboards | CRUD dashboards + widgets + widget data |
## Key Design Decisions
- **Ticket IDs are sequential integers** (1, 2, 3...), formatted as `TKT-0001` for display. No UUIDs.
- **Transaction-centric:** Every state change creates a transaction record. The scrip engine runs on transactions.
- **Two-phase scrip engine:** Prepare (no side effects) then Commit (execute actions). Supports dry-run mode.
- **Lifecycle state machines:** Per-queue configurable status transitions with wildcard support.
- **SQL-level filtering:** Ticket filters (status, queue, owner, team, custom fields) pushed to PostgreSQL via Drizzle WHERE clauses.
- **No ORM for frontend:** Drizzle is only on the backend. Frontend uses a typed fetch wrapper (`web/src/lib/api.ts`).
- **Dev server over production:** Use `bun run dev` (port 3100) with HMR. Build+restart only when dev server has issues.
- **Design consistency:** See `docs/design-system.md` for the design rules applied across the app.
## Git Workflow
Repo: `https://git.gjermund.xyz/gjermund/tessera`
```bash
git remote set-url origin https://gjermund:TOKEN@git.gjermund.xyz/gjermund/tessera.git
``` ```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. ## Common Issues
- **Frontend shows skeleton/blank page:** Dev server may have HMR issues. Kill port 3100, rebuild with `npm run build`, restart with `npm run start`.
- **Backend not running on 9876:** Restart with `bun run src/index.ts`. Check port with `ss -tlnp | grep 9876`.
- **Database connection refused:** Docker container may be stopped. `docker start tessera-db`.
- **Build errors after migration:** Run `bun run src/db/migrate.ts` to apply new migrations.

118
docs/design-system.md Normal file
View File

@@ -0,0 +1,118 @@
# Tessera Design System
The app follows a clean, minimal aesthetic — no heavy borders, no unnecessary shadows, flat hierarchy.
## Principles
1. **Flat, not boxed.** Avoid bordered containers around every section. Use whitespace for separation.
2. **Soft borders.** Never full-opacity borders. Always `/50` or `/30`.
3. **No shadows.** Cards and sections don't need shadows. The sidebar has the only subtle shadow.
4. **Consistent typography.** Three font sizes: labels (10px), content (12-13px), headings (14-24px).
5. **Dimmed by default.** Icons, borders, and secondary text start dimmed. They brighten on hover/focus.
## Component Patterns
### Section Wrapper
```tsx
<section className="overflow-hidden rounded-lg border border-border/50">
...
</section>
```
### Section Header / Title Bar
```tsx
<div className="flex items-center justify-between border-b border-border/50 bg-muted/20 px-4 py-3">
<h2 className="text-sm font-semibold">Title</h2>
<Button size="sm">Action</Button>
</div>
```
### List Item (clickable)
```tsx
<button className={cn(
"rounded-lg border p-3 text-left transition",
"border-border/50 hover:border-primary/30 hover:bg-accent/30",
isActive && "border-primary/50 bg-primary/5"
)}>
<div className="text-sm font-semibold">Name</div>
<div className="text-xs text-muted-foreground">Description</div>
</button>
```
### Property Row (key-value)
```tsx
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">Label</span>
<span className="text-foreground">Value</span>
</div>
```
### Sidebar Nav Item
```tsx
<Link href={href} className={cn(
"flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
active
? "bg-sidebar-accent text-sidebar-foreground font-medium"
: "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50"
)}>
<Icon className={cn("w-4 h-4", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
<span className="truncate">{label}</span>
</Link>
```
### Section Label (small caps)
```tsx
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
LABEL
</div>
```
### Form Input
```tsx
<input className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring" />
```
### Form Select
```tsx
<select className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring">
<option>...</option>
</select>
```
## Typography Scale
| Size | Weight | Use |
|------|--------|-----|
| `text-[10px]` | `font-medium` | Section labels, form labels |
| `text-[11px]` | `font-medium` | Meta, timestamps, badges |
| `text-[13px]` | `font-normal` | Nav items, list items |
| `text-sm` | `font-normal` | Body, property values |
| `text-sm` | `font-semibold` | Card titles, list item names |
| `text-base` | `font-semibold` | Section headings |
| `text-lg` | `font-semibold` | Form titles |
| `text-xl` | `font-semibold` | Page headings |
## Color Tokens
| Token | Use |
|-------|-----|
| `border-border/50` | Section borders, card borders |
| `border-border/30` | Subtle dividers, list separators |
| `border-border/50` | Sidebar border |
| `bg-muted/20` | Section header background |
| `bg-muted/40` | Tab bar background |
| `bg-sidebar-accent` | Active nav item |
| `text-sidebar-foreground/55` | Inactive nav item |
| `text-muted-foreground/60` | Section labels |
| `text-foreground/90` | Slightly muted body text |
## What to Avoid
-`rounded-md` — use `rounded-lg` for cards, `rounded-md` for small elements
-`shadow-sm` or `shadow-md` on any card or section
-`bg-card/82` — use solid `bg-card` or `bg-transparent`
-`border-border` (full opacity) — always `/50` or `/30`
-`backdrop-blur` on headers
-`<dl>`/`<dt>`/`<dd>` grids for property lists — use flex rows
-`text-[11px] font-semibold uppercase` — use the section label pattern
- ❌ Heavy icon usage in section headers — keep icons small (`h-3 w-3`)

View File

@@ -0,0 +1,207 @@
# Request Tracker Architecture: Deep-Dive Technical Analysis
## Source: RT 5.0.5 codebase analysis + official docs (docs.bestpractical.com)
## Purpose: Reference for reimplementing RT's paradigm in Rust
---
## 1. SCRIPS — Event-Driven Automation Engine
### Conceptual Model
A Scrip is a rule: "When X happens, if condition Y is met, execute action Z using template W."
Four primitives compose a scrip:
- **Condition** (RT::ScripCondition): Has `ApplicableTransTypes` — comma-separated string of transaction types it matches (e.g., "Create,Correspond") or "Any". Modules like OnCreate, OnStatusChange, UserDefined.
- **Action** (RT::ScripAction): Maps via `ExecModule` to a Perl module under `RT::Action::*` (SendEmail, AutoReply, CreateTickets, etc.). Has Argument field for parameterization.
- **Template** (RT::Template): Global or queue-specific. Types: "Perl" (Text::Template) or "Simple" (variable substitution).
- **Stage**: `TransactionCreate` (fires per-transaction) or `TransactionBatch` (fires once after batched updates).
### Data Model
```
Scrips: id, Queue(0=global), Template(name), ScripCondition(id), ScripAction(id),
Description, Disabled, CustomPrepareCode, CustomCommitCode, CustomIsApplicableCode
ObjectScrips: id, Scrip(FK), Stage, ObjectId(0=global or Queue id), SortOrder
```
Key insight: The same Scrip record can be applied to multiple queues via ObjectScrips, each with different Stage and SortOrder.
### Dispatch Model — Prepare/Commit Two-Phase
**Prepare phase** (`RT::Scrips::Prepare`):
1. Load Ticket + Transaction as SystemUser (bypass ACL)
2. Find matching Scrips: global + queue-specific, filtered by Stage, matched by Condition.ApplicableTransTypes using SQL LIKE
3. Sort by SortOrder
4. For each: `IsApplicable` (no-side-effects check), then `Prepare` (builds message, determines recipients — no send), push to prepared_scrips
**Commit phase** (`RT::Scrips::Commit`): Iterate prepared_scrips in order, call `Commit` (actual side effects)
**Dry-run mode**: On Ticket Update page load, ALL scrips run in dry-run (Prepare only, no Commit) to populate the "Scrips and Recipients" preview.
**Batch mode**: With `$UseTransactionBatch`, multiple updates accumulate in `_TransactionBatch`. `RanTransactionBatch` flag prevents infinite loops. TransactionBatch-stage scrips see ALL batched transactions.
### Invariants & Edge Cases
- Prepare failures silently skip the scrip; Commit failures are NOT retried
- Template scoping: queue-specific template overrides global for the same name
- Global + queue-specific scrips both apply; their union runs sorted together
- SystemUser escalation is essential — automation must not be gated by the triggering user's ACL
- `CustomIsApplicableCode`/`CustomPrepareCode`/`CustomCommitCode` allow code injection at three points, guarded by `ExecuteCode` right
- The `Disabled` boolean on Scrips is separate from the deprecated `Disabled` stage
---
## 2. TRANSACTION QUERY BUILDER
### Conceptual Model
Five-layer pipeline: UI Tree → QueryBuilder::Tree (AST) → RT::SQL parser → RT::Tickets dispatch → DBIx::SearchBuilder SQL generation.
### Transaction Model
**Everything is a Transaction**: Status changes, owner changes, comments, correspondence, CF updates, link changes, time worked — all create Transaction records.
Schema: `id, ObjectType, ObjectId, Type, Field, OldValue, NewValue, Data, Creator, Created`
Each transaction can have MIME attachments.
### Dispatch Table Architecture
`RT::Tickets::%FIELD_METADATA` maps searchable fields to type handlers:
```
Status => [STRING], Queue => [QUEUE], Owner => [WATCHERFIELD => 'Owner'],
Requestor => [WATCHERFIELD => 'Requestor'], LinkedTo => [LINK => 'To'],
DependsOn => [LINK => To => 'DependsOn'], CF => [CUSTOMFIELD => 'Ticket'],
TxnCF => [CUSTOMFIELD => 'Transaction'], Content => [TRANSCONTENT],
Lifecycle => [LIFECYCLE], Created => [DATE => 'Created']
```
Each type dispatches to a handler (STRING → `_StringLimit`, WATCHERFIELD → `_WatcherLimit`, CUSTOMFIELD → `_CustomFieldLimit`, etc.) that generates appropriate SQL JOINs.
### TicketSQL Parser
State-machine parser with states: KEYWORD, OP, VALUE, AGGREGATOR, OPEN_PAREN, CLOSE_PAREN.
Supports:
- Cross-field references (`Due < CF.{TargetDate}`)
- IS/IS NOT NULL
- LIKE/STARTSWITH/ENDSWITH
- SHALLOW modifier
### Key Optimizations
- **OR→IN conversion**: `(Status='new' OR Status='open' OR Status='stalled')``Status IN ('new','open','stalled')`
- **EntryAggregator intelligence**: String fields default to OR for =, AND for !=; dates default to OR for equality, AND for ranges
- **Join deduplication**: `_sql_aliases` hash tracks which JOINs exist, preventing duplicates
- **Post-filtering**: Deleted tickets and ACL checks applied after SQL, not in SQL
### Edge Cases
- CF name ambiguity (same name, global vs queue-scoped) falls back to name-based query
- IPAddressRange queries decompose = into two range comparisons
- DateTime day-equality decomposes = into >= midnight AND < next midnight
- NULL handling on CFs adds extra EXISTS check on CFs.Name
- `UseSQLForACLChecks` injects ACL JOINs at SQL level
---
## 3. CUSTOM FIELDS
### Attachment Model
Two join tables:
**ObjectCustomFields**: Maps CF to container (Queue id, or 0 for global). Controls *which* CFs appear.
**ObjectCustomFieldValues**: Stores actual values on records. `CustomField(FK), ObjectType, ObjectId, Content, LargeContent, Disabled`.
LookupTypes encode the relationship: `RT::Queue-RT::Ticket` (ticket CFs), `RT::Queue-RT::Ticket-RT::Transaction` (txn CFs), `RT::User`, `RT::Queue`, `RT::Group`.
### Typing System
13 types: Select, Freeform, Text, HTML, Wikitext, Image, Binary, Combobox, Autocomplete, Date, DateTime, IPAddress, IPAddressRange.
Each has: sort_order, selection_type flag, canonicalization flag, render types, labels.
**Values system**: MaxValues (0=unlimited, 1=single, N=capped), UniqueValues, ValuesClass (external sources), CanonicalizeClass (normalization), Pattern (validation regex), BasedOn (cascading), LinkValueTo (object linking).
### Permissions
Five distinct rights: SeeCustomField, AdminCustomField, AdminCustomFieldValues, ModifyCustomField, SetInitialCustomField.
The last creates a "set-once-at-creation" pattern. Rights are contextual (per-queue for tickets).
### Queryability
Notation: `CF.{Name}`, `TxnCF.{Name}`, `QueueCF.{Name}`, `QueueID.CF.{Name}.Content`, cross-field comparison.
Compilation: `_CustomFieldDecipher` resolves name/id to CF object → `_CustomFieldJoin` performs LEFT JOINs → `_LimitCustomField` applies operator with type-aware value parsing.
Sorting: `ORDER BY CF.{Name}` joins ObjectCustomFieldValues and, for selection types, CustomFieldValues to sort by SortOrder.
### Edge Cases
- Context-aware permissions (user sees CF on Queue A but not Queue B)
- CF name collision detection and fallback
- External values sources never stored locally
- Content vs LargeContent transparent handling
- SingleValue optimization (DISTINCT joins when MaxValues=1)
---
## 4. LIFECYCLES
### Conceptual Model
A Lifecycle defines: valid statuses (initial/active/inactive), allowed transitions (directed graph), transition-gating rights, UI actions, defaults, and cross-lifecycle move maps.
Stored on Queue (not ticket); tickets inherit through queue.
### State Machine Semantics
**Date invariants**:
- Moving FROM initial TO active/inactive → set Started
- Moving FROM initial/active TO inactive → set Resolved
- Moving BACK from inactive → clear Resolved (critical!)
- Moving FROM initial directly TO inactive → sets BOTH Started AND Resolved
**Transition validation** (`ValidateStatusChange`):
1. Is new status valid in lifecycle?
2. Is transition allowed? (`IsTransition`)
3. Does user have the required right? (`CheckRight` with 4-level priority: exact→wildcard-from→wildcard-to→full-wildcard→fallback)
**CheckRight priority**: `'new→open'` > `'*→open'` > `'new→*'` > `'*→*'` > fallback (DeleteTicket for deleted, ModifyTicket otherwise).
Lifecycle rights REPLACE ModifyTicket, not supplement it.
### Queue Changes
Move maps are directional (`'default→orders'` vs `'orders→default'`). Maps MUST be complete — any missing status causes a hard error.
On queue change, if owner lacks OwnTicket on new queue, ticket is auto-reassigned to Nobody (safety invariant).
### Rights Model
Custom named rights are auto-created: `'* → rejected' => 'RejectTicket'` creates a RejectTicket right.
No explicit guards for behavioral hooks — handled entirely through Scrips (OnStatusChange, OnResolve conditions).
### Edge Cases
- Action deduplication: specific `'from→to'` overrides wildcard `'*→to'`
- Lifecycle caching; no hot-reload without restart
- Disabled lifecycles: existing queues keep working, can't be selected for new queues
- Default statuses cover specific named contexts: on_create, approved, denied, reminder_on_open, reminder_on_resolve
- Batched status changes: TransactionBatch scrips see all transactions atomically
---
## Cross-Cutting Architectural Patterns
1. **Transaction-centric**: Everything produces a transaction → unified audit trail, unified query interface, single Scrip event stream
2. **Container-scoped config**: CFs, Scrips, Templates all follow global→queue override pattern
3. **Prepare/Commit**: Separates planning from execution (essential for dry-run, safety)
4. **Dispatch tables**: Field types → handlers, ApplicableTransTypes → conditions — config-driven, not code-driven extensibility
5. **SystemUser identity**: Automation runs elevated; reimplementation needs explicit service account concept
6. **Aggressive caching**: Lifecycle configs, CF join aliases, ACL roles, group memberships — essential at scale

View File

@@ -0,0 +1,63 @@
CRITICAL REDESIGN REQUIRED. The current Tessera frontend is dark-mode-only and not user-friendly. Implement these changes:
## 1. LIGHT MODE AS DEFAULT
Rewrite the theme system. shadcn/ui supports light/dark via CSS variables.
- Remove forced `className="dark"` from HTML element in layout.tsx
- Add ThemeProvider (install next-themes: bun add next-themes)
- Light mode is DEFAULT. Dark mode via toggle.
- Use shadcn/ui's built-in CSS variable system (already in globals.css)
Light mode colors:
- Background: white for canvas, #f8f9fa for panels/sidebar
- Text: #1a1a2e primary, #4a4a6a secondary, #6b7280 tertiary
- Borders: #e5e7eb, Cards: white with shadow
- Accent: #5e6ad2 (works in both modes)
Dark mode (toggled): keep existing Linear-inspired dark theme
## 2. BETTER FONT
- Primary: 'Inter' from next/font/google with font-feature-settings: 'cv01' 1, 'ss03' 1 (geometric Linear-like character)
- Monospace: 'JetBrains Mono' for ticket IDs, code, config
- Headings: tighter letter-spacing (-0.02em)
- Body: 15px base, line-height 1.5
## 3. UX IMPROVEMENTS
### Ticket list (page.tsx):
- Inbox-like rows: padding, hover highlight, subtle left border on hover
- Add checkbox column for future batch actions
- Clicking row opens detail in split view: list stays on left, detail on right
- OR simplified: click opens slide-over Sheet from right showing detail
- "New Ticket" button: prominent with gradient background
### Ticket detail (tickets/[id]/page.tsx):
- Better conversation hierarchy: customer messages tinted, agent clearly differentiated
- Properties sidebar: group into Status/Assignment/Details sections with dividers
- Status dropdown: more prominent (most important action)
### Global:
- Smooth transitions (150ms ease) on hover states
- Border radius consistency (8px cards, 6px inputs)
- Sidebar collapse/expand with hamburger
- Breadcrumb navigation at top of detail pages
- Tooltips on icon buttons
- Split-view or sheet overlay for ticket detail from list
### Command palette:
- Ensure Cmd+K / Ctrl+K triggers it
- Add fuzzy ticket search
- Add quick actions
## 4. IMPLEMENTATION
- Read current files first
- Modify existing files, don't delete
- Preserve api.ts and types.ts
- Use next-themes for theme switching
- shadcn/ui CSS variables already in globals.css
## 5. VERIFY
- `cd web && bun run build` — zero TypeScript errors
- Both light and dark mode work
- Font has OpenType features applied
- Ticket list is inbox-like

121
docs/web-redesign-spec.md Normal file
View File

@@ -0,0 +1,121 @@
Redesign the Tessera frontend completely. The current version is functional but has poor UX. This is a complete rewrite of the visual design and layout — keep the existing API client (web/src/lib/api.ts) and types (web/src/lib/types.ts), rewrite everything else.
## DESIGN SYSTEM: Linear-inspired dark mode
Use the exact design tokens from the Linear design system:
- Background: #08090a (canvas), #0f1011 (panels), #191a1b (cards)
- Text: #f7f8f8 (primary), #d0d6e0 (secondary), #8a8f98 (tertiary)
- Border: rgba(255,255,255,0.08) (standard), rgba(255,255,255,0.05) (subtle)
- Accent: #5e6ad2 (primary), #7170ff (interactive), #828fff (hover)
- Font: Inter (from next/font/google), weight 400/510/590
- No pure white anywhere (#f7f8f8 is max brightness)
- No color besides indigo/violet accent and status colors
Status colors: new=#8a8f98, open=#7170ff, in_progress=#f59e0b, resolved=#22c55e, closed=#6b7280
## PAGES TO REWRITE
### 1. Layout (web/src/app/layout.tsx)
- Full-height app shell: sidebar + main content
- LEFT SIDEBAR (240px, fixed):
- "Tessera" brand at top (indigo gradient text or icon)
- Saved views section with counts:
- All tickets (count)
- My tickets (count)
- Unassigned (count)
- Recently updated (count)
- Queues section (dynamic list with counts)
- Bottom: Admin link (gear icon), user avatar placeholder
- Sidebar styling: bg-[#0f1011], border-r border-[rgba(255,255,255,0.05)]
- Active view: bg-[rgba(255,255,255,0.05)], text-white
- Inactive view: text-[#8a8f98] hover:text-[#d0d6e0]
- Each nav item: flex justify-between, icon + label left, count badge right
### 2. Ticket List — the main view (web/src/app/page.tsx)
This is THE primary technician view. It must be fast and scannable.
- TOP BAR: search input (full width, bg-[#0f1011], border-b), "New ticket" button (indigo)
- Below: filter chips row — "All", "Open", "In progress", "Resolved", "My tickets", "Unassigned" — clickable pills
- TICKET LIST (not a heavy data table — a sleek list):
- Each row: colored status dot (8px circle) + ticket ID (first 8 chars, monospace, muted) + subject (primary text) + queue badge + assignee avatar + relative time
- Hover: subtle bg change, show quick actions (assign, change status)
- Click: navigate to detail
- Loading: skeleton rows (pulsing bg-[#191a1b])
- Empty: "No tickets match your filters" with illustration
- Bottom: "Load more" or pagination
- CREATE TICKET DIALOG: modal with subject input + queue select + description textarea + submit
### 3. Ticket Detail — two-panel layout (web/src/app/tickets/[id]/page.tsx)
This is the heart of the product. Conversation-centric.
LAYOUT:
- LEFT (60%): Conversation thread + reply box
- RIGHT (40%): Properties sidebar
CONVERSATION THREAD:
- Each entry is a message bubble, not a table row
- Customer messages: left-aligned, lighter bg
- Agent replies: right-aligned or indigo-tinted
- System events (status changes, assignments): centered, muted, small text, no bubble — inline timeline entries like "Gjermund changed status from open → in_progress · 2 minutes ago"
- Timestamps: relative (formatDistanceToNow from date-fns), absolute on hover
- Avatars: initials circle (first letter of name), colored by hash
REPLY BOX (sticky at bottom of thread):
- Textarea with placeholder "Reply to this ticket..."
- Toggle: "Reply" (public) vs "Internal note" (private) — tab-like toggle
- Send button (indigo) + attachment button (paperclip icon, disabled for now)
PROPERTIES SIDEBAR (right panel, bg-[#0f1011], border-l):
- Status selector: large, prominent dropdown with color indicator
- Priority: not yet implemented, show placeholder
- Assignee: dropdown to assign/change owner
- Queue: display name
- Custom fields: name: value pairs, editable inline
- Created/Updated: display dates
STATUS CHANGE:
- Click status in sidebar → dropdown shows allowed transitions (from lifecycle)
- Selecting a new status shows preview of scrips that will fire (inline, not modal)
- Confirm button: "Apply — 2 scrips will fire"
- After apply: show scrip results inline (success/error with message)
### 4. Admin (web/src/app/admin/page.tsx)
- Same sidebar layout (no changes)
- Content area: tabs across top (Queues, Lifecycles, Scrips, Custom Fields)
- Each tab: clean card-based layout, not data tables
- Add button in each section header
- Forms in slide-over sheets (from right), not dialogs — sheets feel more professional
- Delete/edit: subtle icon buttons on hover
### 5. COMMAND PALETTE (new — web/src/components/command-palette.tsx)
- Trigger: Cmd+K or Ctrl+K
- Modal overlay with search input
- Actions: "New ticket", "Go to tickets", "Go to admin", search tickets by subject
- Keyboard navigation: arrow keys + enter
- Dimmed backdrop with bg-black/60
## IMPLEMENTATION NOTES
- Keep web/src/lib/api.ts and web/src/lib/types.ts as-is (they work)
- Delete all current page files and rewrite from scratch
- Delete current layout.tsx, page.tsx, tickets/[id]/page.tsx, admin/page.tsx
- Install: bun add cmdk (command palette library) or build simple one with shadcn Dialog
- All shadcn/ui components import from @/components/ui/... — reuse what's there
- Sidebar needs to be a client component with state for active view
- Use next/navigation for routing
- Loading states: skeleton everywhere (not spinners)
- Error states: inline red text with retry
- Empty states: helpful text + action button
## VERIFICATION
1. `cd web && bun run build` — zero TypeScript errors
2. Start backend on 9876, frontend on 3000-something
3. Verify: sidebar renders with saved views, ticket list shows real data, ticket detail has conversation thread + sidebar, admin has tabs in sheets
4. Commit each major section
## RULES
- All code via OpenCode. Hermes orchestrates.
- Rewrite is complete — do not keep old components
- Linear design tokens must be used exactly — verify colors match spec
- No placeholders that look broken — every state must render properly

View File

@@ -0,0 +1,29 @@
-- Drop foreign key constraints referencing tickets.id
ALTER TABLE "custom_field_values" DROP CONSTRAINT IF EXISTS "custom_field_values_ticket_id_tickets_id_fk";
ALTER TABLE "transactions" DROP CONSTRAINT IF EXISTS "transactions_ticket_id_tickets_id_fk";
-- Drop dependent indexes
DROP INDEX IF EXISTS "custom_field_values_ticket_id_idx";
DROP INDEX IF EXISTS "transactions_ticket_id_idx";
-- Clear all data from affected tables (UUIDs cannot cast to integer)
DELETE FROM "custom_field_values";
DELETE FROM "transactions";
DELETE FROM "tickets";
-- Alter column types with USING clause for empty tables
ALTER TABLE "custom_field_values" ALTER COLUMN "ticket_id" SET DATA TYPE integer USING (0);
ALTER TABLE "transactions" ALTER COLUMN "ticket_id" SET DATA TYPE integer USING (0);
-- Alter tickets.id to serial
ALTER TABLE "tickets" ALTER COLUMN "id" DROP DEFAULT;
ALTER TABLE "tickets" ALTER COLUMN "id" SET DATA TYPE integer USING (0);
ALTER TABLE "tickets" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY (sequence name "tickets_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1);
-- Re-add foreign key constraints
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
-- Re-create indexes
CREATE INDEX "custom_field_values_ticket_id_idx" ON "custom_field_values" USING btree ("ticket_id");
CREATE INDEX "transactions_ticket_id_idx" ON "transactions" USING btree ("ticket_id");

View File

@@ -0,0 +1,10 @@
ALTER TABLE "custom_fields" ADD COLUMN "key" text;
--> statement-breakpoint
UPDATE "custom_fields"
SET "key" = trim(both '_' from regexp_replace(lower("name"), '[^a-z0-9]+', '_', 'g'));
--> statement-breakpoint
UPDATE "custom_fields" SET "key" = 'field_' || substring("id"::text, 1, 8) WHERE "key" IS NULL OR "key" = '';
--> statement-breakpoint
ALTER TABLE "custom_fields" ALTER COLUMN "key" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "custom_fields" ADD CONSTRAINT "custom_fields_key_unique" UNIQUE("key");

View File

@@ -0,0 +1,12 @@
CREATE TABLE "views" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"filters" jsonb DEFAULT '[]' NOT NULL,
"sort_key" text DEFAULT 'updated',
"columns" jsonb DEFAULT '[]',
"is_public" boolean DEFAULT false,
"creator_id" uuid,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "views" ADD CONSTRAINT "views_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,22 @@
CREATE TABLE "dashboard_widgets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"dashboard_id" uuid NOT NULL,
"view_id" uuid NOT NULL,
"title" text NOT NULL,
"widget_type" text NOT NULL,
"position" jsonb DEFAULT '{"x":0,"y":0,"w":4,"h":2}',
"config" jsonb DEFAULT '{}',
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "dashboards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"layout" jsonb DEFAULT '[]',
"is_default" boolean DEFAULT false,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_dashboard_id_dashboards_id_fk" FOREIGN KEY ("dashboard_id") REFERENCES "public"."dashboards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_view_id_views_id_fk" FOREIGN KEY ("view_id") REFERENCES "public"."views"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,19 @@
CREATE TABLE "team_members" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
CONSTRAINT "team_members_team_id_user_id_unique" UNIQUE("team_id","user_id")
);
--> statement-breakpoint
CREATE TABLE "teams" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "teams_name_unique" UNIQUE("name")
);
--> statement-breakpoint
ALTER TABLE "dashboards" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "tickets" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "queues" ADD COLUMN "team_id" uuid;--> statement-breakpoint
ALTER TABLE "queues" ADD CONSTRAINT "queues_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,916 @@
{
"id": "042752b4-e1ad-4b6d-96ed-81f836028826",
"prevId": "981c2ca0-1a37-4fbd-8624-2e7f43cd8361",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.custom_field_values": {
"name": "custom_field_values",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"custom_field_id": {
"name": "custom_field_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ticket_id": {
"name": "ticket_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"custom_field_values_ticket_id_idx": {
"name": "custom_field_values_ticket_id_idx",
"columns": [
{
"expression": "ticket_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"custom_field_values_custom_field_id_idx": {
"name": "custom_field_values_custom_field_id_idx",
"columns": [
{
"expression": "custom_field_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"custom_field_values_custom_field_id_custom_fields_id_fk": {
"name": "custom_field_values_custom_field_id_custom_fields_id_fk",
"tableFrom": "custom_field_values",
"tableTo": "custom_fields",
"columnsFrom": [
"custom_field_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"custom_field_values_ticket_id_tickets_id_fk": {
"name": "custom_field_values_ticket_id_tickets_id_fk",
"tableFrom": "custom_field_values",
"tableTo": "tickets",
"columnsFrom": [
"ticket_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"custom_field_values_cf_id_ticket_id_value_unique": {
"name": "custom_field_values_cf_id_ticket_id_value_unique",
"nullsNotDistinct": false,
"columns": [
"custom_field_id",
"ticket_id",
"value"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.custom_fields": {
"name": "custom_fields",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"field_type": {
"name": "field_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"values": {
"name": "values",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"max_values": {
"name": "max_values",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"pattern": {
"name": "pattern",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lifecycles": {
"name": "lifecycles",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"definition": {
"name": "definition",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"lifecycles_name_unique": {
"name": "lifecycles_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.queue_custom_fields": {
"name": "queue_custom_fields",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"queue_id": {
"name": "queue_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"custom_field_id": {
"name": "custom_field_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"queue_custom_fields_queue_id_queues_id_fk": {
"name": "queue_custom_fields_queue_id_queues_id_fk",
"tableFrom": "queue_custom_fields",
"tableTo": "queues",
"columnsFrom": [
"queue_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"queue_custom_fields_custom_field_id_custom_fields_id_fk": {
"name": "queue_custom_fields_custom_field_id_custom_fields_id_fk",
"tableFrom": "queue_custom_fields",
"tableTo": "custom_fields",
"columnsFrom": [
"custom_field_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"queue_custom_fields_queue_id_custom_field_id_unique": {
"name": "queue_custom_fields_queue_id_custom_field_id_unique",
"nullsNotDistinct": false,
"columns": [
"queue_id",
"custom_field_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.queues": {
"name": "queues",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"lifecycle_id": {
"name": "lifecycle_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"queues_lifecycle_id_lifecycles_id_fk": {
"name": "queues_lifecycle_id_lifecycles_id_fk",
"tableFrom": "queues",
"tableTo": "lifecycles",
"columnsFrom": [
"lifecycle_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"queues_name_unique": {
"name": "queues_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.scrips": {
"name": "scrips",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"queue_id": {
"name": "queue_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"condition_type": {
"name": "condition_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"condition_config": {
"name": "condition_config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"action_type": {
"name": "action_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"action_config": {
"name": "action_config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"template_id": {
"name": "template_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"stage": {
"name": "stage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'TransactionCreate'"
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"disabled": {
"name": "disabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"scrips_queue_id_idx": {
"name": "scrips_queue_id_idx",
"columns": [
{
"expression": "queue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"scrips_queue_id_queues_id_fk": {
"name": "scrips_queue_id_queues_id_fk",
"tableFrom": "scrips",
"tableTo": "queues",
"columnsFrom": [
"queue_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"scrips_template_id_templates_id_fk": {
"name": "scrips_template_id_templates_id_fk",
"tableFrom": "scrips",
"tableTo": "templates",
"columnsFrom": [
"template_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.templates": {
"name": "templates",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"queue_id": {
"name": "queue_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"subject_template": {
"name": "subject_template",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body_template": {
"name": "body_template",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"templates_queue_id_queues_id_fk": {
"name": "templates_queue_id_queues_id_fk",
"tableFrom": "templates",
"tableTo": "queues",
"columnsFrom": [
"queue_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tickets": {
"name": "tickets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tickets_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true
},
"queue_id": {
"name": "queue_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"creator_id": {
"name": "creator_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"tickets_queue_id_idx": {
"name": "tickets_queue_id_idx",
"columns": [
{
"expression": "queue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"tickets_status_idx": {
"name": "tickets_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tickets_queue_id_queues_id_fk": {
"name": "tickets_queue_id_queues_id_fk",
"tableFrom": "tickets",
"tableTo": "queues",
"columnsFrom": [
"queue_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"tickets_owner_id_users_id_fk": {
"name": "tickets_owner_id_users_id_fk",
"tableFrom": "tickets",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"tickets_creator_id_users_id_fk": {
"name": "tickets_creator_id_users_id_fk",
"tableFrom": "tickets",
"tableTo": "users",
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.transactions": {
"name": "transactions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"ticket_id": {
"name": "ticket_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"transaction_type": {
"name": "transaction_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"field": {
"name": "field",
"type": "text",
"primaryKey": false,
"notNull": false
},
"old_value": {
"name": "old_value",
"type": "text",
"primaryKey": false,
"notNull": false
},
"new_value": {
"name": "new_value",
"type": "text",
"primaryKey": false,
"notNull": false
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"creator_id": {
"name": "creator_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"transactions_ticket_id_idx": {
"name": "transactions_ticket_id_idx",
"columns": [
{
"expression": "ticket_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"transactions_created_at_idx": {
"name": "transactions_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"transactions_ticket_id_tickets_id_fk": {
"name": "transactions_ticket_id_tickets_id_fk",
"tableFrom": "transactions",
"tableTo": "tickets",
"columnsFrom": [
"ticket_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"transactions_creator_id_users_id_fk": {
"name": "transactions_creator_id_users_id_fk",
"tableFrom": "transactions",
"tableTo": "users",
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,55 @@
"when": 1780859982396, "when": 1780859982396,
"tag": "0000_acoustic_wendell_vaughn", "tag": "0000_acoustic_wendell_vaughn",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780867177929,
"tag": "0001_lovely_quentin_quire",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780904200000,
"tag": "0002_short_custom_field_keys",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780995910694,
"tag": "0003_dry_caretaker",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780996807814,
"tag": "0004_sturdy_natasha_romanoff",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1781004398567,
"tag": "0005_spotty_leader",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1781008559188,
"tag": "0006_nosy_black_queen",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1781009018666,
"tag": "0007_flimsy_roughhouse",
"breakpoints": true
} }
] ]
} }

View File

@@ -3,6 +3,13 @@
"module": "src/index.ts", "module": "src/index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"dev:backend": "bun run src/index.ts",
"db:migrate": "bun run src/db/migrate.ts",
"db:seed": "bun run src/db/seed.ts",
"db:seed:reset": "bun run src/db/seed.ts --reset",
"smoke": "bun run scripts/smoke-test.ts"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"@types/handlebars": "^4.1.0", "@types/handlebars": "^4.1.0",

112
scripts/smoke-test.ts Normal file
View File

@@ -0,0 +1,112 @@
const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:9876';
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:3100';
interface Ticket {
id: number;
subject: string;
}
interface Queue {
id: string;
name: string;
}
interface Transaction {
id: string;
ticket_id: number;
transaction_type: string;
}
async function requestJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} returned ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
async function requestOk(url: string): Promise<void> {
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`${url} returned ${response.status} ${response.statusText}`);
}
}
async function check(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
console.log(`ok ${name}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`fail ${name}`);
console.error(` ${message}`);
process.exitCode = 1;
}
}
async function main() {
let ticketForDetail: Ticket | null = null;
await check('backend health', async () => {
const health = await requestJson<{ status: string }>(`${backendUrl}/health`);
if (health.status !== 'ok') {
throw new Error(`expected status ok, got ${JSON.stringify(health)}`);
}
});
await check('queues exist', async () => {
const queues = await requestJson<Queue[]>(`${backendUrl}/queues`);
if (queues.length < 1) {
throw new Error('expected at least one queue');
}
});
await check('tickets exist', async () => {
const tickets = await requestJson<Ticket[]>(`${backendUrl}/tickets`);
if (tickets.length < 1) {
throw new Error('expected at least one ticket');
}
ticketForDetail = tickets.find((ticket) => ticket.subject.includes('VPN access')) ?? tickets[0] ?? null;
});
await check('ticket detail has activity', async () => {
if (!ticketForDetail) {
throw new Error('no ticket available for detail check');
}
const transactions = await requestJson<Transaction[]>(
`${backendUrl}/tickets/${ticketForDetail.id}/transactions`,
);
if (transactions.length < 1) {
throw new Error(`expected ticket ${ticketForDetail.id} to have transactions`);
}
});
await check('frontend index responds', async () => {
await requestOk(frontendUrl);
});
await check('frontend ticket detail responds', async () => {
if (!ticketForDetail) {
throw new Error('no ticket available for frontend detail check');
}
await requestOk(`${frontendUrl}/tickets/${ticketForDetail.id}`);
});
await check('frontend api proxy responds', async () => {
const health = await requestJson<{ status: string }>(`${frontendUrl}/api/health`);
if (health.status !== 'ok') {
throw new Error(`expected status ok, got ${JSON.stringify(health)}`);
}
});
if (process.exitCode) {
process.exit(process.exitCode);
}
console.log('Smoke test passed');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

15
scripts/watch-frontend.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Watch for source changes and auto-rebuild + restart Tessera frontend
DIR="/home/gjermund/projects/tessera/web/src"
LAST_BUILD=0
echo "Watching $DIR for changes..."
inotifywait -m -r -e modify,create,delete "$DIR" --format '%w%f' 2>/dev/null | while read FILE; do
NOW=$(date +%s)
if [ $((NOW - LAST_BUILD)) -gt 3 ]; then
echo "[$(date +%H:%M:%S)] Change detected, rebuilding..."
cd /home/gjermund/projects/tessera/web && npx next build 2>&1 | tail -1
LAST_BUILD=$NOW
fi
done

View File

@@ -1,5 +1,5 @@
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core'; import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm'; import { relations, sql } from 'drizzle-orm';
export const users = pgTable('users', { export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -13,6 +13,7 @@ export const queues = pgTable('queues', {
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
description: text('description'), description: text('description'),
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id), lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id),
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}); });
@@ -24,11 +25,12 @@ export const lifecycles = pgTable('lifecycles', {
}); });
export const tickets = pgTable('tickets', { export const tickets = pgTable('tickets', {
id: uuid('id').primaryKey().defaultRandom(), id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
subject: text('subject').notNull(), subject: text('subject').notNull(),
queue_id: uuid('queue_id').notNull().references(() => queues.id), queue_id: uuid('queue_id').notNull().references(() => queues.id),
status: text('status').notNull(), status: text('status').notNull(),
owner_id: uuid('owner_id').references(() => users.id), owner_id: uuid('owner_id').references(() => users.id),
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
creator_id: uuid('creator_id').notNull().references(() => users.id), creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(), updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
@@ -41,7 +43,7 @@ export const tickets = pgTable('tickets', {
export const transactions = pgTable('transactions', { export const transactions = pgTable('transactions', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }), ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
transaction_type: text('transaction_type').notNull(), transaction_type: text('transaction_type').notNull(),
field: text('field'), field: text('field'),
old_value: text('old_value'), old_value: text('old_value'),
@@ -83,6 +85,7 @@ export const scrips = pgTable('scrips', {
export const customFields = pgTable('custom_fields', { export const customFields = pgTable('custom_fields', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
key: text('key').notNull().unique(),
name: text('name').notNull(), name: text('name').notNull(),
field_type: text('field_type').notNull(), field_type: text('field_type').notNull(),
values: jsonb('values'), values: jsonb('values'),
@@ -103,7 +106,7 @@ export const queueCustomFields = pgTable('queue_custom_fields', {
export const customFieldValues = pgTable('custom_field_values', { export const customFieldValues = pgTable('custom_field_values', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }), custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }),
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }), ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
value: text('value').notNull(), value: text('value').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({ }, (table) => ({
@@ -111,3 +114,59 @@ export const customFieldValues = pgTable('custom_field_values', {
ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id), ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id),
cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id), cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id),
})); }));
export const views = pgTable('views', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
filters: jsonb('filters').notNull().default('[]'),
sort_key: text('sort_key').default('updated'),
columns: jsonb('columns').default('[]'),
is_public: boolean('is_public').default(false),
creator_id: uuid('creator_id').references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const teams = pgTable('teams', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
description: text('description'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const teamsRelations = relations(teams, ({ many }) => ({
members: many(teamMembers),
}));
export const teamMembers = pgTable('team_members', {
id: uuid('id').primaryKey().defaultRandom(),
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
}, (table) => ({
uniqueMember: unique('team_members_team_id_user_id_unique').on(table.team_id, table.user_id),
}));
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
team: one(teams, { fields: [teamMembers.team_id], references: [teams.id] }),
user: one(users, { fields: [teamMembers.user_id], references: [users.id] }),
}));
export const dashboards = pgTable('dashboards', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
layout: jsonb('layout').default('[]'),
is_default: boolean('is_default').default(false),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
export const dashboardWidgets = pgTable('dashboard_widgets', {
id: uuid('id').primaryKey().defaultRandom(),
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
widget_type: text('widget_type').notNull(),
position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'),
config: jsonb('config').default('{}'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});

843
src/db/seed.ts Normal file
View File

@@ -0,0 +1,843 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { eq, inArray } from 'drizzle-orm';
import * as schema from './schema.ts';
import {
customFields,
customFieldValues,
lifecycles,
queueCustomFields,
queues,
scrips,
templates,
tickets,
transactions,
views,
dashboards,
dashboardWidgets,
users,
} from './schema.ts';
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
const lifecycleDefinition = {
statuses: {
initial: ['new'],
active: ['open', 'in_progress'],
inactive: ['resolved', 'closed'],
},
transitions: {
new: ['open', 'in_progress', 'closed'],
open: ['in_progress', 'resolved', 'closed'],
in_progress: ['open', 'resolved', 'closed'],
resolved: ['open', 'closed'],
closed: ['open'],
'*': ['closed'],
},
};
function daysAgo(days: number, hour = 9, minute = 0): Date {
const date = new Date();
date.setDate(date.getDate() - days);
date.setHours(hour, minute, 0, 0);
return date;
}
function hoursAgo(hours: number): Date {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}
function createSeedDb(pool: Pool) {
return drizzle(pool, { schema });
}
type Db = ReturnType<typeof createSeedDb>;
type UserSeed = { id: string; username: string; email: string };
type QueueSeed = { name: string; description: string };
type FieldSeed = {
key?: string;
name: string;
field_type: string;
values?: unknown;
max_values?: number;
pattern?: string | null;
};
function makeFieldKey(value: string): string {
const key = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return key || 'field';
}
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
const existingById = await db.query.users.findFirst({
where: eq(users.id, seed.id),
});
if (existingById) {
await db.update(users)
.set({ username: seed.username, email: seed.email })
.where(eq(users.id, seed.id));
return existingById.id;
}
const existingByUsername = await db.query.users.findFirst({
where: eq(users.username, seed.username),
});
if (existingByUsername) {
await db.update(users)
.set({ email: seed.email })
.where(eq(users.id, existingByUsername.id));
return existingByUsername.id;
}
const [created] = await db.insert(users).values(seed).returning();
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
return created.id;
}
async function ensureLifecycle(db: Db) {
const existing = await db.query.lifecycles.findFirst({
where: eq(lifecycles.name, 'Demo service lifecycle'),
});
if (existing) {
const [updated] = await db.update(lifecycles)
.set({ definition: lifecycleDefinition })
.where(eq(lifecycles.id, existing.id))
.returning();
if (!updated) throw new Error('Failed to update demo lifecycle');
return updated;
}
const [created] = await db.insert(lifecycles).values({
name: 'Demo service lifecycle',
definition: lifecycleDefinition,
}).returning();
if (!created) throw new Error('Failed to seed demo lifecycle');
return created;
}
async function ensureQueue(db: Db, lifecycleId: string, seed: QueueSeed) {
const existing = await db.query.queues.findFirst({
where: eq(queues.name, seed.name),
});
if (existing) {
const [updated] = await db.update(queues)
.set({
description: seed.description,
lifecycle_id: lifecycleId,
})
.where(eq(queues.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update queue ${seed.name}`);
return updated;
}
const [created] = await db.insert(queues).values({
name: seed.name,
description: seed.description,
lifecycle_id: lifecycleId,
}).returning();
if (!created) throw new Error(`Failed to seed queue ${seed.name}`);
return created;
}
async function ensureCustomField(db: Db, seed: FieldSeed) {
const existing = await db.query.customFields.findFirst({
where: eq(customFields.name, seed.name),
});
const values = {
key: seed.key ?? makeFieldKey(seed.name),
field_type: seed.field_type,
values: seed.values ?? null,
max_values: seed.max_values ?? 1,
pattern: seed.pattern ?? null,
};
if (existing) {
const [updated] = await db.update(customFields)
.set(values)
.where(eq(customFields.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update custom field ${seed.name}`);
return updated;
}
const [created] = await db.insert(customFields).values({
name: seed.name,
...values,
}).returning();
if (!created) throw new Error(`Failed to seed custom field ${seed.name}`);
return created;
}
async function attachFieldToQueue(db: Db, queueId: string, fieldId: string, sortOrder: number) {
await db.insert(queueCustomFields)
.values({
queue_id: queueId,
custom_field_id: fieldId,
sort_order: sortOrder,
})
.onConflictDoUpdate({
target: [queueCustomFields.queue_id, queueCustomFields.custom_field_id],
set: { sort_order: sortOrder },
});
}
async function ensureTemplate(
db: Db,
name: string,
queueId: string | null,
subjectTemplate: string,
bodyTemplate: string,
) {
const existing = await db.query.templates.findFirst({
where: (row, { and, eq, isNull }) =>
queueId ? and(eq(row.name, name), eq(row.queue_id, queueId)) : and(eq(row.name, name), isNull(row.queue_id)),
});
if (existing) {
const [updated] = await db.update(templates)
.set({ subject_template: subjectTemplate, body_template: bodyTemplate })
.where(eq(templates.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update template ${name}`);
return updated;
}
const [created] = await db.insert(templates).values({
name,
queue_id: queueId,
subject_template: subjectTemplate,
body_template: bodyTemplate,
}).returning();
if (!created) throw new Error(`Failed to seed template ${name}`);
return created;
}
async function ensureScrip(
db: Db,
seed: {
name: string;
description: string;
queueId: string | null;
conditionType: string;
actionType: string;
actionConfig: Record<string, unknown>;
templateId?: string | null;
sortOrder: number;
disabled?: boolean;
},
) {
const existing = await db.query.scrips.findFirst({
where: (row, { and, eq, isNull }) =>
seed.queueId
? and(eq(row.name, seed.name), eq(row.queue_id, seed.queueId))
: and(eq(row.name, seed.name), isNull(row.queue_id)),
});
const values = {
queue_id: seed.queueId,
name: seed.name,
description: seed.description,
condition_type: seed.conditionType,
condition_config: {},
action_type: seed.actionType,
action_config: seed.actionConfig,
template_id: seed.templateId ?? null,
stage: 'TransactionCreate',
sort_order: seed.sortOrder,
disabled: seed.disabled ?? false,
};
if (existing) {
const [updated] = await db.update(scrips)
.set(values)
.where(eq(scrips.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update scrip ${seed.name}`);
return updated;
}
const [created] = await db.insert(scrips).values(values).returning();
if (!created) throw new Error(`Failed to seed scrip ${seed.name}`);
return created;
}
async function ensureTicket(
db: Db,
seed: {
subject: string;
queueId: string;
status: string;
ownerId: string | null;
creatorId: string;
createdAt: Date;
updatedAt: Date;
startedAt?: Date | null;
resolvedAt?: Date | null;
},
) {
const existing = await db.query.tickets.findFirst({
where: eq(tickets.subject, seed.subject),
});
const values = {
subject: seed.subject,
queue_id: seed.queueId,
status: seed.status,
owner_id: seed.ownerId,
creator_id: seed.creatorId,
created_at: seed.createdAt,
updated_at: seed.updatedAt,
started_at: seed.startedAt ?? null,
resolved_at: seed.resolvedAt ?? null,
};
if (existing) {
const [updated] = await db.update(tickets)
.set(values)
.where(eq(tickets.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update ticket ${seed.subject}`);
return updated;
}
const [created] = await db.insert(tickets).values(values).returning();
if (!created) throw new Error(`Failed to seed ticket ${seed.subject}`);
return created;
}
async function resetDatabase(db: Db) {
await db.delete(customFieldValues);
await db.delete(transactions);
await db.delete(queueCustomFields);
await db.delete(dashboardWidgets);
await db.delete(dashboards);
await db.delete(views);
await db.delete(scrips);
await db.delete(templates);
await db.delete(tickets);
await db.delete(queues);
await db.delete(customFields);
await db.delete(lifecycles);
await db.delete(users);
}
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
const pool = new Pool({ connectionString: databaseUrl });
const db = createSeedDb(pool);
const reset = process.argv.includes('--reset');
try {
if (reset) {
console.log('Resetting database before seeding demo data...');
await resetDatabase(db);
}
const userIds = {
system: await ensureUser(db, {
id: SYSTEM_USER_ID,
username: 'system',
email: 'system@tessera.local',
}),
dispatcher: await ensureUser(db, {
id: '11111111-1111-4111-8111-111111111111',
username: 'maria.dispatch',
email: 'maria.dispatch@tessera.local',
}),
technician: await ensureUser(db, {
id: '22222222-2222-4222-8222-222222222222',
username: 'liam.field',
email: 'liam.field@tessera.local',
}),
facilities: await ensureUser(db, {
id: '33333333-3333-4333-8333-333333333333',
username: 'nora.facilities',
email: 'nora.facilities@tessera.local',
}),
security: await ensureUser(db, {
id: '44444444-4444-4444-8444-444444444444',
username: 'sam.security',
email: 'sam.security@tessera.local',
}),
};
const lifecycle = await ensureLifecycle(db);
const supportQueue = await ensureQueue(db, lifecycle.id, {
name: 'Support Desk',
description: 'Employee requests, account access, hardware, and everyday service desk intake.',
});
const fieldQueue = await ensureQueue(db, lifecycle.id, {
name: 'Field Operations',
description: 'Technician dispatch, site work, parts, and customer-impacting operational issues.',
});
const facilitiesQueue = await ensureQueue(db, lifecycle.id, {
name: 'Facilities',
description: 'Building maintenance, access, meeting rooms, and office environment requests.',
});
const securityQueue = await ensureQueue(db, lifecycle.id, {
name: 'Security',
description: 'Badge access, incident review, and compliance-sensitive operational requests.',
});
const impactField = await ensureCustomField(db, {
key: 'impact',
name: 'Impact',
field_type: 'select',
values: ['Low', 'Medium', 'High', 'Critical'],
});
const locationField = await ensureCustomField(db, {
key: 'location',
name: 'Location',
field_type: 'text',
});
const assetField = await ensureCustomField(db, {
key: 'asset_tag',
name: 'Asset tag',
field_type: 'text',
pattern: '^ASSET-[0-9]{4}$',
});
const channelField = await ensureCustomField(db, {
key: 'channel',
name: 'Channel',
field_type: 'select',
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
});
const outcomeField = await ensureCustomField(db, {
key: 'resolution_outcome',
name: 'Resolution outcome',
field_type: 'select',
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
});
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
await attachFieldToQueue(db, queue.id, impactField.id, 10);
await attachFieldToQueue(db, queue.id, locationField.id, 20);
await attachFieldToQueue(db, queue.id, channelField.id, 30);
}
await attachFieldToQueue(db, supportQueue.id, assetField.id, 40);
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
const resolveTemplate = await ensureTemplate(
db,
'Demo resolution note',
null,
'Ticket {{ticket.id}} resolved: {{ticket.subject}}',
'Ticket {{ticket.id}} in {{queue.name}} moved from {{transaction.old_value}} to {{transaction.new_value}}.',
);
await ensureScrip(db, {
name: 'Demo: mark outcome on resolve',
description: 'When a ticket resolves, set the Resolution outcome custom field to Completed.',
queueId: null,
conditionType: 'OnResolve',
actionType: 'SetCustomField',
actionConfig: {
field_key: 'resolution_outcome',
value: 'Completed',
},
sortOrder: 10,
});
await ensureScrip(db, {
name: 'Demo: customer notification template',
description: 'Disabled sample email action showing how resolution templates render.',
queueId: null,
conditionType: 'OnResolve',
actionType: 'SendEmail',
actionConfig: {
recipients: ['requester@example.com'],
},
templateId: resolveTemplate.id,
sortOrder: 20,
disabled: true,
});
const demoTickets = [
await ensureTicket(db, {
subject: 'VPN access fails after password reset',
queueId: supportQueue.id,
status: 'open',
ownerId: userIds.dispatcher,
creatorId: userIds.system,
createdAt: daysAgo(4, 8, 40),
updatedAt: hoursAgo(3),
startedAt: daysAgo(4, 9, 10),
}),
await ensureTicket(db, {
subject: 'Warehouse scanner ASSET-1042 will not sync inventory',
queueId: fieldQueue.id,
status: 'in_progress',
ownerId: userIds.technician,
creatorId: userIds.system,
createdAt: daysAgo(2, 10, 15),
updatedAt: hoursAgo(1),
startedAt: daysAgo(2, 11, 0),
}),
await ensureTicket(db, {
subject: 'Badge reader intermittently denies access at north entrance',
queueId: securityQueue.id,
status: 'new',
ownerId: null,
creatorId: userIds.system,
createdAt: hoursAgo(7),
updatedAt: hoursAgo(7),
}),
await ensureTicket(db, {
subject: 'Conference room display flickers during video calls',
queueId: facilitiesQueue.id,
status: 'open',
ownerId: userIds.facilities,
creatorId: userIds.system,
createdAt: daysAgo(1, 14, 20),
updatedAt: hoursAgo(4),
startedAt: daysAgo(1, 15, 0),
}),
await ensureTicket(db, {
subject: 'New hire laptop provisioning for Monday start',
queueId: supportQueue.id,
status: 'resolved',
ownerId: userIds.dispatcher,
creatorId: userIds.system,
createdAt: daysAgo(6, 13, 30),
updatedAt: daysAgo(1, 16, 45),
startedAt: daysAgo(6, 14, 0),
resolvedAt: daysAgo(1, 16, 45),
}),
await ensureTicket(db, {
subject: 'Temperature alert in server closet B',
queueId: facilitiesQueue.id,
status: 'in_progress',
ownerId: userIds.facilities,
creatorId: userIds.system,
createdAt: hoursAgo(18),
updatedAt: hoursAgo(2),
startedAt: hoursAgo(17),
}),
await ensureTicket(db, {
subject: 'Quarterly access review export requested',
queueId: securityQueue.id,
status: 'closed',
ownerId: userIds.security,
creatorId: userIds.system,
createdAt: daysAgo(9, 10, 0),
updatedAt: daysAgo(3, 11, 20),
startedAt: daysAgo(9, 10, 30),
resolvedAt: daysAgo(3, 11, 20),
}),
await ensureTicket(db, {
subject: 'POS terminal receipt printer jam at front desk',
queueId: fieldQueue.id,
status: 'new',
ownerId: null,
creatorId: userIds.system,
createdAt: hoursAgo(5),
updatedAt: hoursAgo(5),
}),
];
const demoTicketIds = demoTickets.map((ticket) => ticket.id);
if (demoTicketIds.length > 0) {
await db.delete(customFieldValues).where(inArray(customFieldValues.ticket_id, demoTicketIds));
await db.delete(transactions).where(inArray(transactions.ticket_id, demoTicketIds));
}
const ticketBySubject = new Map(demoTickets.map((ticket) => [ticket.subject, ticket]));
const txRows = [
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(4, 8, 40),
},
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
creator_id: userIds.dispatcher,
created_at: daysAgo(4, 9, 10),
},
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'Correspond',
data: { body: 'I reset my password this morning and now the VPN client rejects the new password. Browser login works.' },
creator_id: userIds.system,
created_at: daysAgo(4, 9, 12),
},
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'Comment',
data: { body: 'Likely stale cached credentials. Ask user to clear saved VPN profile and confirm MFA prompt.' },
creator_id: userIds.dispatcher,
created_at: hoursAgo(3),
},
{
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(2, 10, 15),
},
{
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'in_progress',
creator_id: userIds.technician,
created_at: daysAgo(2, 11, 0),
},
{
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
transaction_type: 'Comment',
data: { body: 'Device reaches Wi-Fi but sync service returns 409. Pulling logs before factory reset.' },
creator_id: userIds.technician,
created_at: hoursAgo(1),
},
{
ticket_id: ticketBySubject.get('Badge reader intermittently denies access at north entrance')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: hoursAgo(7),
},
{
ticket_id: ticketBySubject.get('Badge reader intermittently denies access at north entrance')!.id,
transaction_type: 'Correspond',
data: { body: 'Three employees reported failures between 07:40 and 08:05. Security desk can override manually.' },
creator_id: userIds.security,
created_at: hoursAgo(6),
},
{
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(1, 14, 20),
},
{
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
creator_id: userIds.facilities,
created_at: daysAgo(1, 15, 0),
},
{
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
transaction_type: 'Comment',
data: { body: 'Cable path looks strained. Spare HDMI and USB-C adapters staged in the room.' },
creator_id: userIds.facilities,
created_at: hoursAgo(4),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(6, 13, 30),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
creator_id: userIds.dispatcher,
created_at: daysAgo(6, 14, 0),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'Correspond',
data: { body: 'Laptop imaged, account created, and pickup instructions sent to hiring manager.' },
creator_id: userIds.dispatcher,
created_at: daysAgo(1, 16, 20),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'open',
new_value: 'resolved',
creator_id: userIds.dispatcher,
created_at: daysAgo(1, 16, 45),
},
{
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: hoursAgo(18),
},
{
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'in_progress',
creator_id: userIds.facilities,
created_at: hoursAgo(17),
},
{
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
transaction_type: 'Comment',
data: { body: 'Portable cooling installed. HVAC vendor scheduled; rack intake is back under threshold.' },
creator_id: userIds.facilities,
created_at: hoursAgo(2),
},
{
ticket_id: ticketBySubject.get('Quarterly access review export requested')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(9, 10, 0),
},
{
ticket_id: ticketBySubject.get('Quarterly access review export requested')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'closed',
creator_id: userIds.security,
created_at: daysAgo(3, 11, 20),
},
{
ticket_id: ticketBySubject.get('POS terminal receipt printer jam at front desk')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: hoursAgo(5),
},
{
ticket_id: ticketBySubject.get('POS terminal receipt printer jam at front desk')!.id,
transaction_type: 'Correspond',
data: { body: 'Front desk can still email receipts, but lunch rush needs a working printer.' },
creator_id: userIds.system,
created_at: hoursAgo(5),
},
];
await db.insert(transactions).values(txRows);
const fieldRows = [
['VPN access fails after password reset', impactField.id, 'Medium'],
['VPN access fails after password reset', channelField.id, 'Portal'],
['VPN access fails after password reset', locationField.id, 'Remote'],
['Warehouse scanner ASSET-1042 will not sync inventory', impactField.id, 'High'],
['Warehouse scanner ASSET-1042 will not sync inventory', channelField.id, 'Phone'],
['Warehouse scanner ASSET-1042 will not sync inventory', locationField.id, 'Warehouse A'],
['Warehouse scanner ASSET-1042 will not sync inventory', assetField.id, 'ASSET-1042'],
['Badge reader intermittently denies access at north entrance', impactField.id, 'High'],
['Badge reader intermittently denies access at north entrance', channelField.id, 'Walk-up'],
['Badge reader intermittently denies access at north entrance', locationField.id, 'North entrance'],
['Conference room display flickers during video calls', impactField.id, 'Medium'],
['Conference room display flickers during video calls', channelField.id, 'Email'],
['Conference room display flickers during video calls', locationField.id, 'Room 4B'],
['New hire laptop provisioning for Monday start', impactField.id, 'Low'],
['New hire laptop provisioning for Monday start', channelField.id, 'Portal'],
['New hire laptop provisioning for Monday start', assetField.id, 'ASSET-2201'],
['New hire laptop provisioning for Monday start', outcomeField.id, 'Completed'],
['Temperature alert in server closet B', impactField.id, 'Critical'],
['Temperature alert in server closet B', channelField.id, 'Monitoring'],
['Temperature alert in server closet B', locationField.id, 'Server closet B'],
['Quarterly access review export requested', impactField.id, 'Low'],
['Quarterly access review export requested', channelField.id, 'Portal'],
['Quarterly access review export requested', outcomeField.id, 'Completed'],
['POS terminal receipt printer jam at front desk', impactField.id, 'Medium'],
['POS terminal receipt printer jam at front desk', channelField.id, 'Phone'],
['POS terminal receipt printer jam at front desk', locationField.id, 'Front desk'],
] as const;
await db.insert(customFieldValues).values(fieldRows.map(([subject, fieldId, value]) => ({
ticket_id: ticketBySubject.get(subject)!.id,
custom_field_id: fieldId,
value,
})));
console.log(`${reset ? 'Reset and seeded' : 'Seeded'} ${demoTickets.length} demo tickets across 4 queues`);
// ── Dashboard seeding ──
const dashboardViews = [
{ name: 'Open tickets', filters: [{ field: 'status', operator: 'is', value: 'open' }] },
{ name: 'My tickets', filters: [{ field: 'owner', operator: 'is', value: userIds.dispatcher }] },
{ name: 'Unassigned', filters: [{ field: 'owner', operator: 'is', value: 'unassigned' }] },
{ name: 'All tickets', filters: [] },
];
const viewRecords: Record<string, string> = {};
for (const v of dashboardViews) {
const [row] = await db.insert(views).values({
name: v.name,
filters: v.filters,
is_public: true,
}).returning();
if (row) viewRecords[v.name] = row.id;
}
const [dashboard] = await db.insert(dashboards).values({
name: 'Support overview',
description: 'Daily support team dashboard',
is_default: true,
}).returning();
if (dashboard) {
const widgetDefs = [
{ view: 'Open tickets', type: 'count', title: 'Open tickets', x: 0, y: 0, w: 3, h: 1 },
{ view: 'My tickets', type: 'count', title: 'My tickets', x: 3, y: 0, w: 3, h: 1 },
{ view: 'Unassigned', type: 'count', title: 'Unassigned', x: 6, y: 0, w: 3, h: 1 },
{ view: 'All tickets', type: 'count', title: 'Total tickets', x: 9, y: 0, w: 3, h: 1 },
{ view: 'Open tickets', type: 'status_chart', title: 'Status breakdown', x: 0, y: 1, w: 4, h: 2 },
{ view: 'Open tickets', type: 'ticket_list', title: 'Recent open', x: 4, y: 1, w: 5, h: 2, config: { limit: 5 } },
{ view: 'All tickets', type: 'grouped_counts', title: 'By queue', x: 9, y: 1, w: 3, h: 2, config: { group_by: 'queue' } },
];
for (const w of widgetDefs) {
await db.insert(dashboardWidgets).values({
dashboard_id: dashboard.id,
view_id: viewRecords[w.view],
title: w.title,
widget_type: w.type,
position: { x: w.x, y: w.y, w: w.w, h: w.h },
config: w.config ?? {},
});
}
console.log(`Seeded dashboard "${dashboard.name}" with ${widgetDefs.length} widgets`);
}
console.log('Demo data ready');
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -10,6 +10,11 @@ import { createQueuesRouter } from './routes/queues.ts';
import { createScripsRouter } from './routes/scrips.ts'; import { createScripsRouter } from './routes/scrips.ts';
import { createCustomFieldsRouter } from './routes/custom-fields.ts'; import { createCustomFieldsRouter } from './routes/custom-fields.ts';
import { createLifecyclesRouter } from './routes/lifecycles.ts'; import { createLifecyclesRouter } from './routes/lifecycles.ts';
import { createUsersRouter } from './routes/users.ts';
import { createTemplatesRouter } from './routes/templates.ts';
import { createViewsRouter } from './routes/views.ts';
import { createDashboardsRouter } from './routes/dashboards.ts';
import { createTeamsRouter } from './routes/teams.ts';
let db: Db | null = null; let db: Db | null = null;
@@ -31,6 +36,11 @@ app.route('/queues', createQueuesRouter(getDb()));
app.route('/scrips', createScripsRouter(getDb())); app.route('/scrips', createScripsRouter(getDb()));
app.route('/custom-fields', createCustomFieldsRouter(getDb())); app.route('/custom-fields', createCustomFieldsRouter(getDb()));
app.route('/lifecycles', createLifecyclesRouter(getDb())); app.route('/lifecycles', createLifecyclesRouter(getDb()));
app.route('/users', createUsersRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb()));
app.route('/views', createViewsRouter(getDb()));
app.route('/dashboards', createDashboardsRouter(getDb()));
app.route('/teams', createTeamsRouter(getDb()));
export default app; export default app;
export { app }; export { app };
@@ -41,6 +51,7 @@ if (Bun.main === import.meta.path) {
fetch: app.fetch, fetch: app.fetch,
port: config.SERVER_PORT, port: config.SERVER_PORT,
hostname: config.SERVER_HOST, hostname: config.SERVER_HOST,
development: false,
}); });
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`); console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
} }

View File

@@ -8,4 +8,5 @@ export const CreateQueueSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
lifecycle_id: z.string().uuid().optional(), lifecycle_id: z.string().uuid().optional(),
team_id: z.string().uuid().nullable().optional(),
}); });

View File

@@ -7,10 +7,19 @@ export type Ticket = InferSelectModel<typeof tickets>;
export const CreateTicketSchema = z.object({ export const CreateTicketSchema = z.object({
subject: z.string().min(1), subject: z.string().min(1),
queue_id: z.string().uuid(), queue_id: z.string().uuid(),
description: z.string().trim().optional(),
custom_fields: z.record(z.string(), z.string()).optional(),
}); });
export const UpdateTicketSchema = z.object({ export const UpdateTicketSchema = z.object({
subject: z.string().min(1).optional(), subject: z.string().min(1).optional(),
status: z.string().min(1).optional(), status: z.string().min(1).optional(),
owner_id: z.string().uuid().optional(), owner_id: z.string().uuid().nullable().optional(),
team_id: z.string().uuid().nullable().optional(),
});
export const CommentSchema = z.object({
body: z.string().min(1),
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
internal: z.boolean().optional().default(false),
}); });

View File

@@ -1,8 +1,17 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { customFields } from '../db/schema.ts'; import { customFields, queueCustomFields } from '../db/schema.ts';
import { asc } from 'drizzle-orm'; import { and, asc, eq } from 'drizzle-orm';
function makeFieldKey(value: string): string {
const key = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return key || 'field';
}
export function createCustomFieldsRouter(db: Db): Hono { export function createCustomFieldsRouter(db: Db): Hono {
const router = new Hono(); const router = new Hono();
@@ -17,12 +26,14 @@ export function createCustomFieldsRouter(db: Db): Hono {
router.post('/', async (c) => { router.post('/', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const { name, field_type, values, max_values, pattern } = body; const { name, field_type, values, max_values, pattern } = body;
const key = makeFieldKey(String(body.key ?? name ?? ''));
if (!name || !field_type) { if (!name || !field_type) {
throw new HTTPException(400, { message: 'name and field_type are required' }); throw new HTTPException(400, { message: 'name and field_type are required' });
} }
const [cf] = await db.insert(customFields).values({ const [cf] = await db.insert(customFields).values({
key,
name, name,
field_type, field_type,
values: values ?? null, values: values ?? null,
@@ -37,5 +48,94 @@ export function createCustomFieldsRouter(db: Db): Hono {
return c.json(cf, 201); return c.json(cf, 201);
}); });
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.customFields.findFirst({
where: eq(customFields.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Custom field not found' });
}
const updateData: Partial<typeof customFields.$inferInsert> = {};
if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key));
if (body.name !== undefined) updateData.name = String(body.name);
if (body.field_type !== undefined) updateData.field_type = String(body.field_type);
if (body.values !== undefined) updateData.values = body.values ?? null;
if (body.max_values !== undefined) updateData.max_values = Number(body.max_values);
if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null;
const [updated] = await db.update(customFields)
.set(updateData)
.where(eq(customFields.id, id))
.returning();
return c.json(updated);
});
router.get('/queues/:queueId', async (c) => {
const queueId = c.req.param('queueId');
const assignments = await db.query.queueCustomFields.findMany({
where: eq(queueCustomFields.queue_id, queueId),
orderBy: asc(queueCustomFields.sort_order),
});
const fieldIds = assignments.map((assignment) => assignment.custom_field_id);
const fields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldMap = new Map(fields.map((field) => [field.id, field]));
return c.json(assignments.map((assignment) => ({
...assignment,
custom_field: fieldMap.get(assignment.custom_field_id) ?? null,
})));
});
router.post('/queues/:queueId', async (c) => {
const queueId = c.req.param('queueId');
const body = await c.req.json();
const customFieldId = body.custom_field_id;
if (!customFieldId) {
throw new HTTPException(400, { message: 'custom_field_id is required' });
}
const [assignment] = await db.insert(queueCustomFields).values({
queue_id: queueId,
custom_field_id: customFieldId,
sort_order: Number(body.sort_order ?? 0),
}).onConflictDoNothing().returning();
if (assignment) {
return c.json(assignment, 201);
}
const existing = await db.query.queueCustomFields.findFirst({
where: and(
eq(queueCustomFields.queue_id, queueId),
eq(queueCustomFields.custom_field_id, customFieldId),
),
});
return c.json(existing, 200);
});
router.delete('/queues/:queueId/:fieldId', async (c) => {
const queueId = c.req.param('queueId');
const fieldId = c.req.param('fieldId');
await db.delete(queueCustomFields).where(and(
eq(queueCustomFields.queue_id, queueId),
eq(queueCustomFields.custom_field_id, fieldId),
));
return c.json({ ok: true });
});
return router; return router;
} }

386
src/routes/dashboards.ts Normal file
View File

@@ -0,0 +1,386 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import {
dashboards,
dashboardWidgets,
tickets,
customFieldValues,
customFields,
lifecycles,
queues,
views,
} from '../db/schema.ts';
function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string {
if (def.statuses.initial.includes(status)) return 'initial';
if (def.statuses.active.includes(status)) return 'active';
if (def.statuses.inactive.includes(status)) return 'inactive';
return 'unknown';
}
export function createDashboardsRouter(db: Db): Hono {
const router = new Hono();
// ── Dashboards CRUD ──
router.get('/', async (c) => {
const result = await db.query.dashboards.findMany({
orderBy: asc(dashboards.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) {
throw new HTTPException(400, { message: 'name is required' });
}
const [dashboard] = await db.insert(dashboards).values({
name,
description: body.description ?? null,
team_id: body.team_id || null,
layout: body.layout ?? [],
is_default: body.is_default ?? false,
}).returning();
if (!dashboard) {
throw new HTTPException(500, { message: 'Failed to create dashboard' });
}
return c.json(dashboard, 201);
});
router.get('/:id', async (c) => {
const id = c.req.param('id');
const dashboard = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const widgets = await db.query.dashboardWidgets.findMany({
where: eq(dashboardWidgets.dashboard_id, id),
orderBy: asc(dashboardWidgets.created_at),
});
return c.json({ ...dashboard, widgets });
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const updateData: Partial<typeof dashboards.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.description !== undefined) updateData.description = body.description ?? null;
if (body.layout !== undefined) updateData.layout = body.layout;
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
if (body.is_default !== undefined) {
updateData.is_default = body.is_default;
if (body.is_default) {
await db.update(dashboards)
.set({ is_default: false })
.where(eq(dashboards.is_default, true));
}
}
const [updated] = await db.update(dashboards)
.set(updateData)
.where(eq(dashboards.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.dashboards.findFirst({
where: eq(dashboards.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
await db.delete(dashboards).where(eq(dashboards.id, id));
return c.json({ ok: true });
});
// ── Widgets CRUD ──
router.get('/:id/widgets', async (c) => {
const dashboardId = c.req.param('id');
const result = await db.query.dashboardWidgets.findMany({
where: eq(dashboardWidgets.dashboard_id, dashboardId),
orderBy: asc(dashboardWidgets.created_at),
});
return c.json(result);
});
router.post('/:id/widgets', async (c) => {
const dashboardId = c.req.param('id');
const dashboard = await db.query.dashboards.findFirst({
where: eq(dashboards.id, dashboardId),
});
if (!dashboard) {
throw new HTTPException(404, { message: 'Dashboard not found' });
}
const body = await c.req.json();
const title = String(body.title ?? 'Widget').trim();
const widgetType = String(body.widget_type ?? 'count').trim();
const viewId = String(body.view_id ?? '').trim();
if (!viewId) {
throw new HTTPException(400, { message: 'view_id is required' });
}
const [widget] = await db.insert(dashboardWidgets).values({
dashboard_id: dashboardId,
view_id: viewId,
title,
widget_type: widgetType,
position: body.position ?? { x: 0, y: 0, w: 4, h: 2 },
config: body.config ?? {},
}).returning();
if (!widget) {
throw new HTTPException(500, { message: 'Failed to create widget' });
}
return c.json(widget, 201);
});
router.patch('/:id/widgets/:widgetId', async (c) => {
const widgetId = c.req.param('widgetId');
const body = await c.req.json();
const existing = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!existing) {
throw new HTTPException(404, { message: 'Widget not found' });
}
const updateData: Partial<typeof dashboardWidgets.$inferInsert> = {};
if (body.title !== undefined) updateData.title = String(body.title).trim();
if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type);
if (body.position !== undefined) updateData.position = body.position;
if (body.config !== undefined) updateData.config = body.config;
const [updated] = await db.update(dashboardWidgets)
.set(updateData)
.where(eq(dashboardWidgets.id, widgetId))
.returning();
return c.json(updated);
});
router.delete('/:id/widgets/:widgetId', async (c) => {
const widgetId = c.req.param('widgetId');
const existing = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!existing) {
throw new HTTPException(404, { message: 'Widget not found' });
}
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId));
return c.json({ ok: true });
});
// ── Widget data endpoint ──
router.get('/:id/widgets/:widgetId/data', async (c) => {
const widgetId = c.req.param('widgetId');
const widget = await db.query.dashboardWidgets.findFirst({
where: eq(dashboardWidgets.id, widgetId),
});
if (!widget) {
throw new HTTPException(404, { message: 'Widget not found' });
}
const view = await db.query.views.findFirst({
where: eq(views.id, widget.view_id),
});
if (!view) {
return c.json({ error: 'View not found' }, 404);
}
// Apply saved view filters
const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[];
let result = await db.query.tickets.findMany({
orderBy: asc(tickets.created_at),
});
for (const f of savedFilters) {
if (f.field === 'status') {
result = result.filter((t) => t.status === f.value);
} else if (f.field === 'queue') {
result = result.filter((t) => t.queue_id === f.value);
} else if (f.field === 'owner') {
result = f.value === 'unassigned'
? result.filter((t) => !t.owner_id)
: result.filter((t) => t.owner_id === f.value);
} else if (f.field.startsWith('cf.')) {
const cfKey = f.field.slice(3);
const ticketIds = result.map((t) => t.id);
if (ticketIds.length > 0) {
const cfValues = await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.value, f.value),
),
});
const matchingIds = new Set(cfValues.map((v) => v.ticket_id));
// Also find the field ID for the key
const cfField = await db.query.customFields.findFirst({
where: eq(customFields.key, cfKey),
});
if (cfField) {
const cfValuesForField = await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.custom_field_id, cfField.id),
eq(table.value, f.value),
),
});
const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id));
result = result.filter((t) => matchSet.has(t.id));
} else {
result = result.filter((t) => matchingIds.has(t.id));
}
}
}
}
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
// Find lifecycle for status classification
const queueIds = [...new Set(result.map((r) => r.queue_id))];
const queueRecords = queueIds.length > 0
? await db.query.queues.findMany({
where: (table, { inArray }) => inArray(table.id, queueIds),
})
: [];
const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[];
const lifecycleRecords = lifecycleIds.length > 0
? await db.query.lifecycles.findMany({
where: (table, { inArray }) => inArray(table.id, lifecycleIds),
})
: [];
const lifecycleByQueue = new Map<string, { statuses: { initial: string[]; active: string[]; inactive: string[] } }>();
for (const qr of queueRecords) {
if (qr.lifecycle_id) {
const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id);
if (lc) lifecycleByQueue.set(qr.id, lc.definition as any);
}
}
// Get owner usernames
const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[];
const ownerUsers = ownerIds.length > 0
? await db.query.users.findMany({
where: (table, { inArray }) => inArray(table.id, ownerIds),
})
: [];
const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username]));
// Get queue names
const queueName = new Map(queueRecords.map((q) => [q.id, q.name]));
switch (widget.widget_type) {
case 'count': {
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
}
case 'ticket_list': {
const slice = result.slice(0, limit).map((ticket) => ({
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
owner_id: ticket.owner_id,
owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null,
queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8),
updated_at: ticket.updated_at?.toISOString(),
}));
return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id });
}
case 'status_chart': {
const counts: Record<string, number> = {};
for (const ticket of result) {
counts[ticket.status] = (counts[ticket.status] ?? 0) + 1;
}
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
}
case 'grouped_counts': {
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
const groups: Record<string, number> = {};
if (groupBy === 'owner') {
for (const ticket of result) {
const label = ticket.owner_id
? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8))
: 'Unassigned';
groups[label] = (groups[label] ?? 0) + 1;
}
} else if (groupBy === 'queue') {
for (const ticket of result) {
const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8);
groups[label] = (groups[label] ?? 0) + 1;
}
} else if (groupBy.startsWith('cf.')) {
const cfKey = groupBy.slice(3);
const cfField = await db.query.customFields.findFirst({
where: eq(customFields.key, cfKey),
});
if (cfField) {
const ticketIds = result.map((t) => t.id);
const cfValues = ticketIds.length > 0
? await db.query.customFieldValues.findMany({
where: (table, { and, inArray, eq }) =>
and(
inArray(table.ticket_id, ticketIds),
eq(table.custom_field_id, cfField.id),
),
})
: [];
for (const v of cfValues) {
groups[v.value] = (groups[v.value] ?? 0) + 1;
}
}
}
return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id });
}
default:
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
}
});
return router;
}

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { lifecycles } from '../db/schema.ts'; import { lifecycles } from '../db/schema.ts';
import { asc } from 'drizzle-orm'; import { asc, eq } from 'drizzle-orm';
export function createLifecyclesRouter(db: Db): Hono { export function createLifecyclesRouter(db: Db): Hono {
const router = new Hono(); const router = new Hono();
@@ -34,5 +34,29 @@ export function createLifecyclesRouter(db: Db): Hono {
return c.json(lifecycle, 201); return c.json(lifecycle, 201);
}); });
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Lifecycle not found' });
}
const updateData: Partial<typeof lifecycles.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name);
if (body.definition !== undefined) updateData.definition = body.definition;
const [updated] = await db.update(lifecycles)
.set(updateData)
.where(eq(lifecycles.id, id))
.returning();
return c.json(updated);
});
return router; return router;
} }

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { queues } from '../db/schema.ts'; import { queues } from '../db/schema.ts';
import { asc } from 'drizzle-orm'; import { asc, eq } from 'drizzle-orm';
import { CreateQueueSchema } from '../models/queue.ts'; import { CreateQueueSchema } from '../models/queue.ts';
export function createQueuesRouter(db: Db): Hono { export function createQueuesRouter(db: Db): Hono {
@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
name: parsed.name, name: parsed.name,
description: parsed.description ?? null, description: parsed.description ?? null,
lifecycle_id: parsed.lifecycle_id ?? null, lifecycle_id: parsed.lifecycle_id ?? null,
team_id: parsed.team_id ?? null,
}).returning(); }).returning();
if (!queue) { if (!queue) {
@@ -32,5 +33,31 @@ export function createQueuesRouter(db: Db): Hono {
return c.json(queue, 201); return c.json(queue, 201);
}); });
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.queues.findFirst({
where: eq(queues.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Queue not found' });
}
const updateData: Partial<typeof queues.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name);
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null;
if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null;
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
const [updated] = await db.update(queues)
.set(updateData)
.where(eq(queues.id, id))
.returning();
return c.json(updated);
});
return router; return router;
} }

98
src/routes/teams.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { and, asc, eq, inArray } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { teams, teamMembers, users, dashboards } from '../db/schema.ts';
export function createTeamsRouter(db: Db): Hono {
const router = new Hono();
// GET /teams — list all with member details
router.get('/', async (c) => {
const allTeams = await db.query.teams.findMany({
orderBy: asc(teams.name),
});
const result = await Promise.all(allTeams.map(async (team) => {
const members = await db.query.teamMembers.findMany({
where: eq(teamMembers.team_id, team.id),
});
const memberUsers = members.length > 0
? await db.query.users.findMany({
where: (table, { inArray }) => inArray(table.id, members.map((m) => m.user_id)),
})
: [];
return { ...team, members: memberUsers };
}));
return c.json(result);
});
// POST /teams
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) throw new HTTPException(400, { message: 'name is required' });
const [team] = await db.insert(teams).values({
name,
description: body.description ?? null,
}).returning();
if (!team) throw new HTTPException(500, { message: 'Failed to create team' });
return c.json(team, 201);
});
// PATCH /teams/:id
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
const updateData: Partial<typeof teams.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.description !== undefined) updateData.description = body.description ?? null;
const [updated] = await db.update(teams).set(updateData).where(eq(teams.id, id)).returning();
return c.json(updated);
});
// DELETE /teams/:id
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
await db.delete(teams).where(eq(teams.id, id));
return c.json({ ok: true });
});
// POST /teams/:id/members — add member
router.post('/:id/members', async (c) => {
const teamId = c.req.param('id');
const body = await c.req.json();
const userId = String(body.user_id ?? '').trim();
if (!userId) throw new HTTPException(400, { message: 'user_id is required' });
const team = await db.query.teams.findFirst({ where: eq(teams.id, teamId) });
if (!team) throw new HTTPException(404, { message: 'Team not found' });
const [member] = await db.insert(teamMembers).values({
team_id: teamId,
user_id: userId,
}).returning();
if (!member) throw new HTTPException(500, { message: 'Failed to add member' });
return c.json(member, 201);
});
// DELETE /teams/:id/members/:userId
router.delete('/:id/members/:userId', async (c) => {
const teamId = c.req.param('id');
const userId = c.req.param('userId');
await db.delete(teamMembers).where(
and(eq(teamMembers.team_id, teamId), eq(teamMembers.user_id, userId))
);
return c.json({ ok: true });
});
return router;
}

189
src/routes/templates.ts Normal file
View File

@@ -0,0 +1,189 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, desc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { customFieldValues, queues, templates, tickets, transactions } from '../db/schema.ts';
import { TemplateRenderer } from '../scrip/templates.ts';
import type { TemplateContext } from '../scrip/templates.ts';
function buildDemoContext(): TemplateContext {
return {
ticket: {
id: 1001,
subject: 'Replace access badge reader',
status: 'open',
queue_id: 'demo-queue',
owner_id: null,
creator_id: 'demo-user',
created_at: new Date('2026-06-08T08:00:00.000Z').toISOString(),
updated_at: new Date('2026-06-08T09:15:00.000Z').toISOString(),
},
queue: { name: 'Support Desk' },
transaction: {
type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
},
custom_fields: {
impact: 'High',
location: 'HQ 2nd floor',
channel: 'Portal',
},
};
}
async function buildTicketContext(db: Db, ticketId: number): Promise<TemplateContext> {
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const queue = await db.query.queues.findFirst({
where: eq(queues.id, ticket.queue_id),
});
const latestTx = await db.query.transactions.findFirst({
where: eq(transactions.ticket_id, ticket.id),
orderBy: desc(transactions.created_at),
});
const cfValues = await db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, ticket.id),
});
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
const fields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldById = new Map(fields.map((field) => [field.id, field]));
const customFieldsMap: Record<string, string> = {};
for (const value of cfValues) {
const field = fieldById.get(value.custom_field_id);
if (field) customFieldsMap[field.key] = value.value;
}
return {
ticket: {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
queue_id: ticket.queue_id,
owner_id: ticket.owner_id,
creator_id: ticket.creator_id,
created_at: ticket.created_at?.toISOString() ?? new Date().toISOString(),
updated_at: ticket.updated_at?.toISOString() ?? new Date().toISOString(),
},
queue: {
name: queue?.name ?? 'unknown',
},
transaction: {
type: latestTx?.transaction_type ?? 'Preview',
field: latestTx?.field ?? null,
old_value: latestTx?.old_value ?? null,
new_value: latestTx?.new_value ?? null,
},
custom_fields: customFieldsMap,
};
}
export function createTemplatesRouter(db: Db): Hono {
const router = new Hono();
const renderer = new TemplateRenderer();
router.get('/', async (c) => {
const result = await db.query.templates.findMany({
orderBy: asc(templates.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
const subjectTemplate = String(body.subject_template ?? '');
const bodyTemplate = String(body.body_template ?? '');
if (!name || !subjectTemplate || !bodyTemplate) {
throw new HTTPException(400, { message: 'name, subject_template, and body_template are required' });
}
const [template] = await db.insert(templates).values({
name,
queue_id: body.queue_id || null,
subject_template: subjectTemplate,
body_template: bodyTemplate,
}).returning();
if (!template) {
throw new HTTPException(500, { message: 'Failed to create template' });
}
return c.json(template, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.templates.findFirst({
where: eq(templates.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Template not found' });
}
const updateData: Partial<typeof templates.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.queue_id !== undefined) updateData.queue_id = body.queue_id || null;
if (body.subject_template !== undefined) updateData.subject_template = String(body.subject_template);
if (body.body_template !== undefined) updateData.body_template = String(body.body_template);
const [updated] = await db.update(templates)
.set(updateData)
.where(eq(templates.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.templates.findFirst({
where: eq(templates.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Template not found' });
}
await db.delete(templates).where(eq(templates.id, id));
return c.json({ ok: true });
});
router.post('/preview', async (c) => {
const body = await c.req.json();
const subjectTemplate = String(body.subject_template ?? '');
const bodyTemplate = String(body.body_template ?? '');
const ticketId = body.ticket_id === undefined || body.ticket_id === null || body.ticket_id === ''
? null
: Number(body.ticket_id);
if (!subjectTemplate || !bodyTemplate) {
throw new HTTPException(400, { message: 'subject_template and body_template are required' });
}
const context = ticketId ? await buildTicketContext(db, ticketId) : buildDemoContext();
return c.json({
...renderer.render(subjectTemplate, bodyTemplate, context),
context,
});
});
return router;
}

View File

@@ -1,9 +1,9 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts'; import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts';
import { eq, asc } from 'drizzle-orm'; import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
import { CreateTicketSchema, UpdateTicketSchema } from '../models/ticket.ts'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
import { ScripEngine } from '../scrip/engine.ts'; import { ScripEngine } from '../scrip/engine.ts';
import { LifecycleValidator } from '../lifecycle/validator.ts'; import { LifecycleValidator } from '../lifecycle/validator.ts';
import type { LifecycleDefinition } from '../lifecycle/validator.ts'; import type { LifecycleDefinition } from '../lifecycle/validator.ts';
@@ -13,55 +13,269 @@ export function createTicketsRouter(db: Db): Hono {
const scripEngine = new ScripEngine(db); const scripEngine = new ScripEngine(db);
const lifecycleValidator = new LifecycleValidator(); const lifecycleValidator = new LifecycleValidator();
function statusClass(def: LifecycleDefinition, status: string): 'initial' | 'active' | 'inactive' | 'unknown' {
if (def.statuses.initial.includes(status)) return 'initial';
if (def.statuses.active.includes(status)) return 'active';
if (def.statuses.inactive.includes(status)) return 'inactive';
return 'unknown';
}
// GET / — list tickets // GET / — list tickets
router.get('/', async (c) => { router.get('/', async (c) => {
const params = new URL(c.req.url).searchParams;
const queueId = c.req.query('queue_id'); const queueId = c.req.query('queue_id');
const status = c.req.query('status'); const status = c.req.query('status');
const ownerId = c.req.query('owner_id');
const teamId = c.req.query('team_id');
const query = c.req.query('q')?.trim() ?? '';
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
const cfFilters = [...params.entries()]
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
.map(([key, value]) => ({
key: key.slice(3),
value: value.trim(),
}));
// Build SQL WHERE conditions
const conditions: ReturnType<typeof eq>[] = [];
if (queueId) {
conditions.push(eq(tickets.queue_id, queueId));
}
if (status) {
conditions.push(eq(tickets.status, status));
}
if (ownerId) {
conditions.push(
ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId)
);
}
if (teamId) {
// Resolve team members and filter tickets by those owner_ids
const members = await db.query.teamMembers.findMany({
where: eq(teamMembers.team_id, teamId),
});
const memberIds = members.map((m) => m.user_id);
if (memberIds.length > 0) {
conditions.push(inArray(tickets.owner_id, memberIds));
} else {
conditions.push(isNull(tickets.owner_id)); // empty team = no results
}
}
// Text search: push to SQL via ilike on ticket columns + queue name join
if (query) {
const pattern = `%${query}%`;
conditions.push(
or(
ilike(tickets.subject, pattern),
ilike(tickets.status, pattern),
sql`${tickets.id}::text ILIKE ${pattern}`
)!
);
// Queue name search requires join — keep as post-filter
}
// Custom field filters: use EXISTS subquery
for (const cf of cfFilters) {
conditions.push(
exists(
db.select({ n: sql`1` })
.from(customFieldValues)
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
.where(
and(
eq(customFieldValues.ticket_id, tickets.id),
eq(customFields.key, cf.key),
eq(customFieldValues.value, cf.value)
)
)
)
);
}
const result = await db.query.tickets.findMany({ const result = await db.query.tickets.findMany({
where: (t, { and, eq }) => { where: conditions.length > 0 ? and(...conditions) : undefined,
const conditions = [];
if (queueId) conditions.push(eq(t.queue_id, queueId));
if (status) conditions.push(eq(t.status, status));
return conditions.length > 0 ? and(...conditions) : undefined;
},
orderBy: asc(tickets.created_at), orderBy: asc(tickets.created_at),
limit,
}); });
return c.json(result); // Post-filter for queue name text search (requires in-memory join)
let filtered = result;
if (query) {
const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
filtered = result.filter((ticket) =>
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
);
}
// Attach custom field values to all tickets
if (filtered.length > 0) {
const ticketIds = filtered.map((t) => t.id);
const allCfValues = await db.query.customFieldValues.findMany({
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
});
const fieldIds = [...new Set(allCfValues.map((v) => v.custom_field_id))];
const allFields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldMap = new Map(allFields.map((f) => [f.id, f]));
const ticketsWithCf = filtered.map((ticket) => {
const cfs = allCfValues
.filter((v) => v.ticket_id === ticket.id)
.map((v) => ({
id: v.id,
custom_field_id: v.custom_field_id,
ticket_id: v.ticket_id,
value: v.value,
created_at: v.created_at?.toISOString(),
custom_field: fieldMap.has(v.custom_field_id) ? {
id: v.custom_field_id,
key: fieldMap.get(v.custom_field_id)!.key,
name: fieldMap.get(v.custom_field_id)!.name,
field_type: fieldMap.get(v.custom_field_id)!.field_type,
values: fieldMap.get(v.custom_field_id)!.values,
max_values: fieldMap.get(v.custom_field_id)!.max_values,
pattern: fieldMap.get(v.custom_field_id)!.pattern,
} : undefined,
}));
return { ...ticket, custom_fields: cfs };
});
return c.json(ticketsWithCf);
}
return c.json(filtered);
}); });
// POST / — create ticket // POST / — create ticket
router.post('/', async (c) => { router.post('/', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const parsed = CreateTicketSchema.parse(body); const parsed = CreateTicketSchema.parse(body);
const creatorId = '00000000-0000-0000-0000-000000000000';
const customFieldInput = parsed.custom_fields ?? {};
const customFieldEntries = Object.entries(customFieldInput)
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
.filter(([, value]) => value);
const queue = await db.query.queues.findFirst({
where: eq(queues.id, parsed.queue_id),
});
if (!queue) {
throw new HTTPException(422, { message: 'Queue not found' });
}
let initialStatus = 'new';
if (queue.lifecycle_id) {
const lifecycle = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, queue.lifecycle_id),
});
const definition = lifecycle?.definition as LifecycleDefinition | undefined;
initialStatus = definition?.statuses.initial[0] ?? initialStatus;
}
let assignedFields: typeof customFields.$inferSelect[] = [];
if (customFieldEntries.length > 0) {
const assignments = await db.query.queueCustomFields.findMany({
where: eq(queueCustomFields.queue_id, parsed.queue_id),
});
const assignedIds = new Set(assignments.map((assignment) => assignment.custom_field_id));
const requestedIds = customFieldEntries.map(([fieldId]) => fieldId);
for (const fieldId of requestedIds) {
if (!assignedIds.has(fieldId)) {
throw new HTTPException(422, { message: 'Custom field is not assigned to this queue' });
}
}
assignedFields = await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, requestedIds),
});
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
for (const [fieldId, value] of customFieldEntries) {
const field = fieldById.get(fieldId);
if (!field) {
throw new HTTPException(422, { message: 'Custom field not found' });
}
if (Array.isArray(field.values) && field.values.length > 0) {
const allowed = new Set(field.values.map((option) => String(option)));
if (!allowed.has(value)) {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
}
}
if (field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
}
}
}
}
const [ticket] = await db.insert(tickets).values({ const [ticket] = await db.insert(tickets).values({
subject: parsed.subject, subject: parsed.subject,
queue_id: parsed.queue_id, queue_id: parsed.queue_id,
status: 'new', status: initialStatus,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: creatorId,
team_id: (queue as any).team_id ?? null,
}).returning(); }).returning();
if (!ticket) { if (!ticket) {
throw new HTTPException(500, { message: 'Failed to create ticket' }); throw new HTTPException(500, { message: 'Failed to create ticket' });
} }
// Record transaction const txList = [
await db.insert(transactions).values({ {
ticket_id: ticket.id, ticket_id: ticket.id,
transaction_type: 'Create', transaction_type: 'Create',
field: 'status', field: 'status',
new_value: 'new', new_value: initialStatus,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: creatorId,
}); },
];
return c.json(ticket, 201); if (parsed.description) {
txList.push({
ticket_id: ticket.id,
transaction_type: 'Correspond',
field: null,
new_value: null,
data: { body: parsed.description },
creator_id: creatorId,
} as any);
}
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
for (const [fieldId, value] of customFieldEntries) {
await db.insert(customFieldValues).values({
ticket_id: ticket.id,
custom_field_id: fieldId,
value,
});
txList.push({
ticket_id: ticket.id,
transaction_type: 'CustomFieldChange',
field: fieldById.get(fieldId)?.key ?? fieldId,
new_value: value,
creator_id: creatorId,
} as any);
}
const createdTransactions = await db.insert(transactions).values(txList as any).returning();
const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any);
const results = await scripEngine.commit(prepared);
return c.json({ ticket, scrip_results: results }, 201);
}); });
// GET /:id — get ticket with custom field values // GET /:id — get ticket with custom field values
router.get('/:id', async (c) => { router.get('/:id', async (c) => {
const id = c.req.param('id'); const id = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({ const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id), where: eq(tickets.id, id),
@@ -92,7 +306,7 @@ export function createTicketsRouter(db: Db): Hono {
// PATCH /:id — update ticket // PATCH /:id — update ticket
router.patch('/:id', async (c) => { router.patch('/:id', async (c) => {
const id = c.req.param('id'); const id = Number(c.req.param('id'));
const body = await c.req.json(); const body = await c.req.json();
const parsed = UpdateTicketSchema.parse(body); const parsed = UpdateTicketSchema.parse(body);
@@ -104,6 +318,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
let lifecycleDef: LifecycleDefinition | null = null;
// Validate lifecycle transition if status is changing // Validate lifecycle transition if status is changing
if (parsed.status) { if (parsed.status) {
const queue = await db.query.queues.findFirst({ const queue = await db.query.queues.findFirst({
@@ -116,8 +332,8 @@ export function createTicketsRouter(db: Db): Hono {
}); });
if (lifecycle) { if (lifecycle) {
const def = lifecycle.definition as LifecycleDefinition; lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status); const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
if (!result.valid) { if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' }); throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
} }
@@ -149,7 +365,7 @@ export function createTicketsRouter(db: Db): Hono {
}); });
} }
if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) { if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
txList.push({ txList.push({
ticket_id: id, ticket_id: id,
transaction_type: 'SetOwner' as const, transaction_type: 'SetOwner' as const,
@@ -160,11 +376,43 @@ export function createTicketsRouter(db: Db): Hono {
}); });
} }
if (parsed.team_id !== undefined && parsed.team_id !== (ticket as any).team_id) {
txList.push({
ticket_id: id,
transaction_type: 'SetTeam' as const,
field: 'team_id',
old_value: (ticket as any).team_id ?? null,
new_value: parsed.team_id,
creator_id: '00000000-0000-0000-0000-000000000000',
});
}
// Update the ticket // Update the ticket
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (parsed.subject) updateData.subject = parsed.subject; if (parsed.subject) updateData.subject = parsed.subject;
if (parsed.status) updateData.status = parsed.status; if (parsed.status) {
if (parsed.owner_id) updateData.owner_id = parsed.owner_id; updateData.status = parsed.status;
if (lifecycleDef && parsed.status !== ticket.status) {
const fromClass = statusClass(lifecycleDef, ticket.status);
const toClass = statusClass(lifecycleDef, parsed.status);
const now = new Date();
if (fromClass === 'initial' && (toClass === 'active' || toClass === 'inactive') && !ticket.started_at) {
updateData.started_at = now;
}
if ((fromClass === 'initial' || fromClass === 'active') && toClass === 'inactive') {
updateData.resolved_at = now;
}
if (fromClass === 'inactive' && toClass !== 'inactive') {
updateData.resolved_at = null;
}
}
}
if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id;
if (parsed.team_id !== undefined) updateData.team_id = parsed.team_id;
updateData.updated_at = new Date(); updateData.updated_at = new Date();
const [updated] = await db.update(tickets) const [updated] = await db.update(tickets)
@@ -186,7 +434,7 @@ export function createTicketsRouter(db: Db): Hono {
// POST /:id/preview — dry-run scrips // POST /:id/preview — dry-run scrips
router.post('/:id/preview', async (c) => { router.post('/:id/preview', async (c) => {
const id = c.req.param('id'); const id = Number(c.req.param('id'));
const body = await c.req.json(); const body = await c.req.json();
const parsed = UpdateTicketSchema.parse(body); const parsed = UpdateTicketSchema.parse(body);
@@ -198,6 +446,26 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
if (parsed.status) {
const queue = await db.query.queues.findFirst({
where: eq(queues.id, ticket.queue_id),
});
if (queue?.lifecycle_id) {
const lifecycle = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, queue.lifecycle_id),
});
if (lifecycle) {
const lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
}
}
}
}
const txList: any[] = []; const txList: any[] = [];
if (parsed.status && parsed.status !== ticket.status) { if (parsed.status && parsed.status !== ticket.status) {
@@ -221,7 +489,7 @@ export function createTicketsRouter(db: Db): Hono {
// GET /:id/transactions — list transactions for ticket // GET /:id/transactions — list transactions for ticket
router.get('/:id/transactions', async (c) => { router.get('/:id/transactions', async (c) => {
const id = c.req.param('id'); const id = Number(c.req.param('id'));
const result = await db.query.transactions.findMany({ const result = await db.query.transactions.findMany({
where: eq(transactions.ticket_id, id), where: eq(transactions.ticket_id, id),
@@ -231,5 +499,128 @@ export function createTicketsRouter(db: Db): Hono {
return c.json(result); return c.json(result);
}); });
// POST /:id/comment — add a comment (reply or internal note)
router.post('/:id/comment', async (c) => {
const id = Number(c.req.param('id'));
const body = await c.req.json();
const parsed = CommentSchema.parse(body);
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
const [tx] = await db.insert(transactions).values({
ticket_id: id,
transaction_type: transactionType,
data: { body: parsed.body },
creator_id: parsed.creator_id,
}).returning();
if (!tx) {
throw new HTTPException(500, { message: 'Failed to create comment' });
}
// Run scrips
const txList = [tx];
const prepared = await scripEngine.prepare(id, txList as any);
await scripEngine.commit(prepared);
return c.json(tx, 201);
});
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
router.patch('/:id/custom-fields/:fieldId', async (c) => {
const id = Number(c.req.param('id'));
const fieldId = c.req.param('fieldId');
const body = await c.req.json();
const value = typeof body.value === 'string' ? body.value.trim() : '';
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const assignment = await db.query.queueCustomFields.findFirst({
where: and(
eq(queueCustomFields.queue_id, ticket.queue_id),
eq(queueCustomFields.custom_field_id, fieldId),
),
});
if (!assignment) {
throw new HTTPException(422, { message: 'Custom field is not assigned to this ticket queue' });
}
const field = await db.query.customFields.findFirst({
where: eq(customFields.id, fieldId),
});
if (!field) {
throw new HTTPException(404, { message: 'Custom field not found' });
}
if (value && Array.isArray(field.values) && field.values.length > 0) {
const allowed = new Set(field.values.map((option) => String(option)));
if (!allowed.has(value)) {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
}
}
if (value && field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
}
}
const existing = await db.query.customFieldValues.findMany({
where: and(
eq(customFieldValues.ticket_id, id),
eq(customFieldValues.custom_field_id, fieldId),
),
});
const oldValue = existing.map((item) => item.value).join(', ');
await db.delete(customFieldValues).where(and(
eq(customFieldValues.ticket_id, id),
eq(customFieldValues.custom_field_id, fieldId),
));
if (value) {
await db.insert(customFieldValues).values({
ticket_id: id,
custom_field_id: fieldId,
value,
});
}
await db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, id));
const [tx] = await db.insert(transactions).values({
ticket_id: id,
transaction_type: 'CustomFieldChange',
field: field.key,
old_value: oldValue || null,
new_value: value || null,
creator_id: '00000000-0000-0000-0000-000000000000',
}).returning();
const prepared = await scripEngine.prepare(id, [tx] as any);
await scripEngine.commit(prepared);
return c.json(tx, 200);
});
return router; return router;
} }

78
src/routes/users.ts Normal file
View File

@@ -0,0 +1,78 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { users } from '../db/schema.ts';
export function createUsersRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.users.findMany({
orderBy: asc(users.username),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const username = String(body.username ?? '').trim();
const email = body.email ? String(body.email).trim() : null;
if (!username) {
throw new HTTPException(400, { message: 'username is required' });
}
const [user] = await db.insert(users).values({
username,
email,
}).returning();
if (!user) {
throw new HTTPException(500, { message: 'Failed to create user' });
}
return c.json(user, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
const updateData: Partial<typeof users.$inferInsert> = {};
if (body.username !== undefined) updateData.username = String(body.username).trim();
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
const [updated] = await db.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
await db.delete(users).where(eq(users.id, id));
return c.json({ ok: true });
});
return router;
}

84
src/routes/views.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { views } from '../db/schema.ts';
export function createViewsRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.views.findMany({
orderBy: asc(views.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) {
throw new HTTPException(400, { message: 'name is required' });
}
const [view] = await db.insert(views).values({
name,
filters: body.filters ?? [],
sort_key: body.sort_key ?? 'updated',
columns: body.columns ?? [],
is_public: body.is_public ?? false,
creator_id: body.creator_id || null,
}).returning();
if (!view) {
throw new HTTPException(500, { message: 'Failed to create view' });
}
return c.json(view, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.views.findFirst({
where: eq(views.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'View not found' });
}
const updateData: Partial<typeof views.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.filters !== undefined) updateData.filters = body.filters;
if (body.sort_key !== undefined) updateData.sort_key = body.sort_key;
if (body.columns !== undefined) updateData.columns = body.columns;
if (body.is_public !== undefined) updateData.is_public = body.is_public;
const [updated] = await db.update(views)
.set(updateData)
.where(eq(views.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.views.findFirst({
where: eq(views.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'View not found' });
}
await db.delete(views).where(eq(views.id, id));
return c.json({ ok: true });
});
return router;
}

View File

@@ -1,8 +1,11 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer'; import type { Transporter } from 'nodemailer';
import Handlebars from 'handlebars';
import { config } from '../config.ts'; import { config } from '../config.ts';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { customFieldValues, transactions } from '../db/schema.ts'; import * as schema from '../db/schema.ts';
import { customFieldValues, tickets, transactions, users } from '../db/schema.ts';
import { and, eq, inArray } from 'drizzle-orm';
export interface ActionExecutor { export interface ActionExecutor {
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>; execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
@@ -13,7 +16,7 @@ export interface ActionPayload {
scripName: string; scripName: string;
actionType: string; actionType: string;
actionConfig: Record<string, unknown>; actionConfig: Record<string, unknown>;
ticketId?: string; ticketId?: number;
recipients?: string[]; recipients?: string[];
subject?: string; subject?: string;
body?: string; body?: string;
@@ -21,12 +24,15 @@ export interface ActionPayload {
method?: string; method?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
field_id?: string; field_id?: string;
field_key?: string;
value?: string; value?: string;
} }
export class SendEmail implements ActionExecutor { export class SendEmail implements ActionExecutor {
private transporter: Transporter | null = null; private transporter: Transporter | null = null;
constructor(private db: Db) {}
private getTransporter(): Transporter { private getTransporter(): Transporter {
if (!this.transporter) { if (!this.transporter) {
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
@@ -39,8 +45,55 @@ export class SendEmail implements ActionExecutor {
return this.transporter; return this.transporter;
} }
private async resolveRecipients(payload: ActionPayload): Promise<string[]> {
const configuredRecipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
const recipients = new Set(configuredRecipients.filter(Boolean));
const sources = payload.actionConfig['recipient_sources'] ?? payload.actionConfig['recipient_source'];
const recipientSources = Array.isArray(sources)
? sources.map((source) => String(source))
: sources
? [String(sources)]
: [];
if (recipientSources.length === 0 || !payload.ticketId) {
return Array.from(recipients);
}
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, payload.ticketId),
});
if (!ticket) {
return Array.from(recipients);
}
const userIds = new Set<string>();
for (const source of recipientSources) {
if (['requester', 'requestor', 'requestors', 'creator', 'ticket_creator'].includes(source)) {
userIds.add(ticket.creator_id);
}
if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) {
userIds.add(ticket.owner_id);
}
}
if (userIds.size === 0) {
return Array.from(recipients);
}
const rows = await this.db.query.users.findMany({
where: inArray(users.id, Array.from(userIds)),
});
for (const user of rows) {
if (user.email) recipients.add(user.email);
}
return Array.from(recipients);
}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> { async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const recipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? []; const recipients = await this.resolveRecipients(payload);
const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined); const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined); const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
@@ -91,25 +144,279 @@ export class Webhook implements ActionExecutor {
} }
} }
function parseResponseBody(text: string): unknown {
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function renderHandlebars(template: string, context: Record<string, unknown>): string {
const instance = Handlebars.create();
instance.registerHelper('json', (value: unknown) => JSON.stringify(value, null, 2));
return instance.compile(template)(context);
}
export class FetchMetadata implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
const rawUrl = String(payload.actionConfig['url'] ?? '');
const method = String(payload.actionConfig['method'] ?? 'GET').toUpperCase();
const headers = (payload.actionConfig['headers'] ?? {}) as Record<string, string>;
const requestBodyTemplate = String(payload.actionConfig['body'] ?? '');
const commentTemplate = String(
payload.actionConfig['comment_template'] ??
'External metadata\n\n{{json metadata}}',
);
const internal = payload.actionConfig['internal'] !== false;
if (!ticketId || !rawUrl) {
return { success: false, message: 'FetchMetadata: missing ticket_id or URL' };
}
try {
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
return { success: false, message: `FetchMetadata: ticket ${ticketId} not found` };
}
const baseContext = {
ticket: {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
queue_id: ticket.queue_id,
owner_id: ticket.owner_id,
creator_id: ticket.creator_id,
created_at: ticket.created_at?.toISOString(),
updated_at: ticket.updated_at?.toISOString(),
},
};
const url = renderHandlebars(rawUrl, baseContext);
const requestBody = requestBodyTemplate
? renderHandlebars(requestBodyTemplate, baseContext)
: undefined;
const response = await fetch(url, {
method,
headers: { Accept: 'application/json', ...headers },
body: ['GET', 'HEAD'].includes(method) ? undefined : requestBody,
});
const responseText = await response.text();
const metadata = parseResponseBody(responseText);
if (!response.ok) {
return { success: false, message: `FetchMetadata failed: HTTP ${response.status}` };
}
const commentBody = renderHandlebars(commentTemplate, {
...baseContext,
metadata,
response: {
status: response.status,
ok: response.ok,
body: metadata,
text: responseText,
},
});
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: internal ? 'Comment' : 'Correspond',
data: {
body: commentBody,
metadata,
source_url: url,
},
creator_id: '00000000-0000-0000-0000-000000000000',
});
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
return { success: true, message: `Metadata fetched and added to ticket ${ticketId}` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `FetchMetadata failed: ${message}` };
}
}
}
type ScriptResult = string | { success?: boolean; message?: string } | undefined | null;
export class RunScript implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const script = String(payload.actionConfig['script'] ?? payload.actionConfig['code'] ?? '');
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
if (!script.trim()) {
return { success: false, message: 'RunScript: no script configured' };
}
if (!ticketId) {
return { success: false, message: 'RunScript: missing ticket_id' };
}
try {
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
return { success: false, message: `RunScript: ticket ${ticketId} not found` };
}
const helpers = {
addComment: async (body: string, options?: { internal?: boolean; creator_id?: string }) => {
const [tx] = await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: options?.internal === false ? 'Correspond' : 'Comment',
data: { body },
creator_id: options?.creator_id ?? '00000000-0000-0000-0000-000000000000',
}).returning();
return tx;
},
createTransaction: async (data: {
transaction_type: string;
field?: string | null;
old_value?: string | null;
new_value?: string | null;
data?: unknown;
creator_id?: string;
}) => {
const [tx] = await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: data.transaction_type,
field: data.field ?? null,
old_value: data.old_value ?? null,
new_value: data.new_value ?? null,
data: data.data,
creator_id: data.creator_id ?? '00000000-0000-0000-0000-000000000000',
}).returning();
return tx;
},
updateTicket: async (data: Partial<typeof tickets.$inferInsert>) => {
const [updated] = await this.db.update(tickets)
.set({ ...data, updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId))
.returning();
return updated;
},
touchTicket: async () => {
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
},
};
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(
'context',
`"use strict";
const { ticket, payload, actionConfig, helpers, db, schema, orm, fetch, console } = context;
${script}`,
) as (context: Record<string, unknown>) => Promise<ScriptResult>;
const result = await fn({
ticket,
payload,
actionConfig: payload.actionConfig,
helpers,
db: this.db,
schema,
orm: { and, eq, inArray },
fetch,
console,
});
if (typeof result === 'string') {
return { success: true, message: result };
}
if (result && typeof result === 'object') {
return {
success: result.success !== false,
message: result.message ?? 'RunScript completed',
};
}
return { success: true, message: 'RunScript completed' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `RunScript failed: ${message}` };
}
}
}
export class SetCustomField implements ActionExecutor { export class SetCustomField implements ActionExecutor {
constructor(private db: Db) {} constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> { async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const fieldId = payload.field_id ?? String(payload.actionConfig['field_id'] ?? ''); const fieldRef =
payload.field_id ??
payload.field_key ??
String(payload.actionConfig['field_id'] ?? payload.actionConfig['field_key'] ?? payload.actionConfig['field'] ?? '');
const value = payload.value ?? String(payload.actionConfig['value'] ?? ''); const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
const ticketId = payload.ticketId ?? String(payload.actionConfig['ticket_id'] ?? ''); const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
if (!fieldId || !value || !ticketId) { if (!fieldRef || !value || !ticketId) {
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' }; return { success: false, message: 'SetCustomField: missing field reference, value, or ticket_id' };
} }
try { try {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(fieldRef);
const field = await this.db.query.customFields.findFirst({
where: (row, { or, eq }) =>
isUuid
? or(eq(row.id, fieldRef), eq(row.key, fieldRef), eq(row.name, fieldRef))
: or(eq(row.key, fieldRef), eq(row.name, fieldRef)),
});
if (!field) {
return { success: false, message: `SetCustomField: unknown field ${fieldRef}` };
}
const existing = await this.db.query.customFieldValues.findMany({
where: and(
eq(customFieldValues.ticket_id, ticketId),
eq(customFieldValues.custom_field_id, field.id),
),
});
const oldValue = existing.map((row) => row.value).join(', ');
await this.db.delete(customFieldValues).where(and(
eq(customFieldValues.ticket_id, ticketId),
eq(customFieldValues.custom_field_id, field.id),
));
await this.db.insert(customFieldValues).values({ await this.db.insert(customFieldValues).values({
custom_field_id: fieldId, custom_field_id: field.id,
ticket_id: ticketId, ticket_id: ticketId,
value, value,
}); });
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: 'CustomFieldChange',
field: field.key,
old_value: oldValue || null,
new_value: value,
creator_id: '00000000-0000-0000-0000-000000000000',
});
return { success: true, message: `${field.name} set to "${value}"` };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `SetCustomField failed: ${message}` }; return { success: false, message: `SetCustomField failed: ${message}` };
@@ -121,7 +428,7 @@ export class CreateTransaction implements ActionExecutor {
constructor(private db: Db) {} constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> { async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const ticketId = payload.ticketId ?? String(payload.actionConfig['ticket_id'] ?? ''); const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
const transactionType = String(payload.actionConfig['transaction_type'] ?? ''); const transactionType = String(payload.actionConfig['transaction_type'] ?? '');
const field = payload.actionConfig['field'] as string | undefined ?? null; const field = payload.actionConfig['field'] as string | undefined ?? null;
const oldValue = payload.actionConfig['old_value'] as string | undefined ?? null; const oldValue = payload.actionConfig['old_value'] as string | undefined ?? null;
@@ -150,8 +457,10 @@ export class CreateTransaction implements ActionExecutor {
export function createActionRegistry(db: Db): Record<string, ActionExecutor> { export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
return { return {
SendEmail: new SendEmail(), SendEmail: new SendEmail(db),
Webhook: new Webhook(), Webhook: new Webhook(),
FetchMetadata: new FetchMetadata(db),
RunScript: new RunScript(db),
SetCustomField: new SetCustomField(db), SetCustomField: new SetCustomField(db),
CreateTransaction: new CreateTransaction(db), CreateTransaction: new CreateTransaction(db),
}; };

View File

@@ -7,8 +7,29 @@ export interface ConditionEvaluateContext {
lifecycleDef?: LifecycleDefinition; lifecycleDef?: LifecycleDefinition;
} }
export interface ConditionConfig {
from_status?: unknown;
to_status?: unknown;
field_key?: unknown;
field_id?: unknown;
field?: unknown;
old_value?: unknown;
new_value?: unknown;
value?: unknown;
}
export interface ConditionEvaluator { export interface ConditionEvaluator {
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean; evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean;
}
function matchesStatusFilter(value: string | null, filter: unknown): boolean {
if (filter === undefined || filter === null || filter === '') return true;
if (value === null) return false;
const normalizedValue = value.toLowerCase();
if (Array.isArray(filter)) {
return filter.map((item) => String(item).toLowerCase()).includes(normalizedValue);
}
return normalizedValue === String(filter).toLowerCase();
} }
export class OnCreate implements ConditionEvaluator { export class OnCreate implements ConditionEvaluator {
@@ -18,19 +39,25 @@ export class OnCreate implements ConditionEvaluator {
} }
export class OnStatusChange implements ConditionEvaluator { export class OnStatusChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean { evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
return transactions.some((tx) => tx.transaction_type === 'StatusChange'); return transactions.some((tx) =>
tx.transaction_type === 'StatusChange' &&
matchesStatusFilter(tx.old_value, config?.from_status) &&
matchesStatusFilter(tx.new_value, config?.to_status)
);
} }
} }
export class OnResolve implements ConditionEvaluator { export class OnResolve implements ConditionEvaluator {
private lifecycleValidator = new LifecycleValidator(); private lifecycleValidator = new LifecycleValidator();
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean { evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const lifecycleDef = context?.lifecycleDef; const lifecycleDef = context?.lifecycleDef;
return transactions.some((tx) => { return transactions.some((tx) => {
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false; if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
if (!matchesStatusFilter(tx.old_value, config?.from_status)) return false;
if (!matchesStatusFilter(tx.new_value, config?.to_status)) return false;
if (lifecycleDef) { if (lifecycleDef) {
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value); return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
@@ -41,10 +68,25 @@ export class OnResolve implements ConditionEvaluator {
} }
} }
export class OnCustomFieldChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const fieldFilter = config?.field_key ?? config?.field_id ?? config?.field;
const newValueFilter = config?.new_value ?? config?.value;
return transactions.some((tx) =>
tx.transaction_type === 'CustomFieldChange' &&
matchesStatusFilter(tx.field, fieldFilter) &&
matchesStatusFilter(tx.old_value, config?.old_value) &&
matchesStatusFilter(tx.new_value, newValueFilter)
);
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = { const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(), OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(), OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(), OnResolve: new OnResolve(),
OnCustomFieldChange: new OnCustomFieldChange(),
}; };
export function getConditionEvaluator(type: string): ConditionEvaluator | null { export function getConditionEvaluator(type: string): ConditionEvaluator | null {

View File

@@ -4,7 +4,7 @@ import type { Transaction } from '../models/transaction.ts';
import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts'; import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
import { eq, asc, inArray } from 'drizzle-orm'; import { eq, asc, inArray } from 'drizzle-orm';
import { getConditionEvaluator } from './conditions.ts'; import { getConditionEvaluator } from './conditions.ts';
import type { ConditionEvaluateContext } from './conditions.ts'; import type { ConditionConfig, ConditionEvaluateContext } from './conditions.ts';
import { getActionExecutor } from './actions.ts'; import { getActionExecutor } from './actions.ts';
import type { ActionPayload } from './actions.ts'; import type { ActionPayload } from './actions.ts';
import { TemplateRenderer } from './templates.ts'; import { TemplateRenderer } from './templates.ts';
@@ -35,7 +35,7 @@ export class ScripEngine {
} }
async prepare( async prepare(
ticketId: string, ticketId: number,
transactions: Transaction[], transactions: Transaction[],
): Promise<PreparedScrip[]> { ): Promise<PreparedScrip[]> {
const ticketRecord = await this.db.query.tickets.findFirst({ const ticketRecord = await this.db.query.tickets.findFirst({
@@ -103,7 +103,12 @@ export class ScripEngine {
continue; continue;
} }
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions, conditionContext)) { if (!evaluator.evaluate(
ticketRecord as unknown as Ticket,
transactions,
conditionContext,
scrip.condition_config as ConditionConfig,
)) {
continue; continue;
} }

View File

@@ -17,7 +17,7 @@ export class TemplateRenderer {
export interface TemplateContext { export interface TemplateContext {
ticket: { ticket: {
id: string; id: number;
subject: string; subject: string;
status: string; status: string;
queue_id: string; queue_id: string;

View File

@@ -27,5 +27,6 @@
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
} },
"exclude": ["web", "node_modules"]
} }

View File

@@ -1,5 +1,49 @@
<!-- BEGIN:nextjs-agent-rules --> # Tessera Design Rules
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. Follow these rules when writing any frontend code. The design is clean, modern, and minimal — avoid the "2015 admin panel" look.
<!-- END:nextjs-agent-rules -->
## Borders
- **Never** use `border-border` (full opacity). Always `border-border/50` or `border-border/30`.
- Section dividers: `border-b border-border/50`
- Subtle item separators: `border-b border-border/30`
- Card borders: `border border-border/50`
## Shadows
- **Never** use `shadow-sm` or `shadow-md` on cards, sections, or tabs.
- The sidebar is the only element with a shadow (subtle).
- No `bg-card/82`, no `bg-background/55` — use `bg-card` or `bg-transparent`.
## Section wrappers
- `rounded-lg border border-border/50` — NOT `rounded-md border border-border bg-card shadow-sm`
## List items
- `rounded-lg border border-border/50 p-3`, hover: `hover:border-primary/30 hover:bg-accent/30`
- Active: `border-primary/50 bg-primary/5`
## Nav items (sidebar)
- Default: `text-sidebar-foreground/55`, icons at 50% opacity
- Hover: `text-sidebar-foreground hover:bg-sidebar-accent/50`, icons at 70%
- Active: `bg-sidebar-accent text-sidebar-foreground font-medium`, icons at 90%
- No inset shadow on active items
## Typography
- Section headers: `text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60`
- No `text-[11px] font-semibold uppercase` anywhere — use the above pattern
- Body text: `text-sm` for main, `text-xs` for secondary
- Keep font sizes consistent: 10px (labels), 12-13px (content), 14px (headings)
## Icons
- Section headers: `h-3 w-3` (not `h-4 w-4`)
- In nav items: `w-4 h-4`
- Always dimmed when inactive (opacity-50), bright when active
## Forms
- Inputs/selects: `h-8 rounded-md border border-input bg-transparent px-2.5 text-sm`
- No `bg-card` on inputs unless inside a card
- Labels: `text-[10px] font-medium text-muted-foreground`
## General
- No `backdrop-blur` on headers or sections
- No `bg-card/82` or `bg-background/55` — use solid colors or `bg-transparent`
- Content areas: `bg-background/80` for the main page background
- Avoid `<dl>`/`<dt>`/`<dd>` grids for properties — use simple `flex justify-between` rows

View File

@@ -14,7 +14,7 @@ pnpm dev
bun dev bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://127.0.0.1:3100](http://127.0.0.1:3100) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

View File

@@ -1,11 +1,19 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const appRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
outputFileTracingRoot: appRoot,
turbopack: {
root: appRoot,
},
async rewrites() { async rewrites() {
return [ return [
{ {
source: '/api/:path*', source: "/api/:path*",
destination: 'http://127.0.0.1:9876/api/:path*', destination: "http://127.0.0.1:9876/:path*",
}, },
]; ];
}, },

95
web/package-lock.json generated
View File

@@ -9,15 +9,21 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.5.0", "@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "16.2.7", "next": "16.2.7",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.77.0",
"shadcn": "^4.10.0", "shadcn": "^4.10.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -943,6 +949,18 @@
"hono": "^4" "hono": "^4"
} }
}, },
"node_modules/@hookform/resolvers": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1981,6 +1999,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2261,6 +2285,39 @@
"tailwindcss": "4.3.0" "tailwindcss": "4.3.0"
} }
}, },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@ts-morph/common": { "node_modules/@ts-morph/common": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -3947,6 +4004,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7217,6 +7284,16 @@
} }
} }
}, },
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -8002,6 +8079,22 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-hook-form": {
"version": "7.77.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz",
"integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -3,9 +3,10 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --webpack -H 127.0.0.1 --port 3100",
"dev:prod": "next build && next start -H 127.0.0.1 --port 3100",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -H 127.0.0.1 --port 3100",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
@@ -17,6 +18,7 @@
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "16.2.7", "next": "16.2.7",
"next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.77.0", "react-hook-form": "^7.77.0",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import { Suspense } from "react";
import AdminPage from "./page-content";
export default function AdminPageWrapper() {
return (
<Suspense fallback={<div className="text-[#8a8f98] p-6">Loading admin...</div>}>
<AdminPage />
</Suspense>
);
}

View File

@@ -0,0 +1,589 @@
"use client";
import { useState, useEffect, use, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
GripIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
RefreshCwIcon,
LayoutGridIcon,
} from "lucide-react";
import {
getDashboard,
createWidget,
deleteWidget,
updateWidget,
getWidgetData,
getViews,
getTeams,
updateDashboard,
} from "@/lib/api";
import type {
Dashboard,
DashboardWidget,
SavedView,
Team,
WidgetData,
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { CountWidget } from "@/components/widgets/count-widget";
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
import { cn } from "@/lib/utils";
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
return {
gridColumn: `${position.x + 1} / span ${position.w}`,
gridRow: `${position.y + 1} / span ${position.h}`,
};
}
export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]);
const [views, setViews] = useState<SavedView[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [editMode, setEditMode] = useState(false);
const [teams, setTeams] = useState<Team[]>([]);
// Add widget dialog
const [addOpen, setAddOpen] = useState(false);
const [addViewId, setAddViewId] = useState("");
const [addTitle, setAddTitle] = useState("");
const [addType, setAddType] = useState("count");
const [addGroupBy, setAddGroupBy] = useState("owner");
const [adding, setAdding] = useState(false);
const fetchDashboard = useCallback(async () => {
const { data, error } = await getDashboard(id);
if (error || !data) {
setError(error ?? "Dashboard not found");
setLoading(false);
return;
}
setDashboard(data);
const widgetList = data.widgets ?? [];
setWidgets(widgetList);
// Fetch data for each widget
for (const widget of widgetList) {
const { data: wData } = await getWidgetData(id, widget.id);
if (wData) {
setWidgets((prev) =>
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
);
}
}
setLoading(false);
}, [id]);
useEffect(() => {
fetchDashboard();
getViews().then(({ data }) => { if (data) setViews(data); });
getTeams().then(({ data }) => { if (data) setTeams(data); });
}, [fetchDashboard]);
// Auto-refresh: only refresh widget data, not structure
useEffect(() => {
if (!autoRefresh || !dashboard) return;
const interval = setInterval(() => {
for (const widget of widgets) {
getWidgetData(dashboard.id, widget.id).then(({ data: wData }) => {
if (wData) {
setWidgets((prev) =>
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
);
}
});
}
}, 30_000);
return () => clearInterval(interval);
}, [autoRefresh, dashboard?.id]);
const handleAddWidget = async () => {
if (!addViewId || !addTitle.trim()) return;
setAdding(true);
// Smart positioning: fill a 3-column grid (4 units each in 12-col grid)
const COLS = 3; const W = 4; const H = 2;
const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`));
let x = 0; let y = 0;
while (occupied.has(`${x},${y}`)) {
x += W;
if (x >= COLS * W) { x = 0; y += H; }
}
const pos = { x, y, w: W, h: H };
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
const { data, error } = await createWidget(id, {
view_id: addViewId,
title: addTitle.trim(),
widget_type: addType,
position: pos,
config,
});
if (!error && data) {
setWidgets((prev) => [...prev, data]);
const { data: wData } = await getWidgetData(id, data.id);
if (wData) {
setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w)));
}
setAddOpen(false);
setAddViewId("");
setAddTitle("");
setAddType("count");
}
setAdding(false);
};
const handleDeleteWidget = async (widgetId: string) => {
await deleteWidget(id, widgetId);
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
};
const gridRef = useRef<HTMLDivElement>(null);
const [resizingId, setResizingId] = useState<string | null>(null);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// Resize: track mousedown → mousemove → mouseup
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
e.preventDefault();
e.stopPropagation();
setResizingId(widgetId);
const startX = e.clientX;
const startY = e.clientY;
const widget = widgets.find((w) => w.id === widgetId);
if (!widget || !gridRef.current) return;
const startW = widget.position.w;
const startH = widget.position.h;
const gridWidth = gridRef.current.offsetWidth;
const unitSize = gridWidth / 12;
const onMove = (ev: MouseEvent) => {
const dx = Math.round((ev.clientX - startX) / unitSize);
const dy = Math.round((ev.clientY - startY) / unitSize);
const newW = Math.max(1, Math.min(12, startW + dx));
const newH = Math.max(1, startH + dy);
setWidgets((prev) =>
prev.map((w) =>
w.id === widgetId
? { ...w, position: { ...w.position, w: newW, h: newH } }
: w
)
);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setResizingId(null);
// Resolve overlaps using latest state via functional updater
setWidgets((current) => {
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i].position;
const b = resolved[j].position;
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
hasOverlap = true;
const toMove = widgetId === resolved[i].id ? j : i;
const fixedW = resolved[widgetId === resolved[i].id ? i : j];
resolved[toMove] = {
...resolved[toMove],
position: { ...resolved[toMove].position, y: fixedW.position.y + fixedW.position.h },
};
}
}
}
if (!hasOverlap) break;
}
// Persist changed positions
for (const w of resolved) {
const orig = current.find((o) => o.id === w.id);
if (orig && (orig.position.y !== w.position.y || orig.position.h !== w.position.h)) {
updateWidget(id, w.id, { position: w.position });
}
}
return resolved;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
// Mouse-based drag: mousedown on grip → mousemove → mouseup
const handleDragMouseDown = (e: React.MouseEvent, widgetId: string) => {
e.preventDefault();
e.stopPropagation();
if (!gridRef.current) return;
setDraggingId(widgetId);
const widget = widgets.find((w) => w.id === widgetId);
if (!widget) return;
const unitSize = gridRef.current.offsetWidth / 12;
const startX = e.clientX;
const startY = e.clientY;
const startGridX = widget.position.x;
const startGridY = widget.position.y;
// Store offset from widget origin to mouse for visual tracking
const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
if (widgetEl) {
const rect = widgetEl.getBoundingClientRect();
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
}
const onMove = (ev: MouseEvent) => {
const dx = Math.round((ev.clientX - startX) / unitSize);
const dy = Math.round((ev.clientY - startY) / unitSize);
const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
const newY = Math.max(0, startGridY + dy);
setWidgets((prev) =>
prev.map((w) =>
w.id === widgetId
? { ...w, position: { ...w.position, x: newX, y: newY } }
: w
)
);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
setDraggingId(null);
setWidgets((current) => {
const updated = current.find((w) => w.id === widgetId);
if (!updated) return current;
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
// Push overlapping widgets down
for (let pass = 0; pass < 10; pass++) {
let hasOverlap = false;
for (let i = 0; i < resolved.length; i++) {
for (let j = i + 1; j < resolved.length; j++) {
const a = resolved[i].position;
const b = resolved[j].position;
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
hasOverlap = true;
const moveIdx = resolved[i].id === widgetId ? j : i;
const fixedIdx = moveIdx === i ? j : i;
resolved[moveIdx] = {
...resolved[moveIdx],
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
};
}
}
}
if (!hasOverlap) break;
}
// Persist changed positions
for (const w of resolved) {
const orig = current.find((o) => o.id === w.id);
if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) {
updateWidget(id, w.id, { position: w.position });
}
}
return resolved;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
};
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
if (!widget.data) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
switch (widget.data.type) {
case "count":
return <CountWidget data={widget.data} />;
case "ticket_list":
return <TicketListWidget data={widget.data} />;
case "status_chart":
return <StatusChartWidget data={widget.data} />;
case "grouped_counts":
return <GroupedCountsWidget data={widget.data} />;
default:
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">Unknown type: {widget.data.type}</p>
</div>
);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (error || !dashboard) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4">
<p className="text-sm text-muted-foreground">{error ?? "Dashboard not found"}</p>
<Link href="/" className="text-sm text-primary hover:underline">
Go to ticket list
</Link>
</div>
);
}
return (
<div className="flex h-full flex-col bg-background/80">
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
<div className="flex items-center justify-between px-5 py-3 lg:px-6">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
<LayoutGridIcon className="h-3.5 w-3.5" />
Dashboard
</div>
<h1 className="mt-1 text-xl font-semibold text-foreground">{dashboard.name}</h1>
<div className="mt-2 flex items-center gap-2">
<select
value={dashboard.team_id ?? ""}
onChange={async (e) => {
const teamId = e.target.value || null;
await updateDashboard(dashboard.id, { team_id: teamId });
setDashboard((prev) => prev ? { ...prev, team_id: teamId } : prev);
}}
className="h-7 rounded border border-border bg-card px-2 text-xs text-muted-foreground outline-none"
>
<option value="">No team</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{dashboard.description && (
<p className="text-sm text-muted-foreground">{dashboard.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditMode((v) => !v)}
className={cn("h-8 border-border/80", editMode ? "bg-primary/20 text-primary border-primary/40" : "bg-card/70")}
>
<PencilIcon className="h-4 w-4" />
{editMode ? "Done" : "Edit"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setAutoRefresh((v) => !v)}
className={cn("h-8 border-border/80", autoRefresh ? "bg-primary/20 text-primary" : "bg-card/70")}
>
<RefreshCwIcon className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
{autoRefresh ? "Live" : "Auto"}
</Button>
<Button
variant="outline"
size="sm"
onClick={fetchDashboard}
className="h-8 border-border/80 bg-card/70"
>
<RefreshCwIcon className="h-4 w-4" />
Refresh
</Button>
{editMode && (
<Button size="sm" onClick={() => setAddOpen(true)} className="h-8 bg-primary shadow-sm">
<PlusIcon className="h-4 w-4" />
Add widget
</Button>
)}
</div>
</div>
</header>
<div className="flex-1 overflow-auto p-5 lg:p-6">
{widgets.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<LayoutGridIcon className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No widgets yet</p>
{editMode ? (
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
<PlusIcon className="h-4 w-4" />
Add your first widget
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setEditMode(true)}>
<PencilIcon className="h-4 w-4" />
Enter edit mode
</Button>
)}
</div>
) : (
<div
ref={gridRef}
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
>
{widgets.map((widget) => (
<div
key={widget.id}
data-widget-id={widget.id}
className={cn(
"group relative transition-none",
draggingId === widget.id && "z-10",
resizingId === widget.id && "select-none",
)}
style={widgetGridStyle(widget.position)}
>
{/* Drag handle */}
{editMode && (
<div
className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex active:cursor-grabbing"
onMouseDown={(e) => handleDragMouseDown(e, widget.id)}
>
<GripIcon className="h-3.5 w-3.5" />
</div>
)}
{renderWidget(widget)}
{editMode && (
<>
<button
type="button"
onClick={() => handleDeleteWidget(widget.id)}
className="absolute right-2 top-2 z-10 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
title="Remove widget"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
{/* Resize handle */}
<div
className="absolute bottom-0 right-0 z-10 hidden h-5 w-5 cursor-se-resize items-center justify-center group-hover:flex"
onMouseDown={(e) => handleResizeStart(e, widget.id)}
>
<svg width="10" height="10" viewBox="0 0 10 10" className="text-muted-foreground">
<path d="M0 10 L10 0" stroke="currentColor" strokeWidth="1.5" />
<path d="M5 10 L10 5" stroke="currentColor" strokeWidth="1.5" />
<path d="M0 10 L10 10" stroke="transparent" />
</svg>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add widget</DialogTitle>
<DialogDescription>
Choose a saved view and widget type to add to this dashboard.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Widget title</label>
<input
value={addTitle}
onChange={(e) => setAddTitle(e.target.value)}
placeholder="e.g. Open tickets"
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
/>
</div>
<div>
<label className="text-sm font-medium">Saved view</label>
<select
value={addViewId}
onChange={(e) => {
setAddViewId(e.target.value);
const view = views.find((v) => v.id === e.target.value);
if (view && !addTitle) setAddTitle(view.name);
}}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="">Select a view...</option>
{views.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">Widget type</label>
<select
value={addType}
onChange={(e) => setAddType(e.target.value)}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="count">Count (big number)</option>
<option value="ticket_list">Ticket list (mini table)</option>
<option value="status_chart">Status chart (donut)</option>
<option value="grouped_counts">Grouped counts (bar chart)</option>
</select>
</div>
{addType === "grouped_counts" && (
<div>
<label className="text-sm font-medium">Group by</label>
<select
value={addGroupBy}
onChange={(e) => setAddGroupBy(e.target.value)}
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
>
<option value="owner">Owner</option>
<option value="queue">Queue</option>
</select>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button
size="sm"
disabled={!addViewId || !addTitle.trim() || adding}
onClick={handleAddWidget}
>
{adding ? "Adding..." : "Add widget"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -8,7 +8,7 @@
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
@@ -49,72 +49,83 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.982 0.006 106);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.19 0.018 248);
--card: oklch(1 0 0); --card: oklch(0.996 0.003 106);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.19 0.018 248);
--popover: oklch(1 0 0); --popover: oklch(0.996 0.003 106);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.19 0.018 248);
--primary: oklch(0.205 0 0); --primary: oklch(0.31 0.046 243);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.99 0.003 106);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.945 0.01 105);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.25 0.026 244);
--muted: oklch(0.97 0 0); --muted: oklch(0.948 0.008 106);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.49 0.023 250);
--accent: oklch(0.97 0 0); --accent: oklch(0.925 0.024 184);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.21 0.028 246);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.55 0.18 27);
--border: oklch(0.922 0 0); --border: oklch(0.865 0.014 102);
--input: oklch(0.922 0 0); --input: oklch(0.84 0.015 102);
--ring: oklch(0.708 0 0); --ring: oklch(0.58 0.068 185);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.62 0.095 184);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.53 0.078 243);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.64 0.12 77);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.55 0.15 28);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.44 0.055 257);
--radius: 0.625rem; --radius: 0.5rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.245 0.026 248);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.93 0.012 108);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.69 0.105 184);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.18 0.022 248);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.31 0.031 248);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.98 0.006 106);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.66 0.102 184);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.18 0.018 248);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.94 0.011 105);
--card: oklch(0.205 0 0); --card: oklch(0.225 0.022 248);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.94 0.011 105);
--popover: oklch(0.205 0 0); --popover: oklch(0.225 0.022 248);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.94 0.011 105);
--primary: oklch(0.922 0 0); --primary: oklch(0.74 0.105 184);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.17 0.018 248);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.27 0.026 248);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.94 0.011 105);
--muted: oklch(0.269 0 0); --muted: oklch(0.28 0.023 248);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.7 0.019 105);
--accent: oklch(0.269 0 0); --accent: oklch(0.31 0.043 184);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.94 0.011 105);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.68 0.17 24);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 16%);
--ring: oklch(0.556 0 0); --ring: oklch(0.68 0.095 184);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.74 0.105 184);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.7 0.105 74);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.66 0.12 25);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.61 0.08 245);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.8 0.04 108);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.145 0.018 248);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.94 0.011 105);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.74 0.105 184);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.17 0.018 248);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.24 0.026 248);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.94 0.011 105);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.68 0.095 184);
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
@layer base { @layer base {
@@ -123,6 +134,11 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-feature-settings: "cv01" 1, "ss03" 1;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px);
background-size: 44px 44px;
} }
html { html {
@apply font-sans; @apply font-sans;

View File

@@ -1,33 +1,41 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
import { Suspense } from "react";
import { ThemeProvider } from "next-themes";
import "./globals.css"; import "./globals.css";
import { AppShell } from "@/components/app-shell";
const geistSans = Geist({ const ibmPlexSans = IBM_Plex_Sans({
variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-sans",
}); });
const geistMono = Geist_Mono({ const jetbrainsMono = JetBrains_Mono({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
variable: "--font-mono",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Tessera",
description: "Generated by create next app", description: "Ticket management system",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{ children: React.ReactNode }>) {
children: React.ReactNode;
}>) {
return ( return (
<html <html lang="en" suppressHydrationWarning>
lang="en" <body
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${ibmPlexSans.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{ fontSize: "15px", lineHeight: 1.5 }}
> >
<body className="min-h-full flex flex-col">{children}</body> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
<AppShell>{children}</AppShell>
</Suspense>
</ThemeProvider>
</body>
</html> </html>
); );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,442 @@
"use client";
import { useState, useEffect, Suspense, createContext, useContext } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
CircleIcon,
LayoutGridIcon,
UserIcon,
UsersIcon,
InboxIcon,
ClockIcon,
SettingsIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
CommandIcon,
} from "lucide-react";
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
const SidebarCollapsedContext = createContext(false);
function useSidebarCollapsed() {
return useContext(SidebarCollapsedContext);
}
interface ViewCounts {
all: number;
my: number;
unassigned: number;
recent: number;
}
function SidebarNavItem({
href,
icon: Icon,
label,
count,
active,
}: {
href: string;
icon: React.ComponentType<{ className?: string }>;
label: string;
count?: number;
active: boolean;
}) {
const collapsed = useSidebarCollapsed();
return (
<Link
href={href}
title={collapsed ? label : undefined}
className={cn(
"group flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
collapsed ? "justify-center w-full" : "justify-between",
active
? "bg-sidebar-accent text-sidebar-foreground font-medium"
: "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 font-normal"
)}
>
<span className={cn("flex items-center min-w-0", collapsed ? "" : "gap-2.5")}>
<Icon className={cn("w-4 h-4 flex-shrink-0", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
{!collapsed && <span className="truncate">{label}</span>}
</span>
{!collapsed && count !== undefined && count > 0 && (
<span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
{count}
</span>
)}
</Link>
);
}
function SidebarNav() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [counts, setCounts] = useState<ViewCounts>({
all: 0,
my: 0,
unassigned: 0,
recent: 0,
});
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [myTeamId, setMyTeamId] = useState<string | null>(null);
const [newDashboardName, setNewDashboardName] = useState("");
const [addingDashboard, setAddingDashboard] = useState(false);
useEffect(() => {
async function load() {
// Find current user
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
const data = ticketRes.data;
const users = userRes.data ?? [];
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
const myId = currentUser?.id ?? null;
setCurrentUserId(myId);
if (data) {
const now = Date.now();
const week = 7 * 24 * 60 * 60 * 1000;
setCounts({
all: data.length,
my: myId ? data.filter((t) => t.owner_id === myId).length : 0,
unassigned: data.filter((t) => !t.owner_id).length,
recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
});
}
// Queues
const queueRes = await getQueues();
if (queueRes.data) {
const qs = await Promise.all(
queueRes.data.map((q) =>
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
...q,
count: tickets?.length ?? 0,
}))
)
);
setQueues(qs);
}
// Views
const viewRes = await getViews();
if (viewRes.data) setSavedViews(viewRes.data);
// Dashboards scoped to user's teams
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
const allDashboards = dashRes.data ?? [];
const allTeams = teamRes.data ?? [];
const userTeams = allTeams.filter((t) =>
(t.members ?? []).some((m) => m.id === myId)
);
setMyTeamId(userTeams[0]?.id ?? null);
const teamIds = new Set(userTeams.map((t) => t.id));
const visible = allDashboards.filter((d) =>
!d.team_id || teamIds.has(d.team_id)
);
setDashboards(visible);
}
void load();
}, []);
const collapsed = useSidebarCollapsed();
const views = [
{
label: "All tickets",
href: "/?view=all",
param: "all",
count: counts.all,
icon: LayoutGridIcon,
},
{
label: "My tickets",
href: currentUserId ? `/?view=my&owner=${currentUserId}` : "/?view=my",
param: "my",
count: counts.my,
icon: UserIcon,
},
...(myTeamId ? [{
label: "My team's tickets",
href: `/?view=team&team_id=${myTeamId}`,
param: "team",
count: undefined as number | undefined,
icon: UsersIcon,
}] : []),
{
label: "Unassigned",
href: "/?view=unassigned",
param: "unassigned",
count: counts.unassigned,
icon: InboxIcon,
},
{
label: "Recently updated",
href: "/?view=recent",
param: "recent",
count: counts.recent,
icon: ClockIcon,
},
];
const currentView = searchParams.get("view");
return (
<>
<div className="mb-4">
{views.map((view) => {
const active =
pathname === "/" &&
(view.param ? currentView === view.param : !currentView);
return (
<SidebarNavItem
key={view.label}
href={view.href}
icon={view.icon}
label={view.label}
count={view.count}
active={active}
/>
);
})}
</div>
{dashboards.length > 0 && (
<div className="mt-5">
{!collapsed && (
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
Dashboards
</div>
)}
{dashboards.length > 0 && (
<div className="px-2.5">
<select
value={dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? ""}
onChange={(e) => {
const id = e.target.value;
if (id === "_new") {
setAddingDashboard(true);
e.target.value = dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? "";
return;
}
if (id) window.location.href = `/dashboards/${id}`;
}}
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
>
{dashboards.map((dash) => (
<option key={dash.id} value={dash.id}>{dash.name}</option>
))}
<option value="_new">+ New dashboard</option>
</select>
</div>
)}
{addingDashboard && (
<div className="mt-1 px-2">
<input
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder="Dashboard name"
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
autoFocus
onKeyDown={async (e) => {
if (e.key === "Enter" && newDashboardName.trim()) {
const { data } = await createDashboard({ name: newDashboardName.trim(), is_default: false });
if (data) {
setDashboards((prev) => [...prev, data]);
setNewDashboardName("");
setAddingDashboard(false);
window.location.href = `/dashboards/${data.id}`;
}
} else if (e.key === "Escape") {
setNewDashboardName("");
setAddingDashboard(false);
}
}}
onBlur={() => {
if (!newDashboardName.trim()) {
setNewDashboardName("");
setAddingDashboard(false);
}
}}
/>
</div>
)}
</div>
)}
{savedViews.length > 0 && (
<div className="mt-5">
{!collapsed && (
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
Saved views
</div>
)}
{savedViews.map((view) => {
const active =
pathname === "/" && searchParams.get("view_id") === view.id;
return (
<SidebarNavItem
key={view.id}
href={`/?view_id=${view.id}`}
icon={ClockIcon}
label={view.name}
active={active}
/>
);
})}
</div>
)}
{queues.length > 0 && (
<div className="mt-5">
{!collapsed && (
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
Queues
</div>
)}
{queues.map((queue) => {
const active =
pathname === "/" && searchParams.get("queue") === queue.id;
return (
<SidebarNavItem
key={queue.id}
href={`/?queue=${queue.id}`}
icon={CircleIcon}
label={queue.name}
count={queue.count}
active={active}
/>
);
})}
</div>
)}
</>
);
}
function SidebarBottom() {
const pathname = usePathname();
const collapsed = useSidebarCollapsed();
return (
<div className="border-t border-sidebar-border/50 p-2">
<SidebarNavItem
href="/admin"
icon={SettingsIcon}
label="Admin"
active={pathname === "/admin"}
/>
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
<ThemeToggle />
</div>
</div>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const [commandOpen, setCommandOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
(e.target instanceof HTMLElement && e.target.isContentEditable)
) {
return;
}
e.preventDefault();
setCommandOpen((o) => !o);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
return (
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */}
<aside
className={cn(
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border/50 transition-all duration-200",
sidebarCollapsed ? "w-[56px]" : "w-[232px]"
)}
>
{/* Brand */}
<div className="h-12 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border/50">
<Link href="/" className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded-md bg-sidebar-primary flex items-center justify-center">
<span className="text-sidebar-primary-foreground text-[11px] font-bold">T</span>
</div>
{!sidebarCollapsed && (
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
)}
</Link>
{!sidebarCollapsed && (
<button
onClick={() => setCommandOpen(true)}
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
aria-label="Open command palette"
>
<CommandIcon className="h-3 w-3" />K
</button>
)}
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-2.5 px-2">
<Suspense
fallback={
<div className="space-y-1.5 px-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-7 bg-sidebar-accent rounded-md animate-pulse"
/>
))}
</div>
}
>
<SidebarNav />
</Suspense>
</nav>
{/* Bottom */}
<SidebarBottom />
</aside>
{/* Main */}
<main className="flex-1 overflow-hidden bg-background/88">{children}</main>
{/* Command Palette */}
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
</div>
{/* Collapse toggle */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-sidebar-border border-l-0 text-sidebar-foreground/55 hover:text-sidebar-foreground transition-all duration-150"
style={{ left: sidebarCollapsed ? 60 : 240 }}
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<PanelLeftIcon className="w-3.5 h-3.5" />
) : (
<PanelLeftCloseIcon className="w-3.5 h-3.5" />
)}
</button>
</SidebarCollapsedContext.Provider>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import type { ComponentType, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import {
SearchIcon,
PlusIcon,
LayoutGridIcon,
SettingsIcon,
MessageSquareIcon,
} from "lucide-react";
import { getTickets } from "@/lib/api";
import type { Ticket } from "@/lib/types";
interface CommandItem {
id: string;
label: string;
icon: ComponentType<{ className?: string }>;
action: () => void;
category?: string;
}
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [tickets, setTickets] = useState<Ticket[]>([]);
useEffect(() => {
if (open) {
getTickets().then(({ data }) => {
if (data) setTickets(data);
});
}
}, [open]);
const filtered = useMemo(() => {
const normalizedQuery = query.toLowerCase();
const alwaysCommands: CommandItem[] = [
{
id: "new-ticket",
label: "New ticket",
icon: PlusIcon,
action: () => {
onOpenChange(false);
router.push("/?new=true");
},
category: "Actions",
},
{
id: "admin",
label: "Go to admin",
icon: SettingsIcon,
action: () => {
onOpenChange(false);
router.push("/admin");
},
category: "Navigate",
},
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
},
category: "Navigate",
},
];
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(normalizedQuery)
);
const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
icon: MessageSquareIcon,
action: () => {
onOpenChange(false);
router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
return [...alwaysFiltered, ...ticketCommands];
}, [onOpenChange, query, router, tickets]);
const grouped = useMemo(
() =>
filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const cat = cmd.category || "Other";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {}),
[filtered]
);
useEffect(() => {
if (open) {
queueMicrotask(() => {
setQuery("");
setSelectedIndex(0);
});
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
queueMicrotask(() => setSelectedIndex(0));
}, [query]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[selectedIndex]) {
filtered[selectedIndex].action();
}
} else if (e.key === "Escape") {
onOpenChange(false);
}
},
[filtered, selectedIndex, onOpenChange]
);
if (!open) return null;
return (
<div className="fixed inset-0 z-50">
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => onOpenChange(false)}
/>
<div className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-md">
<div className="bg-popover rounded-xl shadow-2xl border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 border-b border-border">
<SearchIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command or search..."
className="w-full h-10 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
</div>
<div ref={listRef} className="max-h-64 overflow-y-auto p-1">
{filtered.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No results found
</div>
)}
{Object.entries(grouped).map(([category, items]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{category}
</div>
{items.map((item) => {
const idx = filtered.indexOf(item);
const isSelected = idx === selectedIndex;
const Icon = item.icon;
return (
<button
key={item.id}
className={`w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-sm text-left transition-all duration-150 ${
isSelected
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-accent"
}`}
onClick={item.action}
onMouseEnter={() => setSelectedIndex(idx)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{item.label}
</button>
);
})}
</div>
))}
</div>
<div className="border-t border-border px-3 py-2 flex items-center justify-between text-xs text-muted-foreground">
<span> Navigate</span>
<span> Select</span>
<span>Esc Dismiss</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useTheme } from "next-themes";
import { SunIcon, MoonIcon } from "lucide-react";
import { useEffect, useState } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
queueMicrotask(() => setMounted(true));
}, []);
if (!mounted) {
return <div className="w-8 h-8" />;
}
const isDark = theme === "dark";
return (
<button
onClick={() => setTheme(isDark ? "light" : "dark")}
className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-150"
aria-label="Toggle theme"
>
{isDark ? (
<SunIcon className="w-4 h-4" />
) : (
<MoonIcon className="w-4 h-4" />
)}
</button>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import Link from "next/link";
import type { WidgetData } from "@/lib/types";
export function CountWidget({ data }: { data: WidgetData }) {
const params = new URLSearchParams();
if (data.view_id) params.set("view_id", data.view_id);
return (
<Link
href={`/?${params.toString()}`}
className="flex h-full flex-col items-center justify-center rounded-lg border border-border bg-card p-4 transition-colors hover:border-ring/50 hover:bg-accent/30"
>
<span className="text-3xl font-bold tabular-nums text-foreground">
{data.total}
</span>
<span className="mt-1 text-sm text-muted-foreground">{data.title}</span>
</Link>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import type { WidgetData } from "@/lib/types";
export function GroupedCountsWidget({ data }: { data: WidgetData }) {
const groups = data.groups ?? {};
const entries = Object.entries(groups).sort(([, a], [, b]) => b - a);
const max = entries.length > 0 ? Math.max(...entries.map(([, c]) => c)) : 1;
if (entries.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">No data</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
</div>
<div className="flex-1 space-y-1.5 overflow-auto p-3">
{entries.map(([label, count]) => (
<div key={label} className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 truncate text-foreground">{label}</span>
<div className="flex-1">
<div
className="h-2.5 rounded-sm bg-primary/60 transition-all"
style={{ width: `${Math.round((count / max) * 100)}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right tabular-nums text-muted-foreground">{count}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import type { WidgetData } from "@/lib/types";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
function statusLabel(status: string) {
return status.replaceAll("_", " ");
}
export function StatusChartWidget({ data }: { data: WidgetData }) {
const counts = data.counts ?? {};
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
if (entries.length === 0) {
return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
<p className="text-xs text-muted-foreground">No data</p>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
</div>
<div className="flex flex-1 items-center gap-4 p-4">
{/* Donut */}
<svg viewBox="0 0 40 40" className="h-16 w-16 shrink-0">
{entries.map(([, count], index) => {
const total = entries.reduce((sum, [, c]) => sum + c, 0);
const offset = entries
.slice(0, index)
.reduce((sum, [, c]) => sum + (c / total) * 100, 0);
const pct = (count / total) * 100;
const circumference = 2 * Math.PI * 15;
const dash = (pct / 100) * circumference;
const color = STATUS_COLORS[entries[index][0]] ?? "#71717a";
return (
<circle
key={entries[index][0]}
cx="20" cy="20" r="15"
fill="none"
stroke={color}
strokeWidth="6"
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-(offset / 100) * circumference}
transform="rotate(-90 20 20)"
/>
);
})}
</svg>
{/* Legend */}
<div className="min-w-0 flex-1 space-y-1.5">
{entries.map(([status, count]) => (
<div key={status} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: STATUS_COLORS[status] ?? "#71717a" }}
/>
<span className="flex-1 capitalize text-foreground">{statusLabel(status)}</span>
<span className="tabular-nums text-muted-foreground">{count}</span>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { CircleIcon } from "lucide-react";
import type { WidgetData } from "@/lib/types";
import { cn, formatTicketId } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
function statusLabel(status: string) {
return status.replaceAll("_", " ");
}
export function TicketListWidget({ data }: { data: WidgetData }) {
const tickets = data.tickets ?? [];
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-xs font-semibold text-foreground">{data.title}</span>
<span className="text-[11px] tabular-nums text-muted-foreground">{data.total}</span>
</div>
<div className="flex-1 overflow-auto">
{tickets.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">No tickets</p>
) : (
tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="flex items-center gap-2 border-b border-border/50 px-3 py-2 text-xs transition-colors hover:bg-accent/40 last:border-b-0"
>
<CircleIcon
className="h-2 w-2 shrink-0"
style={{ color: STATUS_COLORS[ticket.status] ?? "#71717a", fill: STATUS_COLORS[ticket.status] ?? "#71717a" }}
/>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{ticket.subject}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{ticket.owner_name ?? "unassigned"}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground/60">
{formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
</span>
</Link>
))
)}
</div>
</div>
);
}

375
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,375 @@
import type {
Ticket,
Queue,
Dashboard,
DashboardWidget,
WidgetData,
Team,
User,
Transaction,
SavedView,
Scrip,
Template,
TemplatePreview,
Lifecycle,
LifecycleDefinition,
CustomField,
QueueCustomField,
PreviewResult,
UpdateResult,
} from "./types";
const BASE_URL = "/api";
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
try {
const res = await fetch(`${BASE_URL}${url}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
export async function getTickets(params?: {
queue_id?: string;
status?: string;
q?: string;
owner_id?: string;
team_id?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id);
if (params?.status) sp.set("status", params.status);
if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.team_id) sp.set("team_id", params.team_id);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
}
}
const qs = sp.toString();
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`);
}
export async function getTicket(id: number): Promise<{ data: Ticket | null; error: string | null }> {
return request<Ticket>(`/tickets/${id}`);
}
export async function createTicket(data: {
subject: string;
queue_id: string;
description?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null; team_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function previewTicket(id: number, data: { status?: string }): Promise<{ data: PreviewResult | null; error: string | null }> {
return request<PreviewResult>(`/tickets/${id}/preview`, { method: "POST", body: JSON.stringify(data) });
}
export async function getTicketTransactions(id: number): Promise<{ data: Transaction[] | null; error: string | null }> {
return request<Transaction[]>(`/tickets/${id}/transactions`);
}
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
}
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
return request<Queue[]>("/queues");
}
export async function getUsers(): Promise<{ data: User[] | null; error: string | null }> {
return request<User[]>("/users");
}
export async function createUser(data: {
username: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
}
export async function updateUser(id: string, data: {
username?: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
}
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
return request<Scrip[]>("/scrips");
}
export async function createScrip(data: {
name: string;
description?: string | null;
queue_id?: string | null;
condition_type: string;
condition_config?: Record<string, unknown>;
action_type: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
stage?: string;
sort_order?: number;
disabled?: boolean;
}): Promise<{ data: Scrip | null; error: string | null }> {
return request<Scrip>("/scrips", { method: "POST", body: JSON.stringify(data) });
}
export async function updateScrip(id: string, data: {
name?: string;
description?: string | null;
queue_id?: string | null;
condition_type?: string;
condition_config?: Record<string, unknown>;
action_type?: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
stage?: string;
sort_order?: number;
disabled?: boolean;
}): Promise<{ data: Scrip | null; error: string | null }> {
return request<Scrip>(`/scrips/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getTemplates(): Promise<{ data: Template[] | null; error: string | null }> {
return request<Template[]>("/templates");
}
export async function createTemplate(data: {
name: string;
queue_id?: string | null;
subject_template: string;
body_template: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>("/templates", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTemplate(id: string, data: {
name?: string;
queue_id?: string | null;
subject_template?: string;
body_template?: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>(`/templates/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function previewTemplate(data: {
subject_template: string;
body_template: string;
ticket_id?: number | null;
}): Promise<{ data: TemplatePreview | null; error: string | null }> {
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
}
export async function deleteTemplate(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/templates/${id}`, { method: "DELETE" });
}
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
return request<Lifecycle[]>("/lifecycles");
}
export async function createLifecycle(data: {
name: string;
definition: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) });
}
export async function updateLifecycle(id: string, data: {
name?: string;
definition?: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>(`/lifecycles/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
return request<CustomField[]>("/custom-fields");
}
export async function getQueueCustomFields(queueId: string): Promise<{ data: QueueCustomField[] | null; error: string | null }> {
return request<QueueCustomField[]>(`/custom-fields/queues/${queueId}`);
}
export async function assignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: QueueCustomField | null; error: string | null }> {
return request<QueueCustomField>(`/custom-fields/queues/${queueId}`, {
method: "POST",
body: JSON.stringify({ custom_field_id: customFieldId }),
});
}
export async function unassignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/custom-fields/queues/${queueId}/${customFieldId}`, { method: "DELETE" });
}
export async function updateTicketCustomField(ticketId: number, customFieldId: string, value: string): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${ticketId}/custom-fields/${customFieldId}`, {
method: "PATCH",
body: JSON.stringify({ value }),
});
}
export async function createCustomField(data: {
key?: string;
name: string;
field_type: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
}
export async function updateCustomField(id: string, data: {
key?: string;
name?: string;
field_type?: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> {
return request<SavedView[]>("/views");
}
export async function createView(data: {
name: string;
filters: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: { key: string; label: string; width: number; visible: boolean }[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
}
export async function updateView(id: string, data: {
name?: string;
filters?: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: unknown[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
}
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
return request<Dashboard[]>("/dashboards");
}
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`);
}
export async function createDashboard(data: {
name: string;
description?: string;
team_id?: string | null;
is_default?: boolean;
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
}
export async function updateDashboard(id: string, data: {
name?: string;
description?: string | null;
team_id?: string | null;
is_default?: boolean;
layout?: unknown[];
}): Promise<{ data: Dashboard | null; error: string | null }> {
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
}
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
}
export async function createWidget(dashboardId: string, data: {
view_id: string;
title: string;
widget_type: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
}
export async function updateWidget(dashboardId: string, widgetId: string, data: {
title?: string;
widget_type?: string;
position?: { x: number; y: number; w: number; h: number };
config?: Record<string, unknown>;
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
}
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
}
export async function getTeams(): Promise<{ data: Team[] | null; error: string | null }> {
return request<Team[]>("/teams");
}
export async function createTeam(data: { name: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
return request<Team>("/teams", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTeam(id: string, data: { name?: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
return request<Team>(`/teams/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteTeam(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${id}`, { method: "DELETE" });
}
export async function addTeamMember(teamId: string, userId: string): Promise<{ data: unknown | null; error: string | null }> {
return request<unknown>(`/teams/${teamId}/members`, { method: "POST", body: JSON.stringify({ user_id: userId }) });
}
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
}

200
web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,200 @@
export interface Ticket {
id: number;
subject: string;
queue_id: string;
status: string;
owner_id: string | null;
team_id: string | null;
creator_id: string;
created_at: string;
updated_at: string;
started_at: string | null;
resolved_at: string | null;
custom_fields?: CustomFieldValue[];
}
export interface Queue {
id: string;
name: string;
description: string | null;
lifecycle_id: string | null;
team_id: string | null;
}
export interface User {
id: string;
username: string;
email: string | null;
created_at: string;
}
export interface Transaction {
id: string;
ticket_id: number;
transaction_type: string;
field: string | null;
old_value: string | null;
new_value: string | null;
data: unknown;
creator_id: string;
created_at: string;
}
export interface Scrip {
id: string;
queue_id: string | null;
name: string;
description: string | null;
condition_type: string;
condition_config: Record<string, unknown>;
action_type: string;
action_config: Record<string, unknown>;
template_id: string | null;
stage: string;
sort_order: number;
disabled: boolean;
created_at: string;
}
export interface Template {
id: string;
name: string;
queue_id: string | null;
subject_template: string;
body_template: string;
created_at: string;
}
export interface TemplatePreview {
subject: string;
body: string;
context: unknown;
}
export interface Lifecycle {
id: string;
name: string;
definition: LifecycleDefinition;
}
export interface LifecycleDefinition {
statuses: { initial: string[]; active: string[]; inactive: string[] };
transitions: Record<string, string[]>;
}
export interface CustomField {
id: string;
key: string;
name: string;
field_type: string;
values: unknown | null;
max_values: number;
pattern: string | null;
}
export interface QueueCustomField {
id: string;
queue_id: string;
custom_field_id: string;
sort_order: number;
custom_field: CustomField | null;
}
export interface CustomFieldValue {
id: string;
custom_field_id: string;
ticket_id: number;
value: string;
custom_field?: CustomField;
}
export interface PreviewResult {
prepared_scrips: PreparedScrip[];
}
export interface PreparedScrip {
scripId: string;
scripName: string;
actionType: string;
actionPayload: unknown;
dryRun: boolean;
}
export interface UpdateResult {
ticket: Ticket;
scrip_results: ScripResult[];
}
export interface ScripResult {
scripId: string;
success: boolean;
message: string;
}
export interface SavedFilter {
field: string;
operator: string;
value: string;
}
export interface SavedView {
id: string;
name: string;
filters: SavedFilter[];
sort_key: string;
columns: unknown[];
is_public: boolean;
creator_id: string | null;
created_at: string;
}
export interface Team {
id: string;
name: string;
description: string | null;
created_at: string;
members?: User[];
}
export interface Dashboard {
id: string;
name: string;
description: string | null;
team_id: string | null;
layout: unknown[];
is_default: boolean;
created_at: string;
widgets?: DashboardWidget[];
}
export interface DashboardWidget {
id: string;
dashboard_id: string;
view_id: string;
title: string;
widget_type: string;
position: { x: number; y: number; w: number; h: number };
config: Record<string, unknown>;
created_at: string;
}
export interface WidgetTicket {
id: number;
subject: string;
status: string;
owner_id: string | null;
owner_name: string | null;
queue_name: string;
updated_at: string;
}
export interface WidgetData {
type: string;
title: string;
total: number;
view_id: string;
tickets?: WidgetTicket[];
counts?: Record<string, number>;
groups?: Record<string, number>;
group_by?: string;
}

View File

@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function formatTicketId(id: number): string {
return `TKT-${String(id).padStart(4, "0")}`
}