Compare commits

..

47 Commits

Author SHA1 Message Date
Gjermund Høsøien Wiggen
9679734e3f feat: canonical custom field types, validation, and type-aware querying
- Unify field types across all layers: Text, Textarea, SelectOne, SelectMultiple, Date, DateTime, Number
- Add CustomFieldValidationConfig with type-specific Zod schemas
- Add validateCustomFieldValue() dispatching per-type validation
- Add validation_config (JSONB) and default_value columns to custom_fields
- Replace ad-hoc date/datetime/pattern checks with centralized validator
- Type-aware cf.* query operators: gt:, gte:, lt:, lte:, before:, after:, contains:
- Type-aware admin builder UI with inline option editor, min/max, date ranges, constraints
- Type-aware ticket detail rendering: Number inputs, Textarea, SelectMultiple checkbox groups
- Backward compatible: legacy type names mapped to canonical; old pattern field still checked
- Update seed data to canonical PascalCase types
- Migration 0018: add validation_config and default_value to custom_fields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:29:28 +02:00
Gjermund Høsøien Wiggen
70f0924d4b feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context)
- Add cron-like scrip scheduler for time-based conditions
- Add layout builder, scrip wizard, searchable select components
- Add trend chart widget for dashboards
- Add notifications, attachments, queue-permissions API routes
- Add seed-users script
- Update schema with 10 new migrations (0008-0017)
- Apply redesign: Linear-inspired dark theme, conversation-centric UI
- Gitignore runtime data directory

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:42:17 +02:00
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
86 changed files with 35895 additions and 935 deletions

View File

@@ -1,3 +1,3 @@
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera
DATABASE_URL=postgres://tessera:tessera@127.0.0.1:5435/tessera
SERVER_HOST=127.0.0.1
SERVER_PORT=9876

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ bun.lock
# Codegraph index (MCP tool)
.codegraph
# Runtime data
/data

View File

@@ -14,8 +14,10 @@ tessera/
│ ├── models/ # TypeScript types + Zod schemas
│ ├── scrip/ # Scrip engine (prepare/commit two-phase)
│ └── lifecycle/ # State machine validator
├── web/ # Frontend: Next.js 15 + shadcn/ui
── src/app/ # App Router pages
├── web/ # Frontend: Next.js 16 + shadcn/ui
── src/app/ # App Router pages
│ ├── src/components/ # Reusable components + widgets
│ └── src/lib/ # API client + types + utils
├── drizzle/ # SQL migration files
└── docs/ # Architecture + design specs
```
@@ -24,9 +26,9 @@ tessera/
**Backend:** Bun runtime, Hono web framework, Drizzle ORM, PostgreSQL 17, Zod validation, Handlebars templates, nodemailer
**Frontend:** Next.js 15 App Router, shadcn/ui (Tailwind CSS), next-themes (light/dark), React Hook Form + Zod, TanStack Table, date-fns, lucide-react icons
**Frontend:** Next.js 16 App Router (Turbopack), shadcn/ui (Tailwind CSS), next-themes, date-fns, lucide-react icons
**Fonts:** Inter (variable, with cv01+ss03 OpenType features), JetBrains Mono
**Fonts:** Inter (variable), JetBrains Mono
## Running Locally
@@ -34,50 +36,52 @@ tessera/
- Bun (`nix-shell -p bun` or install globally)
- Node.js 22+ (`nix-shell -p nodejs_22`)
- 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`
- PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=tessera -e POSTGRES_DB=tessera -p 127.0.0.1:5435:5432 postgres:17-alpine`
### Start backend
```bash
cd ~/projects/tessera
cp .env.example .env # Edit DATABASE_URL to point to postgres://tessera:***@127.0.0.1:5433/tessera
cp .env.example .env
npm run dev:backend # Starts API on port 9876
```
### Run migrations
```bash
npm run db:migrate
npm run db:seed # Optional demo data for UI review
npm run db:seed:reset # Reset local app data, then recreate demo data
npm run db:seed # Demo data
npm run db:seed:reset # Reset + re-seed
```
### Start frontend
```bash
cd web
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server)
npm run build # Production build
npm run start # Production server on 127.0.0.1:3100
npm install # Use npm, NOT bun
bun run dev # Dev server on 127.0.0.1:3100 (HMR)
```
**Note:** `bun run dev` (Turbopack) has WebSocket HMR issues in this environment. Use production mode only.
## API Endpoints
All endpoints are served by the backend on port 9876. The frontend proxies `/api/*` to the backend via `next.config.ts`.
All endpoints on port 9876. Frontend proxies `/api/*` via `next.config.ts`.
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| GET | /tickets | List tickets (?queue_id=&status=) |
| 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) |
| 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 | /queues | List/create queues |
| GET/POST/PATCH | /scrips | CRUD scrips |
| GET/POST | /custom-fields | List/create custom fields |
| GET/POST | /lifecycles | List/create lifecycles |
| 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
@@ -85,32 +89,22 @@ All endpoints are served by the backend on port 9876. The frontend proxies `/api
- **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.
- **Light mode is default.** Dark mode available via theme toggle (next-themes).
- **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`
Push via HTTPS with token auth (SSH port 2222 is not configured on Gitea):
```bash
git remote set-url origin https://gjermund:TOKEN@git.gjermund.xyz/gjermund/tessera.git
```
## Development Workflow
All code is written by **OpenCode** (AI coding agent). Hermes writes specs, OpenCode writes code, Gjermund reviews.
OpenCode server: `opencode serve --port 4096` (Gjermund attaches with `opencode --attach http://127.0.0.1:4096`)
After OpenCode makes changes:
1. `cd web && npx next build` — verify zero errors
2. `npm run start` — restart production server on 127.0.0.1:3100
3. `git push` — push to origin
## Common Issues
- **Frontend shows skeleton/blank page:** The frontend dev server (Turbopack) has WebSocket HMR issues. Use production mode (`npx next build` + `npx next start`).
- **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,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,12 @@
CREATE TABLE "transaction_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"transaction_id" uuid NOT NULL,
"filename" text NOT NULL,
"mime_type" text DEFAULT 'application/octet-stream' NOT NULL,
"size_bytes" integer DEFAULT 0 NOT NULL,
"storage_path" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "transaction_attachments" ADD CONSTRAINT "transaction_attachments_transaction_id_transactions_id_fk" FOREIGN KEY ("transaction_id") REFERENCES "public"."transactions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "transaction_attachments_tx_id_idx" ON "transaction_attachments" USING btree ("transaction_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "transaction_attachments" ALTER COLUMN "transaction_id" DROP NOT NULL;

View File

@@ -0,0 +1,15 @@
CREATE TABLE "ticket_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"ticket_id" integer NOT NULL,
"target_ticket_id" integer NOT NULL,
"link_type" text NOT NULL,
"creator_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "ticket_links_ticket_target_type_unique" UNIQUE("ticket_id","target_ticket_id","link_type")
);
--> statement-breakpoint
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_target_ticket_id_tickets_id_fk" FOREIGN KEY ("target_ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ticket_links_ticket_id_idx" ON "ticket_links" USING btree ("ticket_id");--> statement-breakpoint
CREATE INDEX "ticket_links_target_ticket_id_idx" ON "ticket_links" USING btree ("target_ticket_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "password_hash" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'staff' NOT NULL;

View File

@@ -0,0 +1,12 @@
CREATE TABLE "queue_permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"queue_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"right_name" text NOT NULL,
CONSTRAINT "queue_permissions_queue_team_right_unique" UNIQUE("queue_id","team_id","right_name")
);
--> statement-breakpoint
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "queue_permissions_queue_id_idx" ON "queue_permissions" USING btree ("queue_id");--> statement-breakpoint
CREATE INDEX "queue_permissions_team_id_idx" ON "queue_permissions" USING btree ("team_id");

View File

@@ -0,0 +1,12 @@
CREATE TABLE "user_permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"queue_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"right_name" text NOT NULL,
CONSTRAINT "user_permissions_queue_user_right_unique" UNIQUE("queue_id","user_id","right_name")
);
--> statement-breakpoint
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "user_permissions_queue_id_idx" ON "user_permissions" USING btree ("queue_id");--> statement-breakpoint
CREATE INDEX "user_permissions_user_id_idx" ON "user_permissions" USING btree ("user_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "transactions" ADD COLUMN "time_worked_minutes" integer DEFAULT 0;

View File

@@ -0,0 +1,15 @@
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"ticket_id" integer,
"type" text NOT NULL,
"title" text NOT NULL,
"body" text,
"read" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "notifications_user_read_idx" ON "notifications" USING btree ("user_id","read");

View File

@@ -0,0 +1,12 @@
CREATE TABLE "api_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "scrips" ADD COLUMN "applicable_trans_types" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "custom_fields" ADD COLUMN "validation_config" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
ALTER TABLE "custom_fields" ADD COLUMN "default_value" text;

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,118 @@
"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
},
{
"idx": 8,
"version": "7",
"when": 1781039674211,
"tag": "0008_sturdy_prism",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1781039770418,
"tag": "0009_tiny_lady_vermin",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1781040536590,
"tag": "0010_misty_morg",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1781042321413,
"tag": "0011_breezy_tyrannus",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781043175153,
"tag": "0012_living_photon",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1781043729230,
"tag": "0013_bored_silvermane",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1781045611610,
"tag": "0014_cloudy_siren",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781078349499,
"tag": "0015_tense_patch",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1781078511943,
"tag": "0016_famous_maximus",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781095552496,
"tag": "0017_redundant_the_renegades",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1781551130161,
"tag": "0018_dapper_jack_power",
"breakpoints": true
}
]
}
}

1783
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
},
"dependencies": {
"@types/nodemailer": "^8.0.0",
"jose": "^6.2.3",
"nodemailer": "^8.0.10"
}
}

28
scripts/seed-users.ts Normal file
View File

@@ -0,0 +1,28 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { users } from '../src/db/schema.ts';
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
const db = drizzle(pool);
const BATCH = 100;
const TOTAL = 1000;
const password = await Bun.password.hash('password');
console.log(`Inserting ${TOTAL} users...`);
for (let i = 0; i < TOTAL; i += BATCH) {
const batch = [];
for (let j = i; j < Math.min(i + BATCH, TOTAL); j++) {
const n = String(j).padStart(4, '0');
batch.push({
username: `user${n}`,
email: `user${n}@test.local`,
role: 'staff',
password_hash: password,
});
}
await db.insert(users).values(batch as any).onConflictDoNothing();
process.stdout.write('.');
}
console.log(`\nDone. ${TOTAL} users seeded.`);
await pool.end();

144
src/auth/middleware.ts Normal file
View File

@@ -0,0 +1,144 @@
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
import * as jose from 'jose';
import { config } from '../config.ts';
import type { Db } from '../db/index.ts';
import { users, apiTokens } from '../db/schema.ts';
import { eq } from 'drizzle-orm';
export interface AuthUser {
userId: string;
username: string;
role: string;
}
declare module 'hono' {
interface ContextVariableMap {
user: AuthUser;
}
}
const secret = new TextEncoder().encode(config.JWT_SECRET);
export async function createToken(user: { id: string; username: string; role: string }): Promise<string> {
return await new jose.SignJWT({ username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
}
async function verifyJwt(token: string): Promise<AuthUser | null> {
try {
const { payload } = await jose.jwtVerify(token, secret);
return {
userId: payload.sub!,
username: payload.username as string,
role: payload.role as string,
};
} catch {
return null;
}
}
async function verifyApiToken(db: Db, token: string): Promise<AuthUser | null> {
try {
// Find all tokens and verify against hash
const allTokens = await db.query.apiTokens.findMany();
for (const t of allTokens) {
const valid = await Bun.password.verify(token, t.token_hash);
if (valid) {
// Update last_used_at
await db.update(apiTokens)
.set({ last_used_at: new Date() } as any)
.where(eq(apiTokens.id, t.id));
const user = await db.query.users.findFirst({
where: eq(users.id, t.user_id),
});
if (user) {
return {
userId: user.id,
username: user.username,
role: user.role,
};
}
}
}
return null;
} catch {
return null;
}
}
function extractToken(c: Context): string | null {
const auth = c.req.header('Authorization');
if (auth?.startsWith('Bearer ')) {
return auth.slice(7);
}
const cookie = c.req.header('Cookie');
if (cookie) {
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
if (match?.[1]) return match[1];
}
return null;
}
export function createAuthMiddleware(db: Db) {
async function verifyToken(token: string): Promise<AuthUser | null> {
if (token.startsWith('tessera_')) {
return await verifyApiToken(db, token);
}
return await verifyJwt(token);
}
async function requireAuth(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const user = await verifyToken(token);
if (!user) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
c.set('user', user);
await next();
}
async function requireAdmin(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const user = await verifyToken(token);
if (!user) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
if (user.role !== 'admin') {
throw new HTTPException(403, { message: 'Admin access required' });
}
c.set('user', user);
await next();
}
async function optionalAuth(c: Context, next: Next) {
const token = extractToken(c);
if (token) {
const user = await verifyToken(token);
if (user) {
c.set('user', user);
}
}
await next();
}
return { requireAuth, requireAdmin, optionalAuth };
}
export function getUserId(c: Context): string {
const user = c.get('user');
return user?.userId ?? '00000000-0000-0000-0000-000000000000';
}

86
src/auth/permissions.ts Normal file
View File

@@ -0,0 +1,86 @@
import { HTTPException } from 'hono/http-exception';
import type { Context } from 'hono';
import type { Db } from '../db/index.ts';
import { teamMembers, queuePermissions, userPermissions } from '../db/schema.ts';
import { and, eq, inArray } from 'drizzle-orm';
import type { AuthUser } from './middleware.ts';
export type TicketRight = 'ticket.view' | 'ticket.create' | 'ticket.reply' | 'ticket.comment' | 'ticket.modify' | 'queue.admin';
const RIGHT_HIERARCHY: Record<TicketRight, TicketRight[]> = {
'queue.admin': ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'],
'ticket.modify': ['ticket.view', 'ticket.reply', 'ticket.comment', 'ticket.modify'],
'ticket.reply': ['ticket.view', 'ticket.reply'],
'ticket.comment': ['ticket.view', 'ticket.comment'],
'ticket.create': ['ticket.create'],
'ticket.view': ['ticket.view'],
};
/**
* Check whether a user has a specific right on a queue.
* Admins bypass all permission checks.
* Rights come from two sources: team memberships and per-user grants.
* Higher rights imply lower rights (e.g., queue.admin implies ticket.view).
*/
export async function userHasRight(
db: Db,
user: AuthUser,
queueId: string,
right: TicketRight,
): Promise<boolean> {
// Admins have all rights
if (user.role === 'admin') return true;
const neededRights = RIGHT_HIERARCHY[right] ?? [right];
// Check per-user permissions first (direct grant)
const userPerm = await db.query.userPermissions.findFirst({
where: (table, { and, eq: eqFn, inArray: inArr }) =>
and(
eqFn(table.user_id, user.userId),
eqFn(table.queue_id, queueId),
inArr(table.right_name, neededRights),
),
});
if (userPerm) return true;
// Check team permissions (inherited)
const memberships = await db.query.teamMembers.findMany({
where: eq(teamMembers.user_id, user.userId),
});
const teamIds = memberships.map((m) => m.team_id);
if (teamIds.length === 0) return false;
const teamPerm = await db.query.queuePermissions.findFirst({
where: (table, { and, eq: eqFn, inArray: inArr }) =>
and(
inArr(table.team_id, teamIds),
eqFn(table.queue_id, queueId),
inArr(table.right_name, neededRights),
),
});
return teamPerm !== undefined;
}
/**
* Require a specific right on a queue. Throws 403 if the user lacks the right.
*/
export async function requireRight(
c: Context,
db: Db,
queueId: string,
right: TicketRight,
): Promise<void> {
const user = c.get('user');
if (!user) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const has = await userHasRight(db, user, queueId, right);
if (!has) {
throw new HTTPException(403, { message: `Missing required right: ${right}` });
}
}

View File

@@ -9,6 +9,8 @@ const configSchema = z.object({
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('tessera@localhost'),
UPLOAD_DIR: z.string().default('./data/uploads'),
JWT_SECRET: z.string().default('tessera-dev-secret-change-in-production'),
});
export const config = configSchema.parse(process.env);

View File

@@ -1,10 +1,12 @@
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', {
id: uuid('id').primaryKey().defaultRandom(),
username: text('username').notNull().unique(),
email: text('email'),
password_hash: text('password_hash'),
role: text('role').notNull().default('staff'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
@@ -13,6 +15,7 @@ export const queues = pgTable('queues', {
name: text('name').notNull().unique(),
description: text('description'),
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(),
});
@@ -29,6 +32,7 @@ export const tickets = pgTable('tickets', {
queue_id: uuid('queue_id').notNull().references(() => queues.id),
status: text('status').notNull(),
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),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
@@ -47,6 +51,7 @@ export const transactions = pgTable('transactions', {
old_value: text('old_value'),
new_value: text('new_value'),
data: jsonb('data'),
time_worked_minutes: integer('time_worked_minutes').default(0),
creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
@@ -76,6 +81,7 @@ export const scrips = pgTable('scrips', {
stage: text('stage').notNull().default('TransactionCreate'),
sort_order: integer('sort_order').notNull().default(0),
disabled: boolean('disabled').notNull().default(false),
applicable_trans_types: text('applicable_trans_types'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
@@ -89,6 +95,8 @@ export const customFields = pgTable('custom_fields', {
values: jsonb('values'),
max_values: integer('max_values').notNull().default(1),
pattern: text('pattern'),
validation_config: jsonb('validation_config').default(sql`'{}'::jsonb`),
default_value: text('default_value'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});
@@ -112,3 +120,131 @@ export const customFieldValues = pgTable('custom_field_values', {
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),
}));
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 transactionAttachments = pgTable('transaction_attachments', {
id: uuid('id').primaryKey().defaultRandom(),
transaction_id: uuid('transaction_id').references(() => transactions.id, { onDelete: 'cascade' }),
filename: text('filename').notNull(),
mime_type: text('mime_type').notNull().default('application/octet-stream'),
size_bytes: integer('size_bytes').notNull().default(0),
storage_path: text('storage_path').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
transactionIdIdx: index('transaction_attachments_tx_id_idx').on(table.transaction_id),
}));
export const queuePermissions = pgTable('queue_permissions', {
id: uuid('id').primaryKey().defaultRandom(),
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
right_name: text('right_name').notNull(),
}, (table) => ({
uniqueRight: unique('queue_permissions_queue_team_right_unique').on(table.queue_id, table.team_id, table.right_name),
queueIdIdx: index('queue_permissions_queue_id_idx').on(table.queue_id),
teamIdIdx: index('queue_permissions_team_id_idx').on(table.team_id),
}));
export const apiTokens = pgTable('api_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
token_hash: text('token_hash').notNull().unique(),
last_used_at: timestamp('last_used_at', { withTimezone: true }),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.user_id),
}));
export const notifications = pgTable('notifications', {
id: uuid('id').primaryKey().defaultRandom(),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
ticket_id: integer('ticket_id').references(() => tickets.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'assigned', 'mentioned', 'commented', 'scrip_fired'
title: text('title').notNull(),
body: text('body'),
read: boolean('read').notNull().default(false),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
userIdIdx: index('notifications_user_id_idx').on(table.user_id),
unreadIdx: index('notifications_user_read_idx').on(table.user_id, table.read),
}));
export const userPermissions = pgTable('user_permissions', {
id: uuid('id').primaryKey().defaultRandom(),
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
right_name: text('right_name').notNull(),
}, (table) => ({
uniqueRight: unique('user_permissions_queue_user_right_unique').on(table.queue_id, table.user_id, table.right_name),
queueIdIdx: index('user_permissions_queue_id_idx').on(table.queue_id),
userIdIdx: index('user_permissions_user_id_idx').on(table.user_id),
}));
export const ticketLinks = pgTable('ticket_links', {
id: uuid('id').primaryKey().defaultRandom(),
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
target_ticket_id: integer('target_ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
link_type: text('link_type').notNull(),
creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
uniqueLink: unique('ticket_links_ticket_target_type_unique').on(table.ticket_id, table.target_ticket_id, table.link_type),
ticketIdIdx: index('ticket_links_ticket_id_idx').on(table.ticket_id),
targetTicketIdIdx: index('ticket_links_target_ticket_id_idx').on(table.target_ticket_id),
}));
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(),
});

View File

@@ -7,11 +7,17 @@ import {
customFieldValues,
lifecycles,
queueCustomFields,
queuePermissions,
queues,
scrips,
teamMembers,
teams,
templates,
tickets,
transactions,
views,
dashboards,
dashboardWidgets,
users,
} from './schema.ts';
@@ -49,7 +55,7 @@ function createSeedDb(pool: Pool) {
}
type Db = ReturnType<typeof createSeedDb>;
type UserSeed = { id: string; username: string; email: string };
type UserSeed = { id: string; username: string; email: string; role?: string; password_hash?: string };
type QueueSeed = { name: string; description: string };
type FieldSeed = {
key?: string;
@@ -70,12 +76,19 @@ function makeFieldKey(value: string): string {
}
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
const setData = {
username: seed.username,
email: seed.email,
role: seed.role ?? 'staff',
password_hash: seed.password_hash ?? null,
};
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 })
.set(setData)
.where(eq(users.id, seed.id));
return existingById.id;
}
@@ -85,12 +98,12 @@ async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
});
if (existingByUsername) {
await db.update(users)
.set({ email: seed.email })
.set(setData)
.where(eq(users.id, existingByUsername.id));
return existingByUsername.id;
}
const [created] = await db.insert(users).values(seed).returning();
const [created] = await db.insert(users).values({ ...seed, ...setData }).returning();
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
return created.id;
}
@@ -312,8 +325,12 @@ async function ensureTicket(
async function resetDatabase(db: Db) {
await db.delete(customFieldValues);
await db.delete(queuePermissions);
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);
@@ -340,34 +357,68 @@ async function main() {
await resetDatabase(db);
}
const userPassword = await Bun.password.hash('password');
const adminPassword = await Bun.password.hash('admin');
const userIds = {
system: await ensureUser(db, {
id: SYSTEM_USER_ID,
username: 'system',
email: 'system@tessera.local',
}),
admin: await ensureUser(db, {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
username: 'admin',
email: 'admin@tessera.local',
role: 'admin',
password_hash: adminPassword,
}),
dispatcher: await ensureUser(db, {
id: '11111111-1111-4111-8111-111111111111',
username: 'maria.dispatch',
email: 'maria.dispatch@tessera.local',
password_hash: userPassword,
}),
technician: await ensureUser(db, {
id: '22222222-2222-4222-8222-222222222222',
username: 'liam.field',
email: 'liam.field@tessera.local',
password_hash: userPassword,
}),
facilities: await ensureUser(db, {
id: '33333333-3333-4333-8333-333333333333',
username: 'nora.facilities',
email: 'nora.facilities@tessera.local',
password_hash: userPassword,
}),
security: await ensureUser(db, {
id: '44444444-4444-4444-8444-444444444444',
username: 'sam.security',
email: 'sam.security@tessera.local',
password_hash: userPassword,
}),
};
// Create demo team and assign all staff users to it
const [supportTeam] = await db.insert(teams).values({
name: 'Support team',
description: 'Demo support team with full queue access',
}).onConflictDoUpdate({
target: teams.name,
set: { description: 'Demo support team with full queue access' },
}).returning();
if (supportTeam) {
// Add all staff users to the team
const staffIds = [userIds.dispatcher, userIds.technician, userIds.facilities, userIds.security];
for (const userId of staffIds) {
await db.insert(teamMembers).values({
team_id: supportTeam.id,
user_id: userId,
}).onConflictDoNothing();
}
}
const lifecycle = await ensureLifecycle(db);
const supportQueue = await ensureQueue(db, lifecycle.id, {
@@ -390,30 +441,30 @@ async function main() {
const impactField = await ensureCustomField(db, {
key: 'impact',
name: 'Impact',
field_type: 'select',
field_type: 'SelectOne',
values: ['Low', 'Medium', 'High', 'Critical'],
});
const locationField = await ensureCustomField(db, {
key: 'location',
name: 'Location',
field_type: 'text',
field_type: 'Text',
});
const assetField = await ensureCustomField(db, {
key: 'asset_tag',
name: 'Asset tag',
field_type: 'text',
field_type: 'Text',
pattern: '^ASSET-[0-9]{4}$',
});
const channelField = await ensureCustomField(db, {
key: 'channel',
name: 'Channel',
field_type: 'select',
field_type: 'SelectOne',
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
});
const outcomeField = await ensureCustomField(db, {
key: 'resolution_outcome',
name: 'Resolution outcome',
field_type: 'select',
field_type: 'SelectOne',
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
});
@@ -426,6 +477,20 @@ async function main() {
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
// Grant the support team full access to all demo queues
if (supportTeam) {
const allRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify'];
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
for (const right of allRights) {
await db.insert(queuePermissions).values({
queue_id: queue.id,
team_id: supportTeam.id,
right_name: right,
}).onConflictDoNothing();
}
}
}
const resolveTemplate = await ensureTemplate(
db,
'Demo resolution note',
@@ -775,6 +840,56 @@ async function main() {
})));
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();

View File

@@ -4,6 +4,7 @@ import { createDb } from './db/index.ts';
import type { Db } from './db/index.ts';
import { errorHandler } from './middleware/error.ts';
import { requestLogger } from './middleware/logging.ts';
import { createAuthMiddleware } from './auth/middleware.ts';
import healthRouter from './routes/health.ts';
import { createTicketsRouter } from './routes/tickets.ts';
import { createQueuesRouter } from './routes/queues.ts';
@@ -12,6 +13,14 @@ import { createCustomFieldsRouter } from './routes/custom-fields.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';
import { createAttachmentsRouter } from './routes/attachments.ts';
import { createAuthRouter } from './routes/auth.ts';
import { createQueuePermissionsRouter } from './routes/queue-permissions.ts';
import { createNotificationsRouter } from './routes/notifications.ts';
import { startScheduler } from './scrip/scheduler.ts';
let db: Db | null = null;
@@ -27,14 +36,39 @@ const app = new Hono();
app.use('*', requestLogger);
app.onError(errorHandler);
const { requireAuth, requireAdmin } = createAuthMiddleware(getDb());
// Public routes
app.route('/health', healthRouter);
app.route('/tickets', createTicketsRouter(getDb()));
app.route('/queues', createQueuesRouter(getDb()));
app.route('/scrips', createScripsRouter(getDb()));
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
app.route('/lifecycles', createLifecyclesRouter(getDb()));
app.route('/users', createUsersRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb()));
app.route('/', createAuthRouter(getDb()));
// Ticket routes — require authentication
const ticketsWithAuth = new Hono();
ticketsWithAuth.use('*', requireAuth);
ticketsWithAuth.route('/tickets', createTicketsRouter(getDb()));
ticketsWithAuth.route('/', createNotificationsRouter(getDb()));
app.route('/', ticketsWithAuth);
// Attachment serving — require authentication
const attachmentsWithAuth = new Hono();
attachmentsWithAuth.use('*', requireAuth);
attachmentsWithAuth.route('/', createAttachmentsRouter(getDb()));
app.route('/', attachmentsWithAuth);
// Admin routes — require admin role
const admin = new Hono();
admin.use('*', requireAdmin);
admin.route('/queues', createQueuesRouter(getDb()));
admin.route('/scrips', createScripsRouter(getDb()));
admin.route('/custom-fields', createCustomFieldsRouter(getDb()));
admin.route('/lifecycles', createLifecyclesRouter(getDb()));
admin.route('/users', createUsersRouter(getDb()));
admin.route('/templates', createTemplatesRouter(getDb()));
admin.route('/views', createViewsRouter(getDb()));
admin.route('/dashboards', createDashboardsRouter(getDb()));
admin.route('/teams', createTeamsRouter(getDb()));
admin.route('/', createQueuePermissionsRouter(getDb()));
app.route('/', admin);
export default app;
export { app };
@@ -48,4 +82,7 @@ if (Bun.main === import.meta.path) {
development: false,
});
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
// Start the scrip scheduler (runs every 5 minutes)
startScheduler(getDb());
}

View File

@@ -5,13 +5,17 @@ export interface LifecycleDefinition {
inactive: string[];
};
transitions: Record<string, string[]>;
transition_rights?: Record<string, string>; // "from→to" → rightName
}
export interface ValidationResult {
valid: boolean;
error?: string;
requiredRight?: string; // Named right required for this transition, if any
}
const FALLBACK_RIGHT = 'ticket.modify';
export class LifecycleValidator {
validateTransition(
lifecycleDef: LifecycleDefinition,
@@ -35,13 +39,15 @@ export class LifecycleValidator {
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
if (allowedTransitions.includes(toStatus)) {
return { valid: true };
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
}
// Also handle wildcard "*" -> any transition
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
if (wildcardTransitions.includes(toStatus)) {
return { valid: true };
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
}
return {
@@ -50,6 +56,37 @@ export class LifecycleValidator {
};
}
/**
* Get the required right for a transition using RT's 4-level priority:
* 1. exact "from→to"
* 2. wildcard from "*→to"
* 3. wildcard to "from→*"
* 4. full wildcard "*→*"
* 5. fallback: ticket.modify
*/
getRequiredRight(
lifecycleDef: LifecycleDefinition,
fromStatus: string,
toStatus: string,
): string | null {
const rights = lifecycleDef.transition_rights ?? {};
// Priority 1: exact match
if (rights[`${fromStatus}${toStatus}`]) return rights[`${fromStatus}${toStatus}`];
// Priority 2: wildcard from
if (rights[`*→${toStatus}`]) return rights[`*→${toStatus}`];
// Priority 3: wildcard to
if (rights[`${fromStatus}→*`]) return rights[`${fromStatus}→*`];
// Priority 4: full wildcard
if (rights['*→*']) return rights['*→*'];
// Priority 5: fallback
return null;
}
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
return lifecycleDef.statuses.inactive.includes(status);
}
@@ -58,16 +95,12 @@ export class LifecycleValidator {
lifecycleDef: LifecycleDefinition,
fromStatus: string,
): string[] {
// Direct transition
if (lifecycleDef.transitions[fromStatus]) {
return lifecycleDef.transitions[fromStatus]!;
}
// Wildcard transitions
if (lifecycleDef.transitions['*']) {
return lifecycleDef.transitions['*']!;
}
return [];
}
}

View File

@@ -0,0 +1,157 @@
import type { CustomField } from './custom-field.ts';
export interface ValidationResult {
valid: boolean;
error?: string;
}
/**
* Dispatch to the correct type-specific validator based on field.field_type.
*/
export function validateCustomFieldValue(
field: CustomField,
value: string,
): ValidationResult {
if (!value) return { valid: true };
const config = (field.validation_config ?? {}) as Record<string, unknown>;
// Backward-compatible pattern check (field-level pattern, not validation_config)
if (field.pattern) {
try {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
return { valid: false, error: 'Value does not match the required pattern' };
}
} catch {
// Invalid regex — skip
}
}
switch (field.field_type) {
case 'Number':
return validateNumberValue(value, config);
case 'Date':
return validateDateValue(value, config);
case 'DateTime':
return validateDateTimeValue(value, config);
case 'SelectOne':
case 'SelectMultiple':
return validateSelectValue(value, field, config);
case 'Text':
case 'Textarea':
return validateTextValue(value, config);
default:
return { valid: true };
}
}
function validateNumberValue(
value: string,
config: Record<string, unknown>,
): ValidationResult {
const num = Number(value);
if (isNaN(num)) {
return { valid: false, error: 'Value must be a number' };
}
if (config.min !== undefined && num < Number(config.min)) {
return { valid: false, error: `Value must be at least ${config.min}` };
}
if (config.max !== undefined && num > Number(config.max)) {
return { valid: false, error: `Value must be at most ${config.max}` };
}
return { valid: true };
}
function validateDateValue(
value: string,
config: Record<string, unknown>,
): ValidationResult {
// Accept YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return { valid: false, error: 'Value must be a date in YYYY-MM-DD format' };
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return { valid: false, error: 'Invalid date' };
}
if (config.min_date) {
const minDate = new Date(String(config.min_date));
if (!isNaN(minDate.getTime()) && parsed < minDate) {
return { valid: false, error: `Date must be on or after ${config.min_date}` };
}
}
if (config.max_date) {
const maxDate = new Date(String(config.max_date));
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
return { valid: false, error: `Date must be on or before ${config.max_date}` };
}
}
return { valid: true };
}
function validateDateTimeValue(
value: string,
config: Record<string, unknown>,
): ValidationResult {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
return { valid: false, error: 'Value must be a valid ISO 8601 datetime' };
}
if (config.min_date) {
const minDate = new Date(String(config.min_date));
if (!isNaN(minDate.getTime()) && parsed < minDate) {
return { valid: false, error: `Datetime must be on or after ${config.min_date}` };
}
}
if (config.max_date) {
const maxDate = new Date(String(config.max_date));
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
return { valid: false, error: `Datetime must be on or before ${config.max_date}` };
}
}
return { valid: true };
}
function validateSelectValue(
value: string,
field: CustomField,
config: Record<string, unknown>,
): ValidationResult {
const allowed: string[] = [];
// Check validation_config.options first, then fall back to field.values
if (Array.isArray(config.options)) {
allowed.push(...config.options.map(String));
} else if (Array.isArray(field.values)) {
allowed.push(...field.values.map(String));
}
if (allowed.length > 0 && !allowed.includes(value)) {
return { valid: false, error: `Value is not an allowed option. Allowed: ${allowed.join(', ')}` };
}
return { valid: true };
}
function validateTextValue(
value: string,
config: Record<string, unknown>,
): ValidationResult {
if (config.min_length !== undefined && value.length < Number(config.min_length)) {
return { valid: false, error: `Value must be at least ${config.min_length} characters` };
}
if (config.max_length !== undefined && value.length > Number(config.max_length)) {
return { valid: false, error: `Value must be at most ${config.max_length} characters` };
}
if (config.pattern && typeof config.pattern === 'string') {
try {
const regex = new RegExp(config.pattern);
if (!regex.test(value)) {
return { valid: false, error: 'Value does not match the required pattern' };
}
} catch {
// Invalid regex — skip validation
}
}
return { valid: true };
}

View File

@@ -1,13 +1,71 @@
import type { InferSelectModel } from 'drizzle-orm';
import { z } from 'zod';
import { customFields } from '../db/schema.ts';
export type CustomField = InferSelectModel<typeof customFields>;
export const CustomFieldType = {
Text: 'Text',
Textarea: 'Textarea',
SelectOne: 'SelectOne',
SelectMultiple: 'SelectMultiple',
Text: 'Text',
Date: 'Date',
DateTime: 'DateTime',
Number: 'Number',
} as const;
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];
export const CUSTOM_FIELD_TYPES = Object.values(CustomFieldType) as [string, ...string[]];
// Validation config per type
export const NumberValidationConfig = z.object({
min: z.number().optional(),
max: z.number().optional(),
}).optional();
export const DateValidationConfig = z.object({
min_date: z.string().optional(),
max_date: z.string().optional(),
}).optional();
export const DateTimeValidationConfig = z.object({
min_date: z.string().optional(),
max_date: z.string().optional(),
}).optional();
export const TextValidationConfig = z.object({
min_length: z.number().int().min(0).optional(),
max_length: z.number().int().min(0).optional(),
pattern: z.string().optional(),
}).optional();
export const SelectValidationConfig = z.object({
options: z.array(z.string()).optional(),
}).optional();
// Generic validation config — permissive at the API boundary, refined per type in validation
export const CustomFieldValidationConfig = z.record(z.unknown()).nullable().default(null);
// Schemas for create/update
export const CreateCustomFieldSchema = z.object({
key: z.string().optional(),
name: z.string().min(1),
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]),
values: z.unknown().nullable().optional(),
max_values: z.number().int().min(1).optional().default(1),
pattern: z.string().nullable().optional(),
validation_config: z.record(z.unknown()).nullable().optional(),
default_value: z.string().nullable().optional(),
});
export const UpdateCustomFieldSchema = z.object({
key: z.string().optional(),
name: z.string().min(1).optional(),
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]).optional(),
values: z.unknown().nullable().optional(),
max_values: z.number().int().min(1).optional(),
pattern: z.string().nullable().optional(),
validation_config: z.record(z.unknown()).nullable().optional(),
default_value: z.string().nullable().optional(),
});

View File

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

View File

@@ -15,10 +15,13 @@ export const UpdateTicketSchema = z.object({
subject: z.string().min(1).optional(),
status: z.string().min(1).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),
attachment_ids: z.array(z.string()).optional(),
time_worked_minutes: z.number().int().min(0).optional(),
});

View File

@@ -11,6 +11,8 @@ export const TransactionType = {
Comment: 'Comment',
CustomField: 'CustomField',
Correspond: 'Correspond',
LinkCreate: 'LinkCreate',
LinkDelete: 'LinkDelete',
} as const;
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];

190
src/routes/attachments.ts Normal file
View File

@@ -0,0 +1,190 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
import { join, extname } from 'node:path';
import { writeFile, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts';
import { config } from '../config.ts';
import { transactionAttachments, transactions, tickets } from '../db/schema.ts';
import { eq, inArray } from 'drizzle-orm';
function ensureDir(dir: string) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function storageDir(): string {
const now = new Date();
const year = now.getFullYear().toString();
const month = String(now.getMonth() + 1).padStart(2, '0');
const dir = join(config.UPLOAD_DIR, year, month);
ensureDir(dir);
return dir;
}
const MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.csv': 'text/csv',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.mp4': 'video/mp4',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mov': 'video/quicktime',
'.avif': 'image/avif',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.log': 'text/plain',
'.md': 'text/markdown',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
};
function guessMimeType(filename: string): string {
const ext = extname(filename).toLowerCase();
return MIME_MAP[ext] || 'application/octet-stream';
}
export function createAttachmentsRouter(db: Db): Hono {
const router = new Hono();
// POST /tickets/:id/attachments — upload files (returns metadata, no transaction created yet)
router.post('/tickets/:id/attachments', async (c) => {
const ticketId = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const formData = await c.req.formData();
const files = formData.getAll('files') as File[];
if (files.length === 0) {
throw new HTTPException(422, { message: 'No files provided' });
}
const dir = storageDir();
const result: Array<{
id: string;
filename: string;
mime_type: string;
size_bytes: number;
}> = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const ext = extname(file.name);
const storedName = `${randomUUID()}${ext}`;
const storagePath = join(dir, storedName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(storagePath, buffer);
const [saved] = await db.insert(transactionAttachments).values({
filename: file.name,
mime_type: file.type || guessMimeType(file.name),
size_bytes: buffer.length,
storage_path: storagePath,
}).returning();
if (saved) {
result.push({
id: saved.id,
filename: saved.filename,
mime_type: saved.mime_type,
size_bytes: saved.size_bytes,
});
}
}
return c.json({ attachments: result }, 201);
});
// GET /attachments/:id — serve/download an attachment
router.get('/attachments/:id', async (c) => {
const attachmentId = c.req.param('id');
const attachment = await db.query.transactionAttachments.findFirst({
where: eq(transactionAttachments.id, attachmentId),
});
if (!attachment) {
throw new HTTPException(404, { message: 'Attachment not found' });
}
if (!existsSync(attachment.storage_path)) {
throw new HTTPException(404, { message: 'Attachment file not found on disk' });
}
const disposition = c.req.query('download') === 'true' ? 'attachment' : 'inline';
const stream = createReadStream(attachment.storage_path);
return new Response(stream as any, {
status: 200,
headers: {
'Content-Type': attachment.mime_type,
'Content-Disposition': `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`,
'Content-Length': String(attachment.size_bytes),
},
});
});
// GET /tickets/:id/attachments — list attachments for a ticket
router.get('/tickets/:id/attachments', async (c) => {
const ticketId = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const ticketTransactions = await db.query.transactions.findMany({
where: eq(transactions.ticket_id, ticketId),
});
const txIds = ticketTransactions.map((tx) => tx.id);
if (txIds.length === 0) {
return c.json([]);
}
const attachments = await Promise.all(
txIds.map((txId) =>
db.query.transactionAttachments.findMany({
where: eq(transactionAttachments.transaction_id, txId),
})
)
);
return c.json(attachments.flat());
});
return router;
}

132
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,132 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { z } from 'zod/v4';
import type { Db } from '../db/index.ts';
import { users, apiTokens } from '../db/schema.ts';
import { eq, desc, sql } from 'drizzle-orm';
import { createToken, createAuthMiddleware } from '../auth/middleware.ts';
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export function createAuthRouter(db: Db): Hono {
const router = new Hono();
const { requireAuth } = createAuthMiddleware(db);
// POST /auth/login
router.post('/auth/login', async (c) => {
const body = await c.req.json();
const parsed = LoginSchema.parse(body);
const user = await db.query.users.findFirst({
where: eq(users.username, parsed.username),
});
if (!user || !user.password_hash) {
throw new HTTPException(401, { message: 'Invalid username or password' });
}
const valid = await Bun.password.verify(parsed.password, user.password_hash);
if (!valid) {
throw new HTTPException(401, { message: 'Invalid username or password' });
}
const token = await createToken({
id: user.id,
username: user.username,
role: user.role,
});
return c.json({
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
});
});
// GET /auth/me — return current user from token
router.get('/auth/me', requireAuth, async (c) => {
const authUser = c.get('user');
const user = await db.query.users.findFirst({
where: eq(users.id, authUser.userId),
});
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({
id: user.id,
username: user.username,
email: user.email,
role: user.role,
});
});
// POST /auth/tokens — create API token
router.post('/auth/tokens', requireAuth, async (c) => {
const body = await c.req.json();
const name = String(body.name || 'API token').trim();
const authUser = c.get('user');
const rawToken = `tessera_${crypto.randomUUID().replace(/-/g, '')}`;
const tokenHash = await Bun.password.hash(rawToken);
const [token] = await db.insert(apiTokens).values({
user_id: authUser.userId,
name,
token_hash: tokenHash,
}).returning();
if (!token) {
throw new HTTPException(500, { message: 'Failed to create token' });
}
return c.json({
id: token.id,
name: token.name,
token: rawToken,
created_at: token.created_at,
}, 201);
});
// GET /auth/tokens — list tokens
router.get('/auth/tokens', requireAuth, async (c) => {
const authUser = c.get('user');
const result = await db.query.apiTokens.findMany({
where: eq(apiTokens.user_id, authUser.userId),
orderBy: desc(apiTokens.created_at),
});
return c.json(result.map((t) => ({
id: t.id,
name: t.name,
last_used_at: t.last_used_at,
created_at: t.created_at,
})));
});
// DELETE /auth/tokens/:id — revoke token
router.delete('/auth/tokens/:id', requireAuth, async (c) => {
const id = c.req.param('id');
const authUser = c.get('user');
// Verify ownership before revoke
const allTokens = await db.query.apiTokens.findMany();
const existing = allTokens.find((t) => t.id === id && t.user_id === authUser.userId);
if (!existing) {
throw new HTTPException(404, { message: 'Token not found' });
}
// Raw delete to avoid Drizzle type issue with new apiTokens table
await db.execute(sql`DELETE FROM api_tokens WHERE id = ${id}`);
return c.json({ ok: true });
});
return router;
}

View File

@@ -3,6 +3,8 @@ import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { customFields, queueCustomFields } from '../db/schema.ts';
import { and, asc, eq } from 'drizzle-orm';
import { CreateCustomFieldSchema, UpdateCustomFieldSchema } from '../models/custom-field.ts';
import { ZodError } from 'zod';
function makeFieldKey(value: string): string {
const key = value
@@ -13,6 +15,10 @@ function makeFieldKey(value: string): string {
return key || 'field';
}
function formatZodError(err: ZodError): string {
return err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
}
export function createCustomFieldsRouter(db: Db): Hono {
const router = new Hono();
@@ -25,20 +31,28 @@ export function createCustomFieldsRouter(db: Db): Hono {
router.post('/', async (c) => {
const body = await c.req.json();
const { name, field_type, values, max_values, pattern } = body;
const key = makeFieldKey(String(body.key ?? name ?? ''));
if (!name || !field_type) {
throw new HTTPException(400, { message: 'name and field_type are required' });
let parsed;
try {
parsed = CreateCustomFieldSchema.parse(body);
} catch (err) {
if (err instanceof ZodError) {
throw new HTTPException(400, { message: formatZodError(err) });
}
throw err;
}
const key = makeFieldKey(String(parsed.key ?? parsed.name ?? ''));
const [cf] = await db.insert(customFields).values({
key,
name,
field_type,
values: values ?? null,
max_values: max_values ?? 1,
pattern: pattern ?? null,
name: parsed.name,
field_type: parsed.field_type,
values: (parsed.values as any) ?? null,
max_values: parsed.max_values ?? 1,
pattern: parsed.pattern ?? null,
validation_config: (parsed.validation_config as any) ?? null,
default_value: parsed.default_value ?? null,
}).returning();
if (!cf) {
@@ -52,6 +66,16 @@ export function createCustomFieldsRouter(db: Db): Hono {
const id = c.req.param('id');
const body = await c.req.json();
let parsed;
try {
parsed = UpdateCustomFieldSchema.parse(body);
} catch (err) {
if (err instanceof ZodError) {
throw new HTTPException(400, { message: formatZodError(err) });
}
throw err;
}
const existing = await db.query.customFields.findFirst({
where: eq(customFields.id, id),
});
@@ -61,12 +85,18 @@ export function createCustomFieldsRouter(db: Db): Hono {
}
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;
if (parsed.key !== undefined) updateData.key = makeFieldKey(String(parsed.key));
if (parsed.name !== undefined) updateData.name = String(parsed.name);
if (parsed.field_type !== undefined) updateData.field_type = String(parsed.field_type);
if (parsed.values !== undefined) updateData.values = parsed.values ?? null;
if (parsed.max_values !== undefined) updateData.max_values = Number(parsed.max_values);
if (parsed.pattern !== undefined) updateData.pattern = parsed.pattern ? String(parsed.pattern) : null;
if (parsed.validation_config !== undefined) {
updateData.validation_config = (parsed.validation_config as any) ?? null;
}
if (parsed.default_value !== undefined) {
updateData.default_value = parsed.default_value ?? null;
}
const [updated] = await db.update(customFields)
.set(updateData)

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

@@ -0,0 +1,465 @@
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));
}
}
}
}
// Widget-level filters override or add to view filters
const widgetFilters = (widget.config as Record<string, unknown>)?.filters as Array<{ field: string; operator: string; value: string }> | undefined;
if (widgetFilters) {
for (const f of widgetFilters) {
if (f.field === 'status') {
if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value);
else result = result.filter((t) => t.status === f.value);
} else if (f.field === 'queue') {
if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value);
else result = result.filter((t) => t.queue_id === f.value);
} else if (f.field === 'owner') {
if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id);
else result = result.filter((t) => t.owner_id === f.value);
} else if (f.field === 'q') {
const q = f.value.toLowerCase();
result = result.filter((t) =>
t.subject.toLowerCase().includes(q) ||
String(t.id).includes(q) ||
(queueName.get(t.queue_id) ?? '').toLowerCase().includes(q)
);
}
}
}
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 'my_tickets': {
const authUser = c.get('user');
const myTickets = result.filter((t) => t.owner_id === authUser.userId);
return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id });
}
case 'trend_chart': {
const period = ((widget.config as Record<string, unknown>)?.period as string) ?? 'day';
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
const trendField = ((widget.config as Record<string, unknown>)?.field as string) ?? 'created_at';
const now = new Date();
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const filtered = result.filter((t) => {
const d = trendField === 'updated_at' ? t.updated_at : t.created_at;
return d && new Date(d) >= start;
});
const points: Record<string, number> = {};
for (const t of filtered) {
const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!);
let key: string;
if (period === 'week') {
const weekStart = new Date(d);
weekStart.setDate(d.getDate() - d.getDay());
key = weekStart.toISOString().slice(0, 10);
} else {
key = d.toISOString().slice(0, 10); // YYYY-MM-DD
}
points[key] = (points[key] ?? 0) + 1;
}
return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id });
}
case 'overdue': {
const dateFieldKey = (widget.config as Record<string, unknown>)?.field_key as string;
const now = new Date();
const overdue = result.filter((t) => {
if (!dateFieldKey) {
// No specific field — check if any inactive-adjacent status
const lc = lifecycleByQueue.get(t.queue_id);
if (lc) {
const inactive = lc.statuses.inactive;
if (inactive.includes(t.status)) return false; // already resolved
}
// Check if updated_at is older than 7 days
const updated = t.updated_at ? new Date(t.updated_at) : new Date(0);
return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000;
}
return false; // Would need CF value lookup for date field
});
return c.json({ type: 'overdue', total: overdue.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

@@ -0,0 +1,64 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { notifications } from '../db/schema.ts';
import { and, eq, desc } from 'drizzle-orm';
export function createNotificationsRouter(db: Db): Hono {
const router = new Hono();
// GET /notifications — list notifications for current user
router.get('/notifications', async (c) => {
const user = c.get('user');
const result = await db.query.notifications.findMany({
where: eq(notifications.user_id, user.userId),
orderBy: desc(notifications.created_at),
// Return last 50
});
return c.json(result.slice(0, 50));
});
// GET /notifications/unread-count
router.get('/notifications/unread-count', async (c) => {
const user = c.get('user');
const result = await db.query.notifications.findMany({
where: and(
eq(notifications.user_id, user.userId),
eq(notifications.read, false),
),
});
return c.json({ count: result.length });
});
// PATCH /notifications/:id/read — mark as read
router.patch('/notifications/:id/read', async (c) => {
const id = c.req.param('id');
await db.update(notifications).set({ read: true }).where(eq(notifications.id, id));
return c.json({ ok: true });
});
// PATCH /notifications/read-all — mark all as read
router.patch('/notifications/read-all', async (c) => {
const user = c.get('user');
await db.update(notifications)
.set({ read: true })
.where(eq(notifications.user_id, user.userId));
return c.json({ ok: true });
});
return router;
}
// Helper to create notifications (used by other routes)
export async function createNotification(
db: Db,
data: { user_id: string; ticket_id?: number; type: string; title: string; body?: string },
) {
await db.insert(notifications).values({
user_id: data.user_id,
ticket_id: data.ticket_id ?? null,
type: data.type,
title: data.title,
body: data.body ?? null,
});
}

View File

@@ -0,0 +1,176 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { queuePermissions, userPermissions, teams, queues, users } from '../db/schema.ts';
import { eq } from 'drizzle-orm';
export function createQueuePermissionsRouter(db: Db): Hono {
const router = new Hono();
// GET /queue-permissions — list all permissions (with team + queue names)
router.get('/queue-permissions', async (c) => {
const all = await db.query.queuePermissions.findMany();
// Enrich with names
const teamIds = [...new Set(all.map((p) => p.team_id))];
const queueIds = [...new Set(all.map((p) => p.queue_id))];
const teamList = teamIds.length > 0
? await db.query.teams.findMany({ where: (t, { inArray }) => inArray(t.id, teamIds) })
: [];
const queueList = queueIds.length > 0
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
: [];
const teamById = new Map(teamList.map((t) => [t.id, t]));
const queueById = new Map(queueList.map((q) => [q.id, q]));
const enriched = all.map((p) => ({
...p,
team_name: teamById.get(p.team_id)?.name ?? p.team_id,
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
}));
return c.json(enriched);
});
// GET /queue-permissions/teams-and-queues — return teams and queues for the form
router.get('/queue-permissions/teams-and-queues', async (c) => {
const [teamList, queueList] = await Promise.all([
db.query.teams.findMany(),
db.query.queues.findMany(),
]);
return c.json({ teams: teamList, queues: queueList });
});
// POST /queue-permissions — grant a right
router.post('/queue-permissions', async (c) => {
const body = await c.req.json();
const { queue_id, team_id, right_name } = body;
if (!queue_id || !team_id || !right_name) {
throw new HTTPException(422, { message: 'queue_id, team_id, and right_name are required' });
}
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
if (!validRights.includes(right_name)) {
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
}
// Check for duplicate
const existing = await db.query.queuePermissions.findFirst({
where: (table, { and, eq: eqFn }) =>
and(
eqFn(table.queue_id, queue_id),
eqFn(table.team_id, team_id),
eqFn(table.right_name, right_name),
),
});
if (existing) {
return c.json(existing); // Idempotent — return existing
}
const [perm] = await db.insert(queuePermissions).values({
queue_id,
team_id,
right_name,
}).returning();
if (!perm) {
throw new HTTPException(500, { message: 'Failed to create permission' });
}
return c.json(perm, 201);
});
// DELETE /queue-permissions/:id — revoke a right
router.delete('/queue-permissions/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.queuePermissions.findFirst({
where: eq(queuePermissions.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Permission not found' });
}
await db.delete(queuePermissions).where(eq(queuePermissions.id, id));
return c.json({ ok: true });
});
// GET /user-permissions — list all per-user permissions
router.get('/user-permissions', async (c) => {
const all = await db.query.userPermissions.findMany();
const userIds = [...new Set(all.map((p) => p.user_id))];
const queueIds = [...new Set(all.map((p) => p.queue_id))];
const userList = userIds.length > 0
? await db.query.users.findMany({ where: (t, { inArray }) => inArray(t.id, userIds) })
: [];
const queueList = queueIds.length > 0
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
: [];
const userById = new Map(userList.map((u) => [u.id, u]));
const queueById = new Map(queueList.map((q) => [q.id, q]));
const enriched = all.map((p) => ({
...p,
username: userById.get(p.user_id)?.username ?? p.user_id,
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
}));
return c.json(enriched);
});
// POST /user-permissions — grant a right to a user
router.post('/user-permissions', async (c) => {
const body = await c.req.json();
const { queue_id, user_id, right_name } = body;
if (!queue_id || !user_id || !right_name) {
throw new HTTPException(422, { message: 'queue_id, user_id, and right_name are required' });
}
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
if (!validRights.includes(right_name)) {
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
}
const existing = await db.query.userPermissions.findFirst({
where: (table, { and, eq: eqFn }) =>
and(
eqFn(table.queue_id, queue_id),
eqFn(table.user_id, user_id),
eqFn(table.right_name, right_name),
),
});
if (existing) return c.json(existing);
const [perm] = await db.insert(userPermissions).values({
queue_id,
user_id,
right_name,
}).returning();
if (!perm) {
throw new HTTPException(500, { message: 'Failed to create user permission' });
}
return c.json(perm, 201);
});
// DELETE /user-permissions/:id — revoke a user right
router.delete('/user-permissions/:id', async (c) => {
const id = c.req.param('id');
await db.delete(userPermissions).where(eq(userPermissions.id, id));
return c.json({ ok: true });
});
return router;
}

View File

@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
name: parsed.name,
description: parsed.description ?? null,
lifecycle_id: parsed.lifecycle_id ?? null,
team_id: parsed.team_id ?? null,
}).returning();
if (!queue) {
@@ -48,6 +49,7 @@ export function createQueuesRouter(db: Db): Hono {
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)

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;
}

View File

@@ -151,6 +151,21 @@ export function createTemplatesRouter(db: Db): Hono {
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 ?? '');

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { Hono } from 'hono';
import { asc } from 'drizzle-orm';
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';
@@ -13,5 +14,73 @@ export function createUsersRouter(db: Db): Hono {
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;
const role = body.role ? String(body.role) : 'staff';
const password = body.password ? String(body.password).trim() : null;
if (!username) {
throw new HTTPException(400, { message: 'username is required' });
}
const [user] = await db.insert(users).values({
username,
email,
role,
password_hash: password ? await Bun.password.hash(password) : null,
}).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;
if (body.role !== undefined) updateData.role = String(body.role);
if (body.password !== undefined && String(body.password).trim()) {
updateData.password_hash = await Bun.password.hash(String(body.password).trim());
}
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

@@ -5,6 +5,7 @@ import type { LifecycleDefinition } from '../lifecycle/validator.ts';
export interface ConditionEvaluateContext {
lifecycleDef?: LifecycleDefinition;
customFields?: Record<string, string>; // key → value map of CF values
}
export interface ConditionConfig {
@@ -16,6 +17,7 @@ export interface ConditionConfig {
old_value?: unknown;
new_value?: unknown;
value?: unknown;
link_type?: unknown;
}
export interface ConditionEvaluator {
@@ -82,11 +84,52 @@ export class OnCustomFieldChange implements ConditionEvaluator {
}
}
export class OnLinkCreate implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
return transactions.some((tx) => {
if (tx.transaction_type !== 'LinkCreate') return false;
if (config?.link_type) {
const linkType = tx.field;
if (!matchesStatusFilter(linkType, config.link_type)) return false;
}
return true;
});
}
}
export class OnOverdue implements ConditionEvaluator {
evaluate(_ticket: Ticket, _transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const fieldKey = config?.field_key ?? config?.field_id ?? config?.field;
if (!fieldKey) return false;
const cfValue = context?.customFields?.[String(fieldKey)];
if (!cfValue) return false;
// Parse the date value
const dueDate = new Date(cfValue);
if (isNaN(dueDate.getTime())) return false;
// Check if overdue (past due date)
if (new Date() <= dueDate) return false;
// Check that ticket is still active (not in inactive state)
const lifecycleDef = context?.lifecycleDef;
if (lifecycleDef) {
const inactiveStates = lifecycleDef.statuses.inactive;
if (inactiveStates.includes(_ticket.status)) return false;
}
return true;
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(),
OnCustomFieldChange: new OnCustomFieldChange(),
OnLinkCreate: new OnLinkCreate(),
OnOverdue: new OnOverdue(),
};
export function getConditionEvaluator(type: string): ConditionEvaluator | null {

View File

@@ -37,6 +37,7 @@ export class ScripEngine {
async prepare(
ticketId: number,
transactions: Transaction[],
stage: 'TransactionCreate' | 'TransactionBatch' = 'TransactionCreate',
): Promise<PreparedScrip[]> {
const ticketRecord = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
@@ -53,6 +54,15 @@ export class ScripEngine {
const matchingScrips = allScrips.filter((scrip) => {
if (scrip.disabled) return false;
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
if (scrip.stage !== stage) return false;
// Filter by applicable transaction types — if set, at least one tx must match
if (scrip.applicable_trans_types) {
const types = scrip.applicable_trans_types.split(',').map((t) => t.trim()).filter(Boolean);
if (types.length > 0 && !types.includes('Any')) {
const txTypes = new Set(transactions.map((tx) => tx.transaction_type));
if (!types.some((t) => txTypes.has(t))) return false;
}
}
return true;
});
@@ -70,10 +80,6 @@ export class ScripEngine {
}
}
const conditionContext: ConditionEvaluateContext = {
lifecycleDef,
};
const cfValues = await this.db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, ticketId),
});
@@ -94,6 +100,11 @@ export class ScripEngine {
}
}
const conditionContext: ConditionEvaluateContext = {
lifecycleDef,
customFields: customFieldsMap,
};
const prepared: PreparedScrip[] = [];
for (const scrip of matchingScrips) {

92
src/scrip/scheduler.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Db } from '../db/index.ts';
import { transactions, tickets, queues, lifecycles } from '../db/schema.ts';
import { eq, and, isNull } from 'drizzle-orm';
import { ScripEngine } from './engine.ts';
const SYSTEM_USER = '00000000-0000-0000-0000-000000000000';
/**
* Run scheduled scrips against all active tickets.
* Creates a synthetic "Scheduled" transaction so conditions like OnOverdue can fire.
*/
export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> {
const engine = new ScripEngine(db);
// Get all lifecycles to determine inactive statuses
const allLifecycles = await db.query.lifecycles.findMany();
const inactiveByQueue = new Map<string, Set<string>>();
// Get all queues with lifecycles
const allQueues = await db.query.queues.findMany();
for (const q of allQueues) {
if (q.lifecycle_id) {
const lc = allLifecycles.find((l) => l.id === q.lifecycle_id);
if (lc) {
const def = lc.definition as any;
inactiveByQueue.set(q.id, new Set(def?.statuses?.inactive ?? ['resolved', 'closed']));
}
}
}
// Find all potentially active tickets
const allTickets = await db.query.tickets.findMany();
const active = allTickets.filter((t) => {
const inactive = inactiveByQueue.get(t.queue_id);
if (inactive) return !inactive.has(t.status);
return !['resolved', 'closed'].includes(t.status);
});
let fired = 0;
for (const ticket of active) {
try {
// Create a synthetic Scheduled transaction
const [tx] = await db.insert(transactions).values({
ticket_id: ticket.id,
transaction_type: 'Comment' as any,
field: 'scheduled',
data: { body: 'Scheduled scrip evaluation' },
creator_id: SYSTEM_USER,
} as any).returning();
if (!tx) continue;
// Run scrips
const prepared = await engine.prepare(ticket.id, [tx as any]);
if (prepared.length > 0) {
const results = await engine.commit(prepared);
const successes = results.filter((r) => r.success);
if (successes.length > 0) fired += successes.length;
}
} catch (err) {
// Log and continue — don't let one failing ticket block the scheduler
console.error(`[scheduler] Error processing ticket ${ticket.id}:`, err instanceof Error ? err.message : String(err));
}
}
return { checked: active.length, fired };
}
/**
* Start the background scheduler. Runs every `intervalMinutes` minutes.
*/
export function startScheduler(db: Db, intervalMinutes = 5) {
console.log(`[scheduler] Starting scrip scheduler (every ${intervalMinutes}m)`);
const run = async () => {
try {
const result = await runScheduledScrips(db);
if (result.fired > 0) {
console.log(`[scheduler] Checked ${result.checked} tickets, fired ${result.fired} scrip actions`);
}
} catch (err) {
console.error('[scheduler] Error:', err instanceof Error ? err.message : String(err));
}
};
// Run once at startup after a short delay
setTimeout(run, 10000);
// Then run on interval
setInterval(run, intervalMinutes * 60 * 1000);
}

View File

@@ -1,5 +1,49 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# Tessera Design Rules
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.
<!-- END:nextjs-agent-rules -->
Follow these rules when writing any frontend code. The design is clean, modern, and minimal — avoid the "2015 admin panel" look.
## 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,598 @@
"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 { TrendChartWidget } from "@/components/widgets/trend-chart-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":
case "my_tickets":
return <CountWidget data={widget.data} />;
case "overdue":
return <CountWidget data={{ ...widget.data, type: "count" }} />;
case "ticket_list":
return <TicketListWidget data={widget.data} />;
case "status_chart":
return <StatusChartWidget data={widget.data} />;
case "grouped_counts":
return <GroupedCountsWidget data={widget.data} />;
case "trend_chart":
return <TrendChartWidget 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>
<option value="my_tickets">My tickets (auto-scoped)</option>
<option value="overdue">Overdue / stale</option>
<option value="trend_chart">Trend chart (bar)</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

@@ -4,6 +4,7 @@ import { Suspense } from "react";
import { ThemeProvider } from "next-themes";
import "./globals.css";
import { AppShell } from "@/components/app-shell";
import { AuthProvider } from "@/lib/auth-context";
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
@@ -31,9 +32,11 @@ export default function RootLayout({
style={{ fontSize: "15px", lineHeight: 1.5 }}
>
<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>
<AuthProvider>
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
<AppShell>{children}</AppShell>
</Suspense>
</AuthProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { LogInIcon } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const { login, user } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Already logged in
if (user) {
router.replace("/");
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password) return;
setLoading(true);
setError(null);
const result = await login(username.trim(), password);
setLoading(false);
if (result) {
setError(result);
} else {
router.push("/");
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background/80">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Tessera</h1>
<p className="mt-1.5 text-sm text-muted-foreground">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="username" className="text-[10px] font-medium text-muted-foreground">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-[10px] font-medium text-muted-foreground">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
/>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<button
type="submit"
disabled={loading || !username.trim() || !password}
className="flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
>
{loading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
) : (
<LogInIcon className="h-4 w-4" />
)}
Sign in
</button>
</form>
<p className="text-center text-[10px] text-muted-foreground/60">
Demo: admin / admin
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,11 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
BellIcon,
CircleIcon,
LayoutGridIcon,
UserIcon,
UsersIcon,
InboxIcon,
ClockIcon,
SettingsIcon,
@@ -13,11 +16,12 @@ import {
PanelLeftIcon,
CommandIcon,
} from "lucide-react";
import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types";
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
import { useAuth } from "@/lib/auth-context";
import { cn, formatTicketId } from "@/lib/utils";
const SidebarCollapsedContext = createContext(false);
@@ -52,22 +56,19 @@ function SidebarNavItem({
href={href}
title={collapsed ? label : undefined}
className={cn(
"group flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
"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-primary text-sidebar-primary-foreground font-semibold shadow-[inset_3px_0_0_color-mix(in_oklch,var(--sidebar-primary-foreground)_55%,transparent)]"
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
? "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", collapsed ? "" : "gap-2.5")}>
<Icon className="w-4 h-4 flex-shrink-0" />
{!collapsed && label}
<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={cn(
"min-w-5 rounded px-1 text-right text-[11px] tabular-nums",
active ? "text-sidebar-primary-foreground/80" : "text-sidebar-foreground/45"
)}>
<span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
{count}
</span>
)}
@@ -78,6 +79,7 @@ function SidebarNavItem({
function SidebarNav() {
const pathname = usePathname();
const searchParams = useSearchParams();
const { user: authUser } = useAuth();
const [counts, setCounts] = useState<ViewCounts>({
all: 0,
@@ -86,54 +88,90 @@ function SidebarNav() {
recent: 0,
});
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [myTeamId, setMyTeamId] = useState<string | null>(null);
const [newDashboardName, setNewDashboardName] = useState("");
const [addingDashboard, setAddingDashboard] = useState(false);
const currentUserId = authUser?.id ?? null;
useEffect(() => {
getTickets().then(({ data }) => {
async function load() {
const myId = currentUserId;
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
const data = ticketRes.data;
if (data) {
const now = Date.now();
const week = 7 * 24 * 60 * 60 * 1000;
setCounts({
all: data.length,
my: data.filter((t) => t.owner_id).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,
recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
});
}
});
getQueues().then(({ data }) => {
if (data) {
Promise.all(
data.map((q) =>
// 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,
}))
)
).then(setQueues);
);
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 = myId
? 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();
}, [currentUserId]);
const collapsed = useSidebarCollapsed();
const views = [
{
label: "All tickets",
href: "/",
param: null,
href: "/?view=all",
param: "all",
count: counts.all,
icon: LayoutGridIcon,
},
{
label: "My tickets",
href: "/?view=my",
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",
@@ -172,24 +210,107 @@ function SidebarNav() {
})}
</div>
{queues.length > 0 && (
<div>
{dashboards.length > 0 && (
<div className="mt-5">
{!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
<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;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
);
return (
<SidebarNavItem
key={queue.id}
href={`/?queue=${queue.id}`}
icon={QueueIcon}
icon={CircleIcon}
label={queue.name}
count={queue.count}
active={active}
@@ -205,40 +326,268 @@ function SidebarNav() {
function SidebarBottom() {
const pathname = usePathname();
const collapsed = useSidebarCollapsed();
const { user, logout, isAdmin } = useAuth();
const [tokenOpen, setTokenOpen] = useState(false);
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [newTokenName, setNewTokenName] = useState("");
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
const [tokenError, setTokenError] = useState<string | null>(null);
const loadTokens = async () => {
const { data } = await getApiTokens();
if (data) setTokens(data);
};
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
const handleCreateToken = async () => {
if (!newTokenName.trim()) return;
setTokenError(null);
const { data, error } = await createApiToken(newTokenName.trim());
if (error) { setTokenError(error); return; }
if (data) {
setNewTokenValue(data.token);
setNewTokenName("");
await loadTokens();
}
};
const handleRevoke = async (id: string) => {
await revokeApiToken(id);
await loadTokens();
};
return (
<div className="border-t border-sidebar-border p-2">
<SidebarNavItem
href="/admin"
icon={SettingsIcon}
label="Admin"
active={pathname === "/admin"}
/>
<div
className={cn(
"flex items-center mt-0.5 px-2 py-1.5",
collapsed ? "justify-center" : "gap-2"
)}
title={collapsed ? "User" : undefined}
>
<div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
<span className="text-sidebar-primary-foreground text-[10px] font-semibold">
U
</span>
</div>
{!collapsed && (
<span className="text-[13px] text-sidebar-foreground/65 truncate">
User
</span>
)}
</div>
<div className={cn("flex", collapsed ? "justify-center mt-1" : "mt-1")}>
<div className="border-t border-sidebar-border/50 p-2 space-y-1">
{isAdmin && (
<SidebarNavItem
href="/admin"
icon={SettingsIcon}
label="Admin"
active={pathname === "/admin"}
/>
)}
{user ? (
<>
{!collapsed && (
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
{user.username}
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
</div>
)}
<button
onClick={() => setTokenOpen(true)}
className={cn(
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
collapsed ? "justify-center" : ""
)}
>
<span className="opacity-50">API tokens</span>
</button>
<button
onClick={logout}
className={cn(
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
collapsed ? "justify-center" : ""
)}
>
<span className="opacity-50">Sign out</span>
</button>
{/* Token dialog */}
{tokenOpen && (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
<div className="border-b border-border/50 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
</div>
<div className="p-4 space-y-3">
{newTokenValue ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-xs font-semibold text-foreground">Token created copy it now:</p>
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
</div>
) : (
<div className="flex items-center gap-1.5">
<input
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
placeholder="Token name..."
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
/>
<button
onClick={handleCreateToken}
disabled={!newTokenName.trim()}
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
>
Create
</button>
</div>
)}
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
{tokens.length > 0 ? (
<div className="space-y-1">
{tokens.map((t) => (
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
<div>
<p className="text-xs font-medium">{t.name}</p>
<p className="text-[10px] text-muted-foreground">
Created {new Date(t.created_at).toLocaleDateString()}
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
</p>
</div>
<button
onClick={() => handleRevoke(t.id)}
className="text-[10px] text-muted-foreground hover:text-destructive"
>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
)}
</div>
</div>
</>
)}
</>
) : (
<SidebarNavItem
href="/login"
icon={UserIcon}
label="Sign in"
active={pathname === "/login"}
/>
)}
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
<ThemeToggle />
</div>
</div>
);
}
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
const [unread, setUnread] = useState(0);
const [notifs, setNotifs] = useState<Notification[]>([]);
const [open, setOpen] = useState(false);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
const load = async () => {
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
if (countRes.data) setUnread(countRes.data.count);
if (notifRes.data) setNotifs(notifRes.data);
};
void load();
// Poll every 30s
const interval = setInterval(() => { void load(); }, 30000);
return () => clearInterval(interval);
}, [user]);
if (!user) return null;
const handleMarkRead = async (id: string) => {
await markNotificationRead(id);
setUnread((c) => Math.max(0, c - 1));
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
};
const handleMarkAll = async () => {
await markAllNotificationsRead();
setUnread(0);
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
};
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
>
<BellIcon className="h-4 w-4" />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<span className="text-xs font-semibold text-foreground">Notifications</span>
{unread > 0 && (
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
Mark all read
</button>
)}
</div>
<div className="max-h-80 overflow-auto">
{notifs.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No notifications yet.
</div>
) : (
notifs.slice(0, 20).map((n) => (
<button
key={n.id}
onClick={() => {
handleMarkRead(n.id);
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
}}
className={cn(
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
!n.read && "bg-primary/5"
)}
>
<div className="flex items-start gap-2">
<div className={cn(
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
n.read ? "bg-border" : "bg-primary"
)} />
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">{n.title}</p>
{n.body && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
)}
{n.ticket_id && (
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{formatTicketId(n.ticket_id)}
</p>
)}
</div>
</div>
</button>
))
)}
</div>
</div>
</>
)}
{!collapsed && (
<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>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const [commandOpen, setCommandOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -267,43 +616,25 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Sidebar */}
<aside
className={cn(
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-150 shadow-[16px_0_42px_color-mix(in_oklch,var(--sidebar)_18%,transparent)]",
sidebarCollapsed ? "w-[60px]" : "w-60"
"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-14 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border">
<Link href="/" className="flex items-center gap-2">
<div className="w-7 h-7 rounded-md bg-sidebar-primary flex items-center justify-center shadow-[0_0_0_1px_color-mix(in_oklch,var(--sidebar-primary)_55%,white_20%)]">
<span className="text-sidebar-primary-foreground text-[12px] font-bold">
T
</span>
<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="leading-tight">
<span className="block font-semibold text-sidebar-foreground text-sm">
Tessera
</span>
<span className="block text-[10px] text-sidebar-foreground/45">
ScripFoundry
</span>
</span>
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
)}
</Link>
{!sidebarCollapsed && (
<button
onClick={() => setCommandOpen(true)}
className="flex h-7 items-center gap-1 rounded-md border border-sidebar-border px-2 text-[11px] text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
aria-label="Open command palette"
>
<CommandIcon className="h-3.5 w-3.5" />
K
</button>
)}
<NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-3 px-2">
<nav className="flex-1 overflow-y-auto py-2.5 px-2">
<Suspense
fallback={
<div className="space-y-1.5 px-2">

View File

@@ -0,0 +1,210 @@
"use client";
import { useState, useCallback } from "react";
import { GripVerticalIcon, XIcon, ArrowDownIcon } from "lucide-react";
export interface LayoutField {
key: string;
label: string;
width: number;
}
export interface SubtitleEntry {
key: string;
under: string; // which row1 column this subtitle field sits under
}
interface LayoutBuilderProps {
fields: LayoutField[];
row1: LayoutField[];
row2: SubtitleEntry[];
onChange: (row1: LayoutField[], row2: SubtitleEntry[]) => void;
onClose: () => void;
}
export function LayoutBuilder({ fields: allFields, row1, row2, onChange, onClose }: LayoutBuilderProps) {
const [dragKey, setDragKey] = useState<string | null>(null);
const handleDragStart = useCallback((e: React.DragEvent, key: string) => {
setDragKey(key);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", key);
}, []);
const handleDragEnd = useCallback(() => {
setDragKey(null);
}, []);
const makeRow1Drop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!key) return;
// Remove from row2 if present
const newRow2 = row2.filter((e) => e.key !== key);
const field = allFields.find((f) => f.key === key);
if (!field) return;
// Insert into row1 via drop position
const container = e.currentTarget;
const children = Array.from(container.children).filter((c) => (c as HTMLElement).dataset?.chipkey);
const mouseX = e.clientX;
let idx = children.length;
for (let i = 0; i < children.length; i++) {
const rect = (children[i] as HTMLElement).getBoundingClientRect();
if (mouseX < rect.left + rect.width / 2) { idx = i; break; }
}
const newRow1 = [...row1.filter((f) => f.key !== key)];
newRow1.splice(idx, 0, field);
onChange(newRow1, newRow2);
}, [allFields, row1, row2, onChange]);
const makeSubtitleDrop = useCallback((underCol: string) => {
return (e: React.DragEvent) => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!key || key === "subject") return;
// Remove from row1 if present
const newRow1 = row1.filter((f) => f.key !== key);
// Remove from row2 if present
const newRow2 = row2.filter((e) => e.key !== key);
// Add to row2 under this column
newRow2.push({ key, under: underCol });
onChange(newRow1, newRow2);
};
}, [row1, row2, onChange]);
const makePaletteDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!key) return;
onChange(
row1.filter((f) => f.key !== key),
row2.filter((e) => e.key !== key),
);
}, [row1, row2, onChange]);
const renderChip = (label: string, key: string) => (
<div
key={key}
data-chipkey={key}
draggable
onDragStart={(e) => handleDragStart(e, key)}
onDragEnd={handleDragEnd}
className="flex cursor-grab items-center gap-1 rounded border border-border/50 bg-card px-2 py-1 text-xs text-foreground shadow-sm transition-colors hover:border-primary/30 active:cursor-grabbing"
>
<GripVerticalIcon className="h-3 w-3 text-muted-foreground/50" />
{label}
</div>
);
// Fields not in row1 or row2
const usedKeys = new Set([...row1.map((f) => f.key), ...row2.map((e) => e.key)]);
const palette = allFields.filter((f) => !usedKeys.has(f.key) && f.key !== "subject");
return (
<>
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
<div className="fixed left-1/2 top-1/2 z-[9999] w-[560px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Layout builder</h3>
<button onClick={onClose} className="rounded text-muted-foreground hover:text-foreground">
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 p-4">
{/* Row 1 */}
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Main row</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={makeRow1Drop}
className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-dashed border-border/50 p-2 transition-colors"
>
{row1.length === 0 ? (
<span className="text-xs text-muted-foreground/50">Drop fields here</span>
) : (
row1.map((f) => renderChip(f.label, f.key))
)}
</div>
</div>
{/* Row 2 — subtitle fields under specific columns */}
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Subtitle (drop under a column)</div>
<div className="space-y-2">
{row1.map((col) => {
const entries = row2.filter((e) => e.under === col.key);
return (
<div key={col.key} className="flex items-start gap-2">
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
{col.label}
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={makeSubtitleDrop(col.key)}
className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1 transition-colors"
>
{entries.length === 0 ? (
<span className="text-[10px] text-muted-foreground/40">drop here</span>
) : (
entries.map((e) => {
const field = allFields.find((f) => f.key === e.key);
return renderChip(field?.label ?? e.key, e.key);
})
)}
</div>
</div>
);
})}
{/* Orphans: subtitle fields under columns not in row1 */}
{(() => {
const orphanEntries = row2.filter((e) => !row1.some((c) => c.key === e.under));
if (orphanEntries.length === 0) return null;
return (
<div className="flex items-start gap-2">
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
subject
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
</div>
<div className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1">
{orphanEntries.map((e) => {
const field = allFields.find((f) => f.key === e.key);
return renderChip(field?.label ?? e.key, e.key);
})}
</div>
</div>
);
})()}
</div>
</div>
{/* Palette */}
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Available</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={makePaletteDrop}
className="flex min-h-9 flex-wrap gap-1.5 rounded-md border border-dashed border-border/30 p-2 transition-colors"
>
{palette.length === 0 ? (
<span className="text-xs text-muted-foreground/50">All fields are placed</span>
) : (
palette.map((f) => renderChip(f.label, f.key))
)}
</div>
</div>
</div>
<div className="flex justify-end border-t border-border/50 px-4 py-2.5">
<button
onClick={onClose}
className="h-7 rounded-md bg-primary px-3 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
>
Done
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,398 @@
"use client";
import { useState } from "react";
import { XIcon, ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { Queue, CustomField, Template } from "@/lib/types";
interface Props {
open: boolean;
onClose: () => void;
error?: string | null;
onCreate: (data: {
name: string; condition_type: string; condition_config: Record<string, unknown>;
action_type: string; action_config: Record<string, unknown>;
template_id: string | null; queue_id: string | null;
stage: string; sort_order: number;
}) => Promise<void>;
queues: Queue[];
customFields: CustomField[];
templates: Template[];
}
const CONDITIONS = [
{ type: "OnCreate", icon: "", label: "Ticket created", desc: "When a new ticket is created" },
{ type: "OnStatusChange", icon: "🔄", label: "Status changes", desc: "When ticket status changes" },
{ type: "OnResolve", icon: "✅", label: "Ticket resolved", desc: "When a ticket is resolved" },
{ type: "OnCustomFieldChange", icon: "📝", label: "Custom field changes", desc: "When a field value changes" },
{ type: "OnLinkCreate", icon: "🔗", label: "Ticket linked", desc: "When linked to another ticket" },
{ type: "OnOverdue", icon: "⏰", label: "Ticket overdue", desc: "When a date field passes due" },
];
const ACTIONS = [
{ type: "SendEmail", icon: "📧", label: "Send email", desc: "Send a templated email notification" },
{ type: "SetCustomField", icon: "🏷️", label: "Set custom field", desc: "Update a field on the ticket" },
{ type: "Webhook", icon: "🌐", label: "Call webhook", desc: "POST to an external URL" },
{ type: "FetchMetadata", icon: "📡", label: "Fetch metadata", desc: "Pull data from an API" },
{ type: "RunScript", icon: "⚡", label: "Run script", desc: "Execute custom JavaScript" },
];
export function ScripWizard({ open, onClose, onCreate, queues, customFields, templates, error: externalError }: Props) {
const [step, setStep] = useState(1);
const [saving, setSaving] = useState(false);
// Trigger
const [conditionType, setConditionType] = useState("OnCreate");
const [fromStatus, setFromStatus] = useState("");
const [toStatus, setToStatus] = useState("");
const [conditionFieldKey, setConditionFieldKey] = useState("");
// Action
const [actionType, setActionType] = useState("SendEmail");
const [emailTo, setEmailTo] = useState("requestor");
const [emailRecipients, setEmailRecipients] = useState("");
const [emailSubject, setEmailSubject] = useState("");
const [emailBody, setEmailBody] = useState("");
const [templateId, setTemplateId] = useState("");
const [fieldKey, setFieldKey] = useState("");
const [fieldValue, setFieldValue] = useState("");
const [webhookUrl, setWebhookUrl] = useState("");
// Scope
const [name, setName] = useState("");
const [queueId, setQueueId] = useState("");
const [stage, setStage] = useState("TransactionCreate");
const handleCreate = async () => {
setSaving(true);
let conditionConfig: Record<string, unknown> = {};
if (conditionType === "OnStatusChange" || conditionType === "OnResolve") {
if (fromStatus) conditionConfig.from_status = fromStatus;
if (toStatus) conditionConfig.to_status = toStatus;
} else if (conditionType === "OnOverdue") {
if (conditionFieldKey) conditionConfig.field_key = conditionFieldKey;
}
let actionConfig: Record<string, unknown> = {};
if (actionType === "SendEmail") {
const sources = emailTo === "requestor" ? ["requestor"] : emailTo === "owner" ? ["owner"] : [];
actionConfig = {
recipients: emailRecipients ? emailRecipients.split(",").map((s) => s.trim()).filter(Boolean) : [],
recipient_sources: sources,
subject: emailSubject || "",
body: emailBody || "",
};
} else if (actionType === "SetCustomField") {
actionConfig = { field_key: fieldKey || "", value: fieldValue || "" };
} else if (actionType === "Webhook") {
actionConfig = { url: webhookUrl || "", method: "POST" };
}
await onCreate({
name: name || `Scrip: ${conditionType}${actionType}`,
condition_type: conditionType,
condition_config: conditionConfig,
action_type: actionType,
action_config: actionConfig,
template_id: templateId || null,
queue_id: queueId || null,
stage,
sort_order: 0,
});
setSaving(false);
reset();
};
const reset = () => {
setStep(1); setConditionType("OnCreate"); setFromStatus(""); setToStatus(""); setConditionFieldKey("");
setActionType("SendEmail"); setEmailTo("requestor"); setEmailRecipients(""); setEmailSubject("");
setEmailBody(""); setTemplateId(""); setFieldKey(""); setFieldValue(""); setWebhookUrl("");
setName(""); setQueueId(""); setStage("TransactionCreate");
};
if (!open) return null;
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
const dateFields = customFields.filter((cf) => {
const ft = cf.field_type.toLowerCase();
return ft === "date" || ft === "datetime";
});
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
return (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { onClose(); reset(); }} />
<div className="fixed left-1/2 top-1/2 z-50 w-[600px] max-h-[80vh] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-lg border border-border bg-popover shadow-xl">
{/* Header with steps */}
<div className="border-b border-border/50 px-6 py-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">New automation</h2>
<button onClick={() => { onClose(); reset(); }} className="text-muted-foreground hover:text-foreground">
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex items-center gap-2">
{stepLabels.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold",
step > i + 1 ? "bg-primary text-primary-foreground" :
step === i + 1 ? "bg-primary text-primary-foreground ring-2 ring-primary/30" :
"bg-muted text-muted-foreground"
)}>
{step > i + 1 ? <CheckIcon className="h-3 w-3" /> : i + 1}
</div>
<span className={cn("text-[11px] font-medium", step === i + 1 ? "text-foreground" : "text-muted-foreground")}>{label}</span>
{i < 3 && <div className="h-px w-6 bg-border" />}
</div>
))}
</div>
</div>
<div className="p-6">
{/* Step 1: Trigger */}
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">When should this automation run?</p>
<div className="grid grid-cols-2 gap-2">
{CONDITIONS.map((c) => (
<button
key={c.type}
type="button"
onClick={() => { setConditionType(c.type); setFromStatus(""); setToStatus(""); setConditionFieldKey(""); }}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
conditionType === c.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
)}
>
<span className="text-lg">{c.icon}</span>
<div className="mt-1 text-sm font-semibold">{c.label}</div>
<div className="text-[11px] text-muted-foreground">{c.desc}</div>
</button>
))}
</div>
{(conditionType === "OnStatusChange" || conditionType === "OnResolve") && (
<div className="flex gap-2 pt-2">
<div className="flex-1">
<Label className="text-[10px]">From status</Label>
<Select value={fromStatus || "_any"} onValueChange={(v) => setFromStatus((v === "_any" || !v) ? "" : v)}>
<SelectTrigger className="h-8"><SelectValue placeholder="Any status" /></SelectTrigger>
<SelectContent>
<SelectItem value="_any">Any status</SelectItem>
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label className="text-[10px]">To status</Label>
<Select value={toStatus || "_any"} onValueChange={(v) => setToStatus((v === "_any" || !v) ? "" : v)}>
<SelectTrigger className="h-8"><SelectValue placeholder={conditionType === "OnResolve" ? "Any resolved" : "Any status"} /></SelectTrigger>
<SelectContent>
<SelectItem value="_any">{conditionType === "OnResolve" ? "Any resolved" : "Any status"}</SelectItem>
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
)}
{conditionType === "OnOverdue" && dateFields.length > 0 && (
<div>
<Label className="text-[10px]">Date field to check</Label>
<Select value={conditionFieldKey} onValueChange={(v) => setConditionFieldKey(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a date field..." /></SelectTrigger>
<SelectContent>
{dateFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* Step 2: Action */}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">What should happen when triggered?</p>
<div className="grid grid-cols-2 gap-2">
{ACTIONS.map((a) => (
<button
key={a.type}
type="button"
onClick={() => setActionType(a.type)}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
actionType === a.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
)}
>
<span className="text-lg">{a.icon}</span>
<div className="mt-1 text-sm font-semibold">{a.label}</div>
<div className="text-[11px] text-muted-foreground">{a.desc}</div>
</button>
))}
</div>
</div>
)}
{/* Step 3: Configure */}
{step === 3 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Configure the details.</p>
{actionType === "SendEmail" && (
<div className="space-y-3">
<div>
<Label>Recipients</Label>
<Select value={emailTo} onValueChange={(v) => setEmailTo(v ?? "requestor")}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="requestor">Ticket requestor (creator)</SelectItem>
<SelectItem value="owner">Ticket owner</SelectItem>
<SelectItem value="manual">Custom recipients</SelectItem>
</SelectContent>
</Select>
</div>
{emailTo === "manual" && (
<div>
<Label>Email addresses (comma-separated)</Label>
<Input placeholder="user@example.com, other@example.com" value={emailRecipients} onChange={(e) => setEmailRecipients(e.target.value)} />
</div>
)}
<div>
<Label>Subject</Label>
<Input placeholder="Ticket #{{ticket.id}}: {{ticket.subject}}" value={emailSubject} onChange={(e) => setEmailSubject(e.target.value)} />
</div>
<div>
<Label>Body</Label>
<Textarea rows={3} placeholder="The ticket has been updated..." value={emailBody} onChange={(e) => setEmailBody(e.target.value)} />
<p className="mt-1 text-[10px] text-muted-foreground">Variables: {"{{ticket.id}} {{ticket.subject}} {{ticket.status}} {{queue.name}} {{transaction.old_value}} {{transaction.new_value}}"}</p>
</div>
<div>
<Label>Template (optional)</Label>
<Select value={templateId} onValueChange={(v) => setTemplateId(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="No template — use subject/body above" /></SelectTrigger>
<SelectContent>
<SelectItem value="">No template</SelectItem>
{emailTemplates.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
)}
{actionType === "SetCustomField" && (() => {
const selectedField = customFields.find((cf) => cf.key === fieldKey);
const fieldOptions: string[] = Array.isArray(selectedField?.values) ? selectedField.values.map((v: any) => String(v)) : [];
return (
<div className="flex gap-2">
<div className="flex-1">
<Label>Field</Label>
<Select value={fieldKey} onValueChange={(v) => { setFieldKey((v && v !== "_any") ? v : ""); setFieldValue(""); }}>
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a field" /></SelectTrigger>
<SelectContent>
{customFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label>Value</Label>
{fieldOptions.length > 0 ? (
<Select value={fieldValue} onValueChange={(v) => setFieldValue(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a value" /></SelectTrigger>
<SelectContent>
{fieldOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input placeholder="Value to set" value={fieldValue} onChange={(e) => setFieldValue(e.target.value)} />
)}
</div>
</div>
);
})()}
{actionType === "Webhook" && (
<div>
<Label>URL</Label>
<Input placeholder="https://hooks.slack.com/..." value={webhookUrl} onChange={(e) => setWebhookUrl(e.target.value)} />
</div>
)}
<div className="border-t border-border/30 pt-3 space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<Label>Name (optional)</Label>
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex-1">
<Label>Queue scope</Label>
<Select value={queueId} onValueChange={(v) => setQueueId(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="All queues" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All queues (global)</SelectItem>
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
)}
{/* Step 4: Review */}
{step === 4 && (
<div className="space-y-4">
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm space-y-2">
<div className="text-[10px] font-semibold uppercase text-muted-foreground/60">Summary</div>
<p><strong>When:</strong> {CONDITIONS.find((c) => c.type === conditionType)?.label}</p>
<p><strong>Then:</strong> {ACTIONS.find((a) => a.type === actionType)?.label}</p>
{queueId && <p><strong>Queue:</strong> {selectedQueue?.name}</p>}
<p><strong>Stage:</strong> {stage === "TransactionCreate" ? "Per transaction" : "After batch"}</p>
</div>
<div>
<Label>Name</Label>
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
</div>
</div>
)}
</div>
{/* Error */}
{externalError && (
<div className="border-t border-destructive/20 bg-destructive/10 px-6 py-2 text-sm text-destructive">{externalError}</div>
)}
{/* Footer */}
<div className="flex justify-between border-t border-border/50 px-6 py-3">
<div>
{step > 1 && (
<Button variant="outline" size="sm" onClick={() => setStep(step - 1)}>
<ArrowLeftIcon className="h-3.5 w-3.5 mr-1" /> Back
</Button>
)}
</div>
<div>
{step < 4 ? (
<Button size="sm" onClick={() => setStep(step + 1)}>
Next <ArrowRightIcon className="h-3.5 w-3.5 ml-1" />
</Button>
) : (
<Button size="sm" className="bg-primary" disabled={saving} onClick={handleCreate}>
<CheckIcon className="h-3.5 w-3.5 mr-1" />
{saving ? "Creating..." : "Create automation"}
</Button>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectOption {
value: string;
label: string;
}
interface SearchableSelectProps {
options: SelectOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
disabled?: boolean;
className?: string;
allowClear?: boolean;
clearLabel?: string;
}
export function SearchableSelect({
options,
value,
onChange,
placeholder = "Select...",
searchPlaceholder = "Search...",
disabled = false,
className,
allowClear = true,
clearLabel = "None",
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [highlightIdx, setHighlightIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
const filtered = search.trim()
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
: options;
// Reset highlight when search changes
useEffect(() => {
setHighlightIdx(0);
}, [search]);
// Focus search input when opened
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
setSearch("");
}
}, [open]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const select = useCallback((optValue: string) => {
onChange(optValue);
setOpen(false);
}, [onChange]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIdx((prev) => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIdx((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[highlightIdx]) {
select(filtered[highlightIdx].value);
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className={cn(
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2.5 text-sm outline-none transition-colors",
disabled ? "opacity-50 cursor-not-allowed" : "hover:border-ring/50 focus:border-ring",
!selected && "text-muted-foreground"
)}
>
<span className="truncate">{selected?.label ?? placeholder}</span>
<ChevronDownIcon className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-lg">
<div className="flex items-center gap-1.5 border-b border-border/50 px-2">
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="h-8 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
{search && (
<button onClick={() => setSearch("")} className="shrink-0 text-muted-foreground hover:text-foreground">
<XIcon className="h-3 w-3" />
</button>
)}
</div>
<div className="max-h-48 overflow-auto">
{allowClear && (
<button
type="button"
onClick={() => select("")}
className="flex w-full items-center gap-2 border-b border-border/30 px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
>
{clearLabel}
</button>
)}
{filtered.length === 0 ? (
<div className="px-2.5 py-3 text-center text-xs text-muted-foreground">
No results
</div>
) : (
filtered.map((opt, idx) => (
<button
key={opt.value || "__empty__"}
type="button"
onClick={() => select(opt.value)}
className={cn(
"flex w-full items-center px-2.5 py-1.5 text-xs transition-colors",
idx === highlightIdx ? "bg-accent text-foreground" : "text-foreground hover:bg-accent/50",
opt.value === value && "font-semibold"
)}
>
{opt.label}
</button>
))
)}
</div>
</div>
)}
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import type { WidgetData } from "@/lib/types";
export function TrendChartWidget({ data }: { data: WidgetData }) {
const points = data.counts ?? {};
const entries = Object.entries(points).sort(([a], [b]) => a.localeCompare(b));
const maxVal = Math.max(1, ...Object.values(points));
return (
<div className="flex h-full flex-col rounded-lg border border-border/50 bg-card p-3">
<div className="mb-1 text-[10px] font-semibold uppercase text-muted-foreground/60">
{data.title}
</div>
<div className="text-lg font-bold text-foreground tabular-nums">{data.total}</div>
<div className="mt-2 flex flex-1 items-end gap-px">
{entries.length === 0 ? (
<span className="text-xs text-muted-foreground">No data</span>
) : (
entries.map(([label, count]) => {
const h = Math.max(4, (count / maxVal) * 100);
return (
<div
key={label}
className="flex flex-1 flex-col items-center justify-end"
title={`${label}: ${count}`}
>
<div className="text-[9px] tabular-nums text-muted-foreground">{count}</div>
<div
className="w-full min-w-[3px] rounded-t bg-primary/60"
style={{ height: `${h}%` }}
/>
</div>
);
})
)}
</div>
<div className="mt-1 text-[9px] text-muted-foreground/50 text-right">
{entries.length > 0 && `${entries[0]?.[0]}${entries[entries.length - 1]?.[0]}`}
</div>
</div>
);
}

View File

@@ -1,8 +1,13 @@
import type {
Ticket,
Queue,
Dashboard,
DashboardWidget,
WidgetData,
Team,
User,
Transaction,
SavedView,
Scrip,
Template,
TemplatePreview,
@@ -12,15 +17,30 @@ import type {
QueueCustomField,
PreviewResult,
UpdateResult,
Attachment,
AttachmentUploadResult,
TicketLink,
LoginResult,
} from "./types";
const BASE_URL = "/api";
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
try {
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
// Merge with options headers if any
const opts = { ...options };
if (opts.headers) {
Object.assign(headers, opts.headers as Record<string, string>);
delete opts.headers;
}
const res = await fetch(`${BASE_URL}${url}`, {
headers: { "Content-Type": "application/json" },
...options,
headers,
...opts,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
@@ -38,13 +58,21 @@ export async function getTickets(params?: {
status?: string;
q?: string;
owner_id?: string;
team_id?: string;
custom_fields?: Record<string, string>;
subject?: string;
created?: string;
updated?: 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?.subject) sp.set("subject", params.subject);
if (params?.created) sp.set("created", params.created);
if (params?.updated) sp.set("updated", params.updated);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
@@ -67,7 +95,7 @@ export async function createTicket(data: {
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 }): Promise<{ data: UpdateResult | null; error: string | null }> {
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) });
}
@@ -79,10 +107,80 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
return request<Transaction[]>(`/tickets/${id}/transactions`);
}
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
}
export async function batchUpdateTickets(data: {
ticket_ids: number[];
status?: string;
owner_id?: string | null;
team_id?: string | null;
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
}
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
}
// Notifications
export interface Notification {
id: string;
user_id: string;
ticket_id: number | null;
type: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
}
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
return request<Notification[]>("/notifications");
}
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
return request<{ count: number }>("/notifications/unread-count");
}
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
}
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
}
// API Tokens
export interface ApiToken {
id: string;
name: string;
last_used_at: string | null;
created_at: string;
}
export interface ApiTokenCreated {
id: string;
name: string;
token: string;
created_at: string;
}
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
return request<ApiToken[]>("/auth/tokens");
}
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
}
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
}
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
return request<Queue[]>("/queues");
}
@@ -91,11 +189,33 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
return request<User[]>("/users");
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
export async function createUser(data: {
username: string;
email?: string | null;
role?: string;
password?: 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;
role?: string;
password?: 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 }): Promise<{ data: Queue | null; error: string | null }> {
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) });
}
@@ -165,6 +285,10 @@ export async function previewTemplate(data: {
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");
}
@@ -216,6 +340,8 @@ export async function createCustomField(data: {
values?: unknown | null;
max_values?: number;
pattern?: string | null;
validation_config?: Record<string, unknown> | null;
default_value?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
}
@@ -227,6 +353,326 @@ export async function updateCustomField(id: string, data: {
values?: unknown | null;
max_values?: number;
pattern?: string | null;
validation_config?: Record<string, unknown> | null;
default_value?: 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; display: string }[];
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" });
}
export async function uploadAttachments(
ticketId: number,
files: File[],
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
try {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
method: "POST",
headers,
body: formData,
});
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 function getAttachmentUrl(attachmentId: string): string {
return `/api/attachments/${attachmentId}`;
}
export async function getTicketAttachments(
ticketId: number,
): Promise<{ data: Attachment[] | null; error: string | null }> {
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
}
export async function getTicketLinks(
ticketId: number,
): Promise<{ data: TicketLink[] | null; error: string | null }> {
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
}
export async function createTicketLink(
ticketId: number,
data: { target_ticket_id: number; link_type: string },
): Promise<{ data: TicketLink | null; error: string | null }> {
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
}
export async function deleteTicketLink(
ticketId: number,
linkId: string,
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
}
// Queue Permissions (admin)
export interface QueuePermission {
id: string;
queue_id: string;
team_id: string;
right_name: string;
team_name?: string;
queue_name?: string;
}
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
return request<QueuePermission[]>("/queue-permissions");
}
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
}
export async function grantQueuePermission(
queue_id: string,
team_id: string,
right_name: string,
): Promise<{ data: QueuePermission | null; error: string | null }> {
return request<QueuePermission>("/queue-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, team_id, right_name }),
});
}
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
}
// User Permissions (admin)
export interface UserPermission {
id: string;
queue_id: string;
user_id: string;
right_name: string;
username?: string;
queue_name?: string;
}
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
return request<UserPermission[]>("/user-permissions");
}
export async function grantUserPermission(
queue_id: string,
user_id: string,
right_name: string,
): Promise<{ data: UserPermission | null; error: string | null }> {
return request<UserPermission>("/user-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, user_id, right_name }),
});
}
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
}
// Auth
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("tessera_token");
}
export function setStoredToken(token: string | null) {
if (typeof window === "undefined") return;
if (token) {
localStorage.setItem("tessera_token", token);
} else {
localStorage.removeItem("tessera_token");
}
}
export async function login(
username: string,
password: string,
): Promise<{ data: LoginResult | null; error: string | null }> {
const result = await request<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (result.data?.token) {
setStoredToken(result.data.token);
}
return result;
}
export function logout() {
setStoredToken(null);
}
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
const token = getStoredToken();
if (!token) return { data: null, error: "Not authenticated" };
try {
const res = await fetch(`/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
setStoredToken(null);
return { data: null, error: "Session expired" };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
/**
* Fetch wrapper that includes the auth token.
*/
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
const token = getStoredToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
setStoredToken(null);
}
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" };
}
}

View File

@@ -0,0 +1,61 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
import { login as apiLogin, logout as apiLogout, getMe } from "./api";
import type { User, LoginResult } from "./types";
interface AuthState {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<string | null>;
logout: () => void;
isAdmin: boolean;
}
const AuthContext = createContext<AuthState>({
user: null,
loading: true,
login: async () => null,
logout: () => {},
isAdmin: false,
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Check existing session on mount
useEffect(() => {
void Promise.resolve().then(async () => {
const { data } = await getMe();
if (data) {
setUser(data);
}
setLoading(false);
});
}, []);
const login = useCallback(async (username: string, password: string): Promise<string | null> => {
const { data, error } = await apiLogin(username, password);
if (error || !data) {
return error || "Login failed";
}
setUser(data.user);
return null; // null = success
}, []);
const logout = useCallback(() => {
apiLogout();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin: user?.role === "admin" }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -4,12 +4,14 @@ export interface Ticket {
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[];
blocked_by?: Array<{ id: number; subject: string; status: string }>;
}
export interface Queue {
@@ -17,15 +19,22 @@ export interface Queue {
name: string;
description: string | null;
lifecycle_id: string | null;
team_id: string | null;
}
export interface User {
id: string;
username: string;
email: string | null;
role: string;
created_at: string;
}
export interface LoginResult {
token: string;
user: User;
}
export interface Transaction {
id: string;
ticket_id: number;
@@ -34,8 +43,10 @@ export interface Transaction {
old_value: string | null;
new_value: string | null;
data: unknown;
time_worked_minutes: number;
creator_id: string;
created_at: string;
attachments?: Attachment[];
}
export interface Scrip {
@@ -51,6 +62,7 @@ export interface Scrip {
stage: string;
sort_order: number;
disabled: boolean;
applicable_trans_types: string | null;
created_at: string;
}
@@ -88,8 +100,22 @@ export interface CustomField {
values: unknown | null;
max_values: number;
pattern: string | null;
validation_config: Record<string, unknown> | null;
default_value: string | null;
}
export const CUSTOM_FIELD_TYPES = [
'Text',
'Textarea',
'SelectOne',
'SelectMultiple',
'Date',
'DateTime',
'Number',
] as const;
export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number];
export interface QueueCustomField {
id: string;
queue_id: string;
@@ -128,3 +154,117 @@ export interface ScripResult {
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;
config?: Record<string, unknown>;
}
export interface Attachment {
id: string;
transaction_id: string | null;
filename: string;
mime_type: string;
size_bytes: number;
storage_path: string;
created_at: string;
}
export interface AttachmentUploadResult {
id: string;
filename: string;
mime_type: string;
size_bytes: number;
}
export interface TicketLink {
id: string;
ticket_id: number;
target_ticket_id: number;
link_type: string;
creator_id: string;
created_at: string;
target_ticket?: { id: number; subject: string; status: string } | null;
}
export interface Notification {
id: string;
user_id: string;
ticket_id: number | null;
type: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
}
export interface ApiToken {
id: string;
name: string;
last_used_at: string | null;
created_at: string;
}