Compare commits
69 Commits
87bd6997e3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4dc38d06 | ||
|
|
f12b24e042 | ||
|
|
96a71a34fe | ||
|
|
1c780be710 | ||
|
|
dfcdbc623a | ||
|
|
6f1d7bfa9b | ||
|
|
c023079a1a | ||
|
|
cee263944b | ||
|
|
8b371ae3c2 | ||
|
|
5308ee8653 | ||
|
|
667979c4b2 | ||
|
|
1f308b4342 | ||
|
|
ed5d96a74b | ||
|
|
dd747946ea | ||
|
|
dde19f5fab | ||
|
|
5970e3fe9d | ||
|
|
7f91a51e32 | ||
|
|
30108c7600 | ||
|
|
d7a5b5ba1d | ||
|
|
b2fb69ffc5 | ||
|
|
dd7bd867bf | ||
|
|
e486558309 | ||
|
|
38a82ad0d8 | ||
|
|
7ddf82f93f | ||
|
|
d5d6a209bd | ||
|
|
4e285f8c4d | ||
|
|
3d7ba0d6a7 | ||
|
|
4157a7b0af | ||
|
|
6a277f9c36 | ||
|
|
a2005d007e | ||
|
|
b3da204bd0 | ||
|
|
41fb10120c | ||
|
|
6ca8974eb9 | ||
|
|
9938c7a7ad | ||
|
|
3616046b78 | ||
|
|
c79cd183d4 | ||
|
|
35b7f49518 | ||
|
|
f7e34f1690 | ||
|
|
6263ce1332 | ||
|
|
c6c5272e50 | ||
|
|
affbbdaa46 | ||
|
|
7be90684fb | ||
|
|
b70a133ea2 | ||
|
|
aa90b88991 | ||
|
|
000e97e1bd | ||
|
|
2501bcbad1 | ||
|
|
aa808f1d3f | ||
|
|
60d2196e51 | ||
|
|
ade966ace7 | ||
|
|
06cc7c79a3 | ||
|
|
b96ba21e99 | ||
|
|
54ef6fcc5b | ||
|
|
e960df61ad | ||
|
|
9e884546f2 | ||
|
|
599ca75fc4 | ||
|
|
087b8cdce7 | ||
|
|
08b52426b0 | ||
|
|
04b4e28d21 | ||
|
|
7da52dfff6 | ||
|
|
86e00b076a | ||
|
|
88ab30a7fd | ||
|
|
737e8942f6 | ||
|
|
10962f795f | ||
|
|
784d30acbd | ||
|
|
6f2b0f39f7 | ||
|
|
8175b05b23 | ||
|
|
b2423f2821 | ||
|
|
b05eb8b2d4 | ||
|
|
10005799fb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ bun.lock
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
|
||||
# Codegraph index (MCP tool)
|
||||
.codegraph
|
||||
|
||||
180
CLAUDE.md
180
CLAUDE.md
@@ -1,106 +1,110 @@
|
||||
# Tessera
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
Open-source ticketing system — Request Tracker's paradigm rebuilt in modern TypeScript.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
## Architecture
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
tessera/
|
||||
├── src/ # Backend: Bun + Hono + Drizzle ORM
|
||||
│ ├── index.ts # Hono server entry (port 9876)
|
||||
│ ├── config.ts # Zod-validated env config
|
||||
│ ├── db/ # Drizzle ORM schema + migrations
|
||||
│ ├── routes/ # REST API endpoints
|
||||
│ ├── models/ # TypeScript types + Zod schemas
|
||||
│ ├── scrip/ # Scrip engine (prepare/commit two-phase)
|
||||
│ └── lifecycle/ # State machine validator
|
||||
├── 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
|
||||
```
|
||||
|
||||
## Frontend
|
||||
## Stack
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
**Backend:** Bun runtime, Hono web framework, Drizzle ORM, PostgreSQL 17, Zod validation, Handlebars templates, nodemailer
|
||||
|
||||
Server:
|
||||
**Frontend:** Next.js 16 App Router (Turbopack), shadcn/ui (Tailwind CSS), next-themes, date-fns, lucide-react icons
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
**Fonts:** Inter (variable), JetBrains Mono
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
## Running Locally
|
||||
|
||||
### Prerequisites
|
||||
- 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`
|
||||
|
||||
### Start backend
|
||||
```bash
|
||||
cd ~/projects/tessera
|
||||
cp .env.example .env
|
||||
npm run dev:backend # Starts API on port 9876
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
### Run migrations
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run db:seed # Demo data
|
||||
npm run db:seed:reset # Reset + re-seed
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
### Start frontend
|
||||
```bash
|
||||
cd web
|
||||
npm install # Use npm, NOT bun
|
||||
bun run dev # Dev server on 127.0.0.1:3100 (HMR)
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
## API Endpoints
|
||||
|
||||
```sh
|
||||
bun --hot ./index.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=&owner_id=&team_id=&q=&limit=&cf.*=) |
|
||||
| POST | /tickets | Create ticket |
|
||||
| GET | /tickets/:id | Get ticket with custom fields |
|
||||
| PATCH | /tickets/:id | Update ticket (validates lifecycle, runs scrips, returns scrip_results) |
|
||||
| POST | /tickets/:id/preview | Dry-run scrips for status change |
|
||||
| POST | /tickets/:id/comment | Add comment to ticket |
|
||||
| GET | /tickets/:id/transactions | List ticket transactions |
|
||||
| GET/POST/PATCH | /queues | CRUD queues |
|
||||
| GET/POST/PATCH/DELETE | /scrips | CRUD scrips |
|
||||
| GET/POST/PATCH | /custom-fields | CRUD custom fields |
|
||||
| GET/POST/PATCH | /lifecycles | CRUD lifecycles |
|
||||
| GET/POST/PATCH/DELETE | /users | CRUD users |
|
||||
| GET/POST/PATCH/DELETE | /templates | CRUD templates + POST /preview |
|
||||
| GET/POST/PATCH/DELETE | /views | CRUD saved views |
|
||||
| GET/POST/PATCH/DELETE | /teams | CRUD teams + POST/DELETE members |
|
||||
| GET/POST/PATCH/DELETE | /dashboards | CRUD dashboards + widgets + widget data |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Ticket IDs are sequential integers** (1, 2, 3...), formatted as `TKT-0001` for display. No UUIDs.
|
||||
- **Transaction-centric:** Every state change creates a transaction record. The scrip engine runs on transactions.
|
||||
- **Two-phase scrip engine:** Prepare (no side effects) then Commit (execute actions). Supports dry-run mode.
|
||||
- **Lifecycle state machines:** Per-queue configurable status transitions with wildcard support.
|
||||
- **SQL-level filtering:** Ticket filters (status, queue, owner, team, custom fields) pushed to PostgreSQL via Drizzle WHERE clauses.
|
||||
- **No ORM for frontend:** Drizzle is only on the backend. Frontend uses a typed fetch wrapper (`web/src/lib/api.ts`).
|
||||
- **Dev server over production:** Use `bun run dev` (port 3100) with HMR. Build+restart only when dev server has issues.
|
||||
- **Design consistency:** See `docs/design-system.md` for the design rules applied across the app.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
Repo: `https://git.gjermund.xyz/gjermund/tessera`
|
||||
|
||||
```bash
|
||||
git remote set-url origin https://gjermund:TOKEN@git.gjermund.xyz/gjermund/tessera.git
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||
## Common Issues
|
||||
|
||||
- **Frontend shows skeleton/blank page:** Dev server may have HMR issues. Kill port 3100, rebuild with `npm run build`, restart with `npm run start`.
|
||||
- **Backend not running on 9876:** Restart with `bun run src/index.ts`. Check port with `ss -tlnp | grep 9876`.
|
||||
- **Database connection refused:** Docker container may be stopped. `docker start tessera-db`.
|
||||
- **Build errors after migration:** Run `bun run src/db/migrate.ts` to apply new migrations.
|
||||
|
||||
118
docs/design-system.md
Normal file
118
docs/design-system.md
Normal 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`)
|
||||
207
docs/rt-architecture-analysis.md
Normal file
207
docs/rt-architecture-analysis.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Request Tracker Architecture: Deep-Dive Technical Analysis
|
||||
|
||||
## Source: RT 5.0.5 codebase analysis + official docs (docs.bestpractical.com)
|
||||
## Purpose: Reference for reimplementing RT's paradigm in Rust
|
||||
|
||||
---
|
||||
|
||||
## 1. SCRIPS — Event-Driven Automation Engine
|
||||
|
||||
### Conceptual Model
|
||||
|
||||
A Scrip is a rule: "When X happens, if condition Y is met, execute action Z using template W."
|
||||
|
||||
Four primitives compose a scrip:
|
||||
- **Condition** (RT::ScripCondition): Has `ApplicableTransTypes` — comma-separated string of transaction types it matches (e.g., "Create,Correspond") or "Any". Modules like OnCreate, OnStatusChange, UserDefined.
|
||||
- **Action** (RT::ScripAction): Maps via `ExecModule` to a Perl module under `RT::Action::*` (SendEmail, AutoReply, CreateTickets, etc.). Has Argument field for parameterization.
|
||||
- **Template** (RT::Template): Global or queue-specific. Types: "Perl" (Text::Template) or "Simple" (variable substitution).
|
||||
- **Stage**: `TransactionCreate` (fires per-transaction) or `TransactionBatch` (fires once after batched updates).
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
Scrips: id, Queue(0=global), Template(name), ScripCondition(id), ScripAction(id),
|
||||
Description, Disabled, CustomPrepareCode, CustomCommitCode, CustomIsApplicableCode
|
||||
ObjectScrips: id, Scrip(FK), Stage, ObjectId(0=global or Queue id), SortOrder
|
||||
```
|
||||
|
||||
Key insight: The same Scrip record can be applied to multiple queues via ObjectScrips, each with different Stage and SortOrder.
|
||||
|
||||
### Dispatch Model — Prepare/Commit Two-Phase
|
||||
|
||||
**Prepare phase** (`RT::Scrips::Prepare`):
|
||||
1. Load Ticket + Transaction as SystemUser (bypass ACL)
|
||||
2. Find matching Scrips: global + queue-specific, filtered by Stage, matched by Condition.ApplicableTransTypes using SQL LIKE
|
||||
3. Sort by SortOrder
|
||||
4. For each: `IsApplicable` (no-side-effects check), then `Prepare` (builds message, determines recipients — no send), push to prepared_scrips
|
||||
|
||||
**Commit phase** (`RT::Scrips::Commit`): Iterate prepared_scrips in order, call `Commit` (actual side effects)
|
||||
|
||||
**Dry-run mode**: On Ticket Update page load, ALL scrips run in dry-run (Prepare only, no Commit) to populate the "Scrips and Recipients" preview.
|
||||
|
||||
**Batch mode**: With `$UseTransactionBatch`, multiple updates accumulate in `_TransactionBatch`. `RanTransactionBatch` flag prevents infinite loops. TransactionBatch-stage scrips see ALL batched transactions.
|
||||
|
||||
### Invariants & Edge Cases
|
||||
|
||||
- Prepare failures silently skip the scrip; Commit failures are NOT retried
|
||||
- Template scoping: queue-specific template overrides global for the same name
|
||||
- Global + queue-specific scrips both apply; their union runs sorted together
|
||||
- SystemUser escalation is essential — automation must not be gated by the triggering user's ACL
|
||||
- `CustomIsApplicableCode`/`CustomPrepareCode`/`CustomCommitCode` allow code injection at three points, guarded by `ExecuteCode` right
|
||||
- The `Disabled` boolean on Scrips is separate from the deprecated `Disabled` stage
|
||||
|
||||
---
|
||||
|
||||
## 2. TRANSACTION QUERY BUILDER
|
||||
|
||||
### Conceptual Model
|
||||
|
||||
Five-layer pipeline: UI Tree → QueryBuilder::Tree (AST) → RT::SQL parser → RT::Tickets dispatch → DBIx::SearchBuilder SQL generation.
|
||||
|
||||
### Transaction Model
|
||||
|
||||
**Everything is a Transaction**: Status changes, owner changes, comments, correspondence, CF updates, link changes, time worked — all create Transaction records.
|
||||
|
||||
Schema: `id, ObjectType, ObjectId, Type, Field, OldValue, NewValue, Data, Creator, Created`
|
||||
|
||||
Each transaction can have MIME attachments.
|
||||
|
||||
### Dispatch Table Architecture
|
||||
|
||||
`RT::Tickets::%FIELD_METADATA` maps searchable fields to type handlers:
|
||||
|
||||
```
|
||||
Status => [STRING], Queue => [QUEUE], Owner => [WATCHERFIELD => 'Owner'],
|
||||
Requestor => [WATCHERFIELD => 'Requestor'], LinkedTo => [LINK => 'To'],
|
||||
DependsOn => [LINK => To => 'DependsOn'], CF => [CUSTOMFIELD => 'Ticket'],
|
||||
TxnCF => [CUSTOMFIELD => 'Transaction'], Content => [TRANSCONTENT],
|
||||
Lifecycle => [LIFECYCLE], Created => [DATE => 'Created']
|
||||
```
|
||||
|
||||
Each type dispatches to a handler (STRING → `_StringLimit`, WATCHERFIELD → `_WatcherLimit`, CUSTOMFIELD → `_CustomFieldLimit`, etc.) that generates appropriate SQL JOINs.
|
||||
|
||||
### TicketSQL Parser
|
||||
|
||||
State-machine parser with states: KEYWORD, OP, VALUE, AGGREGATOR, OPEN_PAREN, CLOSE_PAREN.
|
||||
|
||||
Supports:
|
||||
- Cross-field references (`Due < CF.{TargetDate}`)
|
||||
- IS/IS NOT NULL
|
||||
- LIKE/STARTSWITH/ENDSWITH
|
||||
- SHALLOW modifier
|
||||
|
||||
### Key Optimizations
|
||||
|
||||
- **OR→IN conversion**: `(Status='new' OR Status='open' OR Status='stalled')` → `Status IN ('new','open','stalled')`
|
||||
- **EntryAggregator intelligence**: String fields default to OR for =, AND for !=; dates default to OR for equality, AND for ranges
|
||||
- **Join deduplication**: `_sql_aliases` hash tracks which JOINs exist, preventing duplicates
|
||||
- **Post-filtering**: Deleted tickets and ACL checks applied after SQL, not in SQL
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- CF name ambiguity (same name, global vs queue-scoped) falls back to name-based query
|
||||
- IPAddressRange queries decompose = into two range comparisons
|
||||
- DateTime day-equality decomposes = into >= midnight AND < next midnight
|
||||
- NULL handling on CFs adds extra EXISTS check on CFs.Name
|
||||
- `UseSQLForACLChecks` injects ACL JOINs at SQL level
|
||||
|
||||
---
|
||||
|
||||
## 3. CUSTOM FIELDS
|
||||
|
||||
### Attachment Model
|
||||
|
||||
Two join tables:
|
||||
|
||||
**ObjectCustomFields**: Maps CF to container (Queue id, or 0 for global). Controls *which* CFs appear.
|
||||
**ObjectCustomFieldValues**: Stores actual values on records. `CustomField(FK), ObjectType, ObjectId, Content, LargeContent, Disabled`.
|
||||
|
||||
LookupTypes encode the relationship: `RT::Queue-RT::Ticket` (ticket CFs), `RT::Queue-RT::Ticket-RT::Transaction` (txn CFs), `RT::User`, `RT::Queue`, `RT::Group`.
|
||||
|
||||
### Typing System
|
||||
|
||||
13 types: Select, Freeform, Text, HTML, Wikitext, Image, Binary, Combobox, Autocomplete, Date, DateTime, IPAddress, IPAddressRange.
|
||||
|
||||
Each has: sort_order, selection_type flag, canonicalization flag, render types, labels.
|
||||
|
||||
**Values system**: MaxValues (0=unlimited, 1=single, N=capped), UniqueValues, ValuesClass (external sources), CanonicalizeClass (normalization), Pattern (validation regex), BasedOn (cascading), LinkValueTo (object linking).
|
||||
|
||||
### Permissions
|
||||
|
||||
Five distinct rights: SeeCustomField, AdminCustomField, AdminCustomFieldValues, ModifyCustomField, SetInitialCustomField.
|
||||
|
||||
The last creates a "set-once-at-creation" pattern. Rights are contextual (per-queue for tickets).
|
||||
|
||||
### Queryability
|
||||
|
||||
Notation: `CF.{Name}`, `TxnCF.{Name}`, `QueueCF.{Name}`, `QueueID.CF.{Name}.Content`, cross-field comparison.
|
||||
|
||||
Compilation: `_CustomFieldDecipher` resolves name/id to CF object → `_CustomFieldJoin` performs LEFT JOINs → `_LimitCustomField` applies operator with type-aware value parsing.
|
||||
|
||||
Sorting: `ORDER BY CF.{Name}` joins ObjectCustomFieldValues and, for selection types, CustomFieldValues to sort by SortOrder.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Context-aware permissions (user sees CF on Queue A but not Queue B)
|
||||
- CF name collision detection and fallback
|
||||
- External values sources never stored locally
|
||||
- Content vs LargeContent transparent handling
|
||||
- SingleValue optimization (DISTINCT joins when MaxValues=1)
|
||||
|
||||
---
|
||||
|
||||
## 4. LIFECYCLES
|
||||
|
||||
### Conceptual Model
|
||||
|
||||
A Lifecycle defines: valid statuses (initial/active/inactive), allowed transitions (directed graph), transition-gating rights, UI actions, defaults, and cross-lifecycle move maps.
|
||||
|
||||
Stored on Queue (not ticket); tickets inherit through queue.
|
||||
|
||||
### State Machine Semantics
|
||||
|
||||
**Date invariants**:
|
||||
- Moving FROM initial TO active/inactive → set Started
|
||||
- Moving FROM initial/active TO inactive → set Resolved
|
||||
- Moving BACK from inactive → clear Resolved (critical!)
|
||||
- Moving FROM initial directly TO inactive → sets BOTH Started AND Resolved
|
||||
|
||||
**Transition validation** (`ValidateStatusChange`):
|
||||
1. Is new status valid in lifecycle?
|
||||
2. Is transition allowed? (`IsTransition`)
|
||||
3. Does user have the required right? (`CheckRight` with 4-level priority: exact→wildcard-from→wildcard-to→full-wildcard→fallback)
|
||||
|
||||
**CheckRight priority**: `'new→open'` > `'*→open'` > `'new→*'` > `'*→*'` > fallback (DeleteTicket for deleted, ModifyTicket otherwise).
|
||||
|
||||
Lifecycle rights REPLACE ModifyTicket, not supplement it.
|
||||
|
||||
### Queue Changes
|
||||
|
||||
Move maps are directional (`'default→orders'` vs `'orders→default'`). Maps MUST be complete — any missing status causes a hard error.
|
||||
|
||||
On queue change, if owner lacks OwnTicket on new queue, ticket is auto-reassigned to Nobody (safety invariant).
|
||||
|
||||
### Rights Model
|
||||
|
||||
Custom named rights are auto-created: `'* → rejected' => 'RejectTicket'` creates a RejectTicket right.
|
||||
|
||||
No explicit guards for behavioral hooks — handled entirely through Scrips (OnStatusChange, OnResolve conditions).
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Action deduplication: specific `'from→to'` overrides wildcard `'*→to'`
|
||||
- Lifecycle caching; no hot-reload without restart
|
||||
- Disabled lifecycles: existing queues keep working, can't be selected for new queues
|
||||
- Default statuses cover specific named contexts: on_create, approved, denied, reminder_on_open, reminder_on_resolve
|
||||
- Batched status changes: TransactionBatch scrips see all transactions atomically
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Architectural Patterns
|
||||
|
||||
1. **Transaction-centric**: Everything produces a transaction → unified audit trail, unified query interface, single Scrip event stream
|
||||
2. **Container-scoped config**: CFs, Scrips, Templates all follow global→queue override pattern
|
||||
3. **Prepare/Commit**: Separates planning from execution (essential for dry-run, safety)
|
||||
4. **Dispatch tables**: Field types → handlers, ApplicableTransTypes → conditions — config-driven, not code-driven extensibility
|
||||
5. **SystemUser identity**: Automation runs elevated; reimplementation needs explicit service account concept
|
||||
6. **Aggressive caching**: Lifecycle configs, CF join aliases, ACL roles, group memberships — essential at scale
|
||||
29
drizzle/migrations/0001_lovely_quentin_quire.sql
Normal file
29
drizzle/migrations/0001_lovely_quentin_quire.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Drop foreign key constraints referencing tickets.id
|
||||
ALTER TABLE "custom_field_values" DROP CONSTRAINT IF EXISTS "custom_field_values_ticket_id_tickets_id_fk";
|
||||
ALTER TABLE "transactions" DROP CONSTRAINT IF EXISTS "transactions_ticket_id_tickets_id_fk";
|
||||
|
||||
-- Drop dependent indexes
|
||||
DROP INDEX IF EXISTS "custom_field_values_ticket_id_idx";
|
||||
DROP INDEX IF EXISTS "transactions_ticket_id_idx";
|
||||
|
||||
-- Clear all data from affected tables (UUIDs cannot cast to integer)
|
||||
DELETE FROM "custom_field_values";
|
||||
DELETE FROM "transactions";
|
||||
DELETE FROM "tickets";
|
||||
|
||||
-- Alter column types with USING clause for empty tables
|
||||
ALTER TABLE "custom_field_values" ALTER COLUMN "ticket_id" SET DATA TYPE integer USING (0);
|
||||
ALTER TABLE "transactions" ALTER COLUMN "ticket_id" SET DATA TYPE integer USING (0);
|
||||
|
||||
-- Alter tickets.id to serial
|
||||
ALTER TABLE "tickets" ALTER COLUMN "id" DROP DEFAULT;
|
||||
ALTER TABLE "tickets" ALTER COLUMN "id" SET DATA TYPE integer USING (0);
|
||||
ALTER TABLE "tickets" ALTER COLUMN "id" ADD GENERATED ALWAYS AS IDENTITY (sequence name "tickets_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1);
|
||||
|
||||
-- Re-add foreign key constraints
|
||||
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
|
||||
|
||||
-- Re-create indexes
|
||||
CREATE INDEX "custom_field_values_ticket_id_idx" ON "custom_field_values" USING btree ("ticket_id");
|
||||
CREATE INDEX "transactions_ticket_id_idx" ON "transactions" USING btree ("ticket_id");
|
||||
10
drizzle/migrations/0002_short_custom_field_keys.sql
Normal file
10
drizzle/migrations/0002_short_custom_field_keys.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE "custom_fields" ADD COLUMN "key" text;
|
||||
--> statement-breakpoint
|
||||
UPDATE "custom_fields"
|
||||
SET "key" = trim(both '_' from regexp_replace(lower("name"), '[^a-z0-9]+', '_', 'g'));
|
||||
--> statement-breakpoint
|
||||
UPDATE "custom_fields" SET "key" = 'field_' || substring("id"::text, 1, 8) WHERE "key" IS NULL OR "key" = '';
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "custom_fields" ALTER COLUMN "key" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "custom_fields" ADD CONSTRAINT "custom_fields_key_unique" UNIQUE("key");
|
||||
12
drizzle/migrations/0003_dry_caretaker.sql
Normal file
12
drizzle/migrations/0003_dry_caretaker.sql
Normal 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;
|
||||
22
drizzle/migrations/0004_sturdy_natasha_romanoff.sql
Normal file
22
drizzle/migrations/0004_sturdy_natasha_romanoff.sql
Normal 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;
|
||||
19
drizzle/migrations/0005_spotty_leader.sql
Normal file
19
drizzle/migrations/0005_spotty_leader.sql
Normal 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;
|
||||
2
drizzle/migrations/0006_nosy_black_queen.sql
Normal file
2
drizzle/migrations/0006_nosy_black_queen.sql
Normal 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;
|
||||
2
drizzle/migrations/0007_flimsy_roughhouse.sql
Normal file
2
drizzle/migrations/0007_flimsy_roughhouse.sql
Normal 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;
|
||||
916
drizzle/migrations/meta/0001_snapshot.json
Normal file
916
drizzle/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,916 @@
|
||||
{
|
||||
"id": "042752b4-e1ad-4b6d-96ed-81f836028826",
|
||||
"prevId": "981c2ca0-1a37-4fbd-8624-2e7f43cd8361",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.custom_field_values": {
|
||||
"name": "custom_field_values",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"custom_field_id": {
|
||||
"name": "custom_field_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"custom_field_values_ticket_id_idx": {
|
||||
"name": "custom_field_values_ticket_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "ticket_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"custom_field_values_custom_field_id_idx": {
|
||||
"name": "custom_field_values_custom_field_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "custom_field_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"custom_field_values_custom_field_id_custom_fields_id_fk": {
|
||||
"name": "custom_field_values_custom_field_id_custom_fields_id_fk",
|
||||
"tableFrom": "custom_field_values",
|
||||
"tableTo": "custom_fields",
|
||||
"columnsFrom": [
|
||||
"custom_field_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"custom_field_values_ticket_id_tickets_id_fk": {
|
||||
"name": "custom_field_values_ticket_id_tickets_id_fk",
|
||||
"tableFrom": "custom_field_values",
|
||||
"tableTo": "tickets",
|
||||
"columnsFrom": [
|
||||
"ticket_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"custom_field_values_cf_id_ticket_id_value_unique": {
|
||||
"name": "custom_field_values_cf_id_ticket_id_value_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"custom_field_id",
|
||||
"ticket_id",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custom_fields": {
|
||||
"name": "custom_fields",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"field_type": {
|
||||
"name": "field_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"values": {
|
||||
"name": "values",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"max_values": {
|
||||
"name": "max_values",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"pattern": {
|
||||
"name": "pattern",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lifecycles": {
|
||||
"name": "lifecycles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"definition": {
|
||||
"name": "definition",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"lifecycles_name_unique": {
|
||||
"name": "lifecycles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.queue_custom_fields": {
|
||||
"name": "queue_custom_fields",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"custom_field_id": {
|
||||
"name": "custom_field_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"queue_custom_fields_queue_id_queues_id_fk": {
|
||||
"name": "queue_custom_fields_queue_id_queues_id_fk",
|
||||
"tableFrom": "queue_custom_fields",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"queue_custom_fields_custom_field_id_custom_fields_id_fk": {
|
||||
"name": "queue_custom_fields_custom_field_id_custom_fields_id_fk",
|
||||
"tableFrom": "queue_custom_fields",
|
||||
"tableTo": "custom_fields",
|
||||
"columnsFrom": [
|
||||
"custom_field_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"queue_custom_fields_queue_id_custom_field_id_unique": {
|
||||
"name": "queue_custom_fields_queue_id_custom_field_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"queue_id",
|
||||
"custom_field_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.queues": {
|
||||
"name": "queues",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"lifecycle_id": {
|
||||
"name": "lifecycle_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"queues_lifecycle_id_lifecycles_id_fk": {
|
||||
"name": "queues_lifecycle_id_lifecycles_id_fk",
|
||||
"tableFrom": "queues",
|
||||
"tableTo": "lifecycles",
|
||||
"columnsFrom": [
|
||||
"lifecycle_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"queues_name_unique": {
|
||||
"name": "queues_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.scrips": {
|
||||
"name": "scrips",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"condition_type": {
|
||||
"name": "condition_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"condition_config": {
|
||||
"name": "condition_config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"action_type": {
|
||||
"name": "action_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action_config": {
|
||||
"name": "action_config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"template_id": {
|
||||
"name": "template_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"stage": {
|
||||
"name": "stage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'TransactionCreate'"
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"disabled": {
|
||||
"name": "disabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"scrips_queue_id_idx": {
|
||||
"name": "scrips_queue_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "queue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"scrips_queue_id_queues_id_fk": {
|
||||
"name": "scrips_queue_id_queues_id_fk",
|
||||
"tableFrom": "scrips",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"scrips_template_id_templates_id_fk": {
|
||||
"name": "scrips_template_id_templates_id_fk",
|
||||
"tableFrom": "scrips",
|
||||
"tableTo": "templates",
|
||||
"columnsFrom": [
|
||||
"template_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.templates": {
|
||||
"name": "templates",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_template": {
|
||||
"name": "subject_template",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body_template": {
|
||||
"name": "body_template",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"templates_queue_id_queues_id_fk": {
|
||||
"name": "templates_queue_id_queues_id_fk",
|
||||
"tableFrom": "templates",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tickets": {
|
||||
"name": "tickets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"identity": {
|
||||
"type": "always",
|
||||
"name": "tickets_id_seq",
|
||||
"schema": "public",
|
||||
"increment": "1",
|
||||
"startWith": "1",
|
||||
"minValue": "1",
|
||||
"maxValue": "2147483647",
|
||||
"cache": "1",
|
||||
"cycle": false
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tickets_queue_id_idx": {
|
||||
"name": "tickets_queue_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "queue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tickets_status_idx": {
|
||||
"name": "tickets_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tickets_queue_id_queues_id_fk": {
|
||||
"name": "tickets_queue_id_queues_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tickets_owner_id_users_id_fk": {
|
||||
"name": "tickets_owner_id_users_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tickets_creator_id_users_id_fk": {
|
||||
"name": "tickets_creator_id_users_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"creator_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.transactions": {
|
||||
"name": "transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"transaction_type": {
|
||||
"name": "transaction_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"field": {
|
||||
"name": "field",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"old_value": {
|
||||
"name": "old_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"new_value": {
|
||||
"name": "new_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"transactions_ticket_id_idx": {
|
||||
"name": "transactions_ticket_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "ticket_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"transactions_created_at_idx": {
|
||||
"name": "transactions_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"transactions_ticket_id_tickets_id_fk": {
|
||||
"name": "transactions_ticket_id_tickets_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "tickets",
|
||||
"columnsFrom": [
|
||||
"ticket_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transactions_creator_id_users_id_fk": {
|
||||
"name": "transactions_creator_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"creator_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
1011
drizzle/migrations/meta/0003_snapshot.json
Normal file
1011
drizzle/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1156
drizzle/migrations/meta/0004_snapshot.json
Normal file
1156
drizzle/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1291
drizzle/migrations/meta/0005_snapshot.json
Normal file
1291
drizzle/migrations/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1310
drizzle/migrations/meta/0006_snapshot.json
Normal file
1310
drizzle/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1329
drizzle/migrations/meta/0007_snapshot.json
Normal file
1329
drizzle/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,55 @@
|
||||
"when": 1780859982396,
|
||||
"tag": "0000_acoustic_wendell_vaughn",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1780867177929,
|
||||
"tag": "0001_lovely_quentin_quire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1780904200000,
|
||||
"tag": "0002_short_custom_field_keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1780995910694,
|
||||
"tag": "0003_dry_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1780996807814,
|
||||
"tag": "0004_sturdy_natasha_romanoff",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1781004398567,
|
||||
"tag": "0005_spotty_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1781008559188,
|
||||
"tag": "0006_nosy_black_queen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781009018666,
|
||||
"tag": "0007_flimsy_roughhouse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:backend": "bun run src/index.ts",
|
||||
"db:migrate": "bun run src/db/migrate.ts",
|
||||
"db:seed": "bun run src/db/seed.ts",
|
||||
"db:seed:reset": "bun run src/db/seed.ts --reset",
|
||||
"smoke": "bun run scripts/smoke-test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
|
||||
112
scripts/smoke-test.ts
Normal file
112
scripts/smoke-test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:9876';
|
||||
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:3100';
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
interface Queue {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
transaction_type: string;
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function requestOk(url: string): Promise<void> {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`${url} returned ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function check(name: string, fn: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`ok ${name}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`fail ${name}`);
|
||||
console.error(` ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let ticketForDetail: Ticket | null = null;
|
||||
|
||||
await check('backend health', async () => {
|
||||
const health = await requestJson<{ status: string }>(`${backendUrl}/health`);
|
||||
if (health.status !== 'ok') {
|
||||
throw new Error(`expected status ok, got ${JSON.stringify(health)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await check('queues exist', async () => {
|
||||
const queues = await requestJson<Queue[]>(`${backendUrl}/queues`);
|
||||
if (queues.length < 1) {
|
||||
throw new Error('expected at least one queue');
|
||||
}
|
||||
});
|
||||
|
||||
await check('tickets exist', async () => {
|
||||
const tickets = await requestJson<Ticket[]>(`${backendUrl}/tickets`);
|
||||
if (tickets.length < 1) {
|
||||
throw new Error('expected at least one ticket');
|
||||
}
|
||||
ticketForDetail = tickets.find((ticket) => ticket.subject.includes('VPN access')) ?? tickets[0] ?? null;
|
||||
});
|
||||
|
||||
await check('ticket detail has activity', async () => {
|
||||
if (!ticketForDetail) {
|
||||
throw new Error('no ticket available for detail check');
|
||||
}
|
||||
const transactions = await requestJson<Transaction[]>(
|
||||
`${backendUrl}/tickets/${ticketForDetail.id}/transactions`,
|
||||
);
|
||||
if (transactions.length < 1) {
|
||||
throw new Error(`expected ticket ${ticketForDetail.id} to have transactions`);
|
||||
}
|
||||
});
|
||||
|
||||
await check('frontend index responds', async () => {
|
||||
await requestOk(frontendUrl);
|
||||
});
|
||||
|
||||
await check('frontend ticket detail responds', async () => {
|
||||
if (!ticketForDetail) {
|
||||
throw new Error('no ticket available for frontend detail check');
|
||||
}
|
||||
await requestOk(`${frontendUrl}/tickets/${ticketForDetail.id}`);
|
||||
});
|
||||
|
||||
await check('frontend api proxy responds', async () => {
|
||||
const health = await requestJson<{ status: string }>(`${frontendUrl}/api/health`);
|
||||
if (health.status !== 'ok') {
|
||||
throw new Error(`expected status ok, got ${JSON.stringify(health)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.exitCode) {
|
||||
process.exit(process.exitCode);
|
||||
}
|
||||
|
||||
console.log('Smoke test passed');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
15
scripts/watch-frontend.sh
Normal file
15
scripts/watch-frontend.sh
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Watch for source changes and auto-rebuild + restart Tessera frontend
|
||||
DIR="/home/gjermund/projects/tessera/web/src"
|
||||
LAST_BUILD=0
|
||||
|
||||
echo "Watching $DIR for changes..."
|
||||
|
||||
inotifywait -m -r -e modify,create,delete "$DIR" --format '%w%f' 2>/dev/null | while read FILE; do
|
||||
NOW=$(date +%s)
|
||||
if [ $((NOW - LAST_BUILD)) -gt 3 ]; then
|
||||
echo "[$(date +%H:%M:%S)] Change detected, rebuilding..."
|
||||
cd /home/gjermund/projects/tessera/web && npx next build 2>&1 | tail -1
|
||||
LAST_BUILD=$NOW
|
||||
fi
|
||||
done
|
||||
@@ -1,5 +1,5 @@
|
||||
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(),
|
||||
@@ -13,6 +13,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(),
|
||||
});
|
||||
|
||||
@@ -24,11 +25,12 @@ export const lifecycles = pgTable('lifecycles', {
|
||||
});
|
||||
|
||||
export const tickets = pgTable('tickets', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
||||
subject: text('subject').notNull(),
|
||||
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(),
|
||||
@@ -41,7 +43,7 @@ export const tickets = pgTable('tickets', {
|
||||
|
||||
export const transactions = pgTable('transactions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
transaction_type: text('transaction_type').notNull(),
|
||||
field: text('field'),
|
||||
old_value: text('old_value'),
|
||||
@@ -83,6 +85,7 @@ export const scrips = pgTable('scrips', {
|
||||
|
||||
export const customFields = pgTable('custom_fields', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
key: text('key').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
field_type: text('field_type').notNull(),
|
||||
values: jsonb('values'),
|
||||
@@ -103,7 +106,7 @@ export const queueCustomFields = pgTable('queue_custom_fields', {
|
||||
export const customFieldValues = pgTable('custom_field_values', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }),
|
||||
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
value: text('value').notNull(),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
@@ -111,3 +114,59 @@ 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 dashboardWidgets = pgTable('dashboard_widgets', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
|
||||
view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
widget_type: text('widget_type').notNull(),
|
||||
position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'),
|
||||
config: jsonb('config').default('{}'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
843
src/db/seed.ts
Normal file
843
src/db/seed.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import * as schema from './schema.ts';
|
||||
import {
|
||||
customFields,
|
||||
customFieldValues,
|
||||
lifecycles,
|
||||
queueCustomFields,
|
||||
queues,
|
||||
scrips,
|
||||
templates,
|
||||
tickets,
|
||||
transactions,
|
||||
views,
|
||||
dashboards,
|
||||
dashboardWidgets,
|
||||
users,
|
||||
} from './schema.ts';
|
||||
|
||||
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const lifecycleDefinition = {
|
||||
statuses: {
|
||||
initial: ['new'],
|
||||
active: ['open', 'in_progress'],
|
||||
inactive: ['resolved', 'closed'],
|
||||
},
|
||||
transitions: {
|
||||
new: ['open', 'in_progress', 'closed'],
|
||||
open: ['in_progress', 'resolved', 'closed'],
|
||||
in_progress: ['open', 'resolved', 'closed'],
|
||||
resolved: ['open', 'closed'],
|
||||
closed: ['open'],
|
||||
'*': ['closed'],
|
||||
},
|
||||
};
|
||||
|
||||
function daysAgo(days: number, hour = 9, minute = 0): Date {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - days);
|
||||
date.setHours(hour, minute, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): Date {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function createSeedDb(pool: Pool) {
|
||||
return drizzle(pool, { schema });
|
||||
}
|
||||
|
||||
type Db = ReturnType<typeof createSeedDb>;
|
||||
type UserSeed = { id: string; username: string; email: string };
|
||||
type QueueSeed = { name: string; description: string };
|
||||
type FieldSeed = {
|
||||
key?: string;
|
||||
name: string;
|
||||
field_type: string;
|
||||
values?: unknown;
|
||||
max_values?: number;
|
||||
pattern?: string | null;
|
||||
};
|
||||
|
||||
function makeFieldKey(value: string): string {
|
||||
const key = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return key || 'field';
|
||||
}
|
||||
|
||||
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
||||
const existingById = await db.query.users.findFirst({
|
||||
where: eq(users.id, seed.id),
|
||||
});
|
||||
if (existingById) {
|
||||
await db.update(users)
|
||||
.set({ username: seed.username, email: seed.email })
|
||||
.where(eq(users.id, seed.id));
|
||||
return existingById.id;
|
||||
}
|
||||
|
||||
const existingByUsername = await db.query.users.findFirst({
|
||||
where: eq(users.username, seed.username),
|
||||
});
|
||||
if (existingByUsername) {
|
||||
await db.update(users)
|
||||
.set({ email: seed.email })
|
||||
.where(eq(users.id, existingByUsername.id));
|
||||
return existingByUsername.id;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(users).values(seed).returning();
|
||||
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function ensureLifecycle(db: Db) {
|
||||
const existing = await db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.name, 'Demo service lifecycle'),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(lifecycles)
|
||||
.set({ definition: lifecycleDefinition })
|
||||
.where(eq(lifecycles.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('Failed to update demo lifecycle');
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(lifecycles).values({
|
||||
name: 'Demo service lifecycle',
|
||||
definition: lifecycleDefinition,
|
||||
}).returning();
|
||||
if (!created) throw new Error('Failed to seed demo lifecycle');
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ensureQueue(db: Db, lifecycleId: string, seed: QueueSeed) {
|
||||
const existing = await db.query.queues.findFirst({
|
||||
where: eq(queues.name, seed.name),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(queues)
|
||||
.set({
|
||||
description: seed.description,
|
||||
lifecycle_id: lifecycleId,
|
||||
})
|
||||
.where(eq(queues.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Failed to update queue ${seed.name}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(queues).values({
|
||||
name: seed.name,
|
||||
description: seed.description,
|
||||
lifecycle_id: lifecycleId,
|
||||
}).returning();
|
||||
if (!created) throw new Error(`Failed to seed queue ${seed.name}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ensureCustomField(db: Db, seed: FieldSeed) {
|
||||
const existing = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.name, seed.name),
|
||||
});
|
||||
|
||||
const values = {
|
||||
key: seed.key ?? makeFieldKey(seed.name),
|
||||
field_type: seed.field_type,
|
||||
values: seed.values ?? null,
|
||||
max_values: seed.max_values ?? 1,
|
||||
pattern: seed.pattern ?? null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(customFields)
|
||||
.set(values)
|
||||
.where(eq(customFields.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Failed to update custom field ${seed.name}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(customFields).values({
|
||||
name: seed.name,
|
||||
...values,
|
||||
}).returning();
|
||||
if (!created) throw new Error(`Failed to seed custom field ${seed.name}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function attachFieldToQueue(db: Db, queueId: string, fieldId: string, sortOrder: number) {
|
||||
await db.insert(queueCustomFields)
|
||||
.values({
|
||||
queue_id: queueId,
|
||||
custom_field_id: fieldId,
|
||||
sort_order: sortOrder,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [queueCustomFields.queue_id, queueCustomFields.custom_field_id],
|
||||
set: { sort_order: sortOrder },
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureTemplate(
|
||||
db: Db,
|
||||
name: string,
|
||||
queueId: string | null,
|
||||
subjectTemplate: string,
|
||||
bodyTemplate: string,
|
||||
) {
|
||||
const existing = await db.query.templates.findFirst({
|
||||
where: (row, { and, eq, isNull }) =>
|
||||
queueId ? and(eq(row.name, name), eq(row.queue_id, queueId)) : and(eq(row.name, name), isNull(row.queue_id)),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(templates)
|
||||
.set({ subject_template: subjectTemplate, body_template: bodyTemplate })
|
||||
.where(eq(templates.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Failed to update template ${name}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(templates).values({
|
||||
name,
|
||||
queue_id: queueId,
|
||||
subject_template: subjectTemplate,
|
||||
body_template: bodyTemplate,
|
||||
}).returning();
|
||||
if (!created) throw new Error(`Failed to seed template ${name}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ensureScrip(
|
||||
db: Db,
|
||||
seed: {
|
||||
name: string;
|
||||
description: string;
|
||||
queueId: string | null;
|
||||
conditionType: string;
|
||||
actionType: string;
|
||||
actionConfig: Record<string, unknown>;
|
||||
templateId?: string | null;
|
||||
sortOrder: number;
|
||||
disabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const existing = await db.query.scrips.findFirst({
|
||||
where: (row, { and, eq, isNull }) =>
|
||||
seed.queueId
|
||||
? and(eq(row.name, seed.name), eq(row.queue_id, seed.queueId))
|
||||
: and(eq(row.name, seed.name), isNull(row.queue_id)),
|
||||
});
|
||||
|
||||
const values = {
|
||||
queue_id: seed.queueId,
|
||||
name: seed.name,
|
||||
description: seed.description,
|
||||
condition_type: seed.conditionType,
|
||||
condition_config: {},
|
||||
action_type: seed.actionType,
|
||||
action_config: seed.actionConfig,
|
||||
template_id: seed.templateId ?? null,
|
||||
stage: 'TransactionCreate',
|
||||
sort_order: seed.sortOrder,
|
||||
disabled: seed.disabled ?? false,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(scrips)
|
||||
.set(values)
|
||||
.where(eq(scrips.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Failed to update scrip ${seed.name}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(scrips).values(values).returning();
|
||||
if (!created) throw new Error(`Failed to seed scrip ${seed.name}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function ensureTicket(
|
||||
db: Db,
|
||||
seed: {
|
||||
subject: string;
|
||||
queueId: string;
|
||||
status: string;
|
||||
ownerId: string | null;
|
||||
creatorId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
startedAt?: Date | null;
|
||||
resolvedAt?: Date | null;
|
||||
},
|
||||
) {
|
||||
const existing = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.subject, seed.subject),
|
||||
});
|
||||
|
||||
const values = {
|
||||
subject: seed.subject,
|
||||
queue_id: seed.queueId,
|
||||
status: seed.status,
|
||||
owner_id: seed.ownerId,
|
||||
creator_id: seed.creatorId,
|
||||
created_at: seed.createdAt,
|
||||
updated_at: seed.updatedAt,
|
||||
started_at: seed.startedAt ?? null,
|
||||
resolved_at: seed.resolvedAt ?? null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db.update(tickets)
|
||||
.set(values)
|
||||
.where(eq(tickets.id, existing.id))
|
||||
.returning();
|
||||
if (!updated) throw new Error(`Failed to update ticket ${seed.subject}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(tickets).values(values).returning();
|
||||
if (!created) throw new Error(`Failed to seed ticket ${seed.subject}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
async function resetDatabase(db: Db) {
|
||||
await db.delete(customFieldValues);
|
||||
await db.delete(transactions);
|
||||
await db.delete(queueCustomFields);
|
||||
await db.delete(dashboardWidgets);
|
||||
await db.delete(dashboards);
|
||||
await db.delete(views);
|
||||
await db.delete(scrips);
|
||||
await db.delete(templates);
|
||||
await db.delete(tickets);
|
||||
await db.delete(queues);
|
||||
await db.delete(customFields);
|
||||
await db.delete(lifecycles);
|
||||
await db.delete(users);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error('DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
const db = createSeedDb(pool);
|
||||
const reset = process.argv.includes('--reset');
|
||||
|
||||
try {
|
||||
if (reset) {
|
||||
console.log('Resetting database before seeding demo data...');
|
||||
await resetDatabase(db);
|
||||
}
|
||||
|
||||
const userIds = {
|
||||
system: await ensureUser(db, {
|
||||
id: SYSTEM_USER_ID,
|
||||
username: 'system',
|
||||
email: 'system@tessera.local',
|
||||
}),
|
||||
dispatcher: await ensureUser(db, {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'maria.dispatch',
|
||||
email: 'maria.dispatch@tessera.local',
|
||||
}),
|
||||
technician: await ensureUser(db, {
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
username: 'liam.field',
|
||||
email: 'liam.field@tessera.local',
|
||||
}),
|
||||
facilities: await ensureUser(db, {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
username: 'nora.facilities',
|
||||
email: 'nora.facilities@tessera.local',
|
||||
}),
|
||||
security: await ensureUser(db, {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
username: 'sam.security',
|
||||
email: 'sam.security@tessera.local',
|
||||
}),
|
||||
};
|
||||
|
||||
const lifecycle = await ensureLifecycle(db);
|
||||
|
||||
const supportQueue = await ensureQueue(db, lifecycle.id, {
|
||||
name: 'Support Desk',
|
||||
description: 'Employee requests, account access, hardware, and everyday service desk intake.',
|
||||
});
|
||||
const fieldQueue = await ensureQueue(db, lifecycle.id, {
|
||||
name: 'Field Operations',
|
||||
description: 'Technician dispatch, site work, parts, and customer-impacting operational issues.',
|
||||
});
|
||||
const facilitiesQueue = await ensureQueue(db, lifecycle.id, {
|
||||
name: 'Facilities',
|
||||
description: 'Building maintenance, access, meeting rooms, and office environment requests.',
|
||||
});
|
||||
const securityQueue = await ensureQueue(db, lifecycle.id, {
|
||||
name: 'Security',
|
||||
description: 'Badge access, incident review, and compliance-sensitive operational requests.',
|
||||
});
|
||||
|
||||
const impactField = await ensureCustomField(db, {
|
||||
key: 'impact',
|
||||
name: 'Impact',
|
||||
field_type: 'select',
|
||||
values: ['Low', 'Medium', 'High', 'Critical'],
|
||||
});
|
||||
const locationField = await ensureCustomField(db, {
|
||||
key: 'location',
|
||||
name: 'Location',
|
||||
field_type: 'text',
|
||||
});
|
||||
const assetField = await ensureCustomField(db, {
|
||||
key: 'asset_tag',
|
||||
name: 'Asset tag',
|
||||
field_type: 'text',
|
||||
pattern: '^ASSET-[0-9]{4}$',
|
||||
});
|
||||
const channelField = await ensureCustomField(db, {
|
||||
key: 'channel',
|
||||
name: 'Channel',
|
||||
field_type: 'select',
|
||||
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
|
||||
});
|
||||
const outcomeField = await ensureCustomField(db, {
|
||||
key: 'resolution_outcome',
|
||||
name: 'Resolution outcome',
|
||||
field_type: 'select',
|
||||
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
|
||||
});
|
||||
|
||||
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
|
||||
await attachFieldToQueue(db, queue.id, impactField.id, 10);
|
||||
await attachFieldToQueue(db, queue.id, locationField.id, 20);
|
||||
await attachFieldToQueue(db, queue.id, channelField.id, 30);
|
||||
}
|
||||
await attachFieldToQueue(db, supportQueue.id, assetField.id, 40);
|
||||
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
|
||||
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
|
||||
|
||||
const resolveTemplate = await ensureTemplate(
|
||||
db,
|
||||
'Demo resolution note',
|
||||
null,
|
||||
'Ticket {{ticket.id}} resolved: {{ticket.subject}}',
|
||||
'Ticket {{ticket.id}} in {{queue.name}} moved from {{transaction.old_value}} to {{transaction.new_value}}.',
|
||||
);
|
||||
|
||||
await ensureScrip(db, {
|
||||
name: 'Demo: mark outcome on resolve',
|
||||
description: 'When a ticket resolves, set the Resolution outcome custom field to Completed.',
|
||||
queueId: null,
|
||||
conditionType: 'OnResolve',
|
||||
actionType: 'SetCustomField',
|
||||
actionConfig: {
|
||||
field_key: 'resolution_outcome',
|
||||
value: 'Completed',
|
||||
},
|
||||
sortOrder: 10,
|
||||
});
|
||||
await ensureScrip(db, {
|
||||
name: 'Demo: customer notification template',
|
||||
description: 'Disabled sample email action showing how resolution templates render.',
|
||||
queueId: null,
|
||||
conditionType: 'OnResolve',
|
||||
actionType: 'SendEmail',
|
||||
actionConfig: {
|
||||
recipients: ['requester@example.com'],
|
||||
},
|
||||
templateId: resolveTemplate.id,
|
||||
sortOrder: 20,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const demoTickets = [
|
||||
await ensureTicket(db, {
|
||||
subject: 'VPN access fails after password reset',
|
||||
queueId: supportQueue.id,
|
||||
status: 'open',
|
||||
ownerId: userIds.dispatcher,
|
||||
creatorId: userIds.system,
|
||||
createdAt: daysAgo(4, 8, 40),
|
||||
updatedAt: hoursAgo(3),
|
||||
startedAt: daysAgo(4, 9, 10),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'Warehouse scanner ASSET-1042 will not sync inventory',
|
||||
queueId: fieldQueue.id,
|
||||
status: 'in_progress',
|
||||
ownerId: userIds.technician,
|
||||
creatorId: userIds.system,
|
||||
createdAt: daysAgo(2, 10, 15),
|
||||
updatedAt: hoursAgo(1),
|
||||
startedAt: daysAgo(2, 11, 0),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'Badge reader intermittently denies access at north entrance',
|
||||
queueId: securityQueue.id,
|
||||
status: 'new',
|
||||
ownerId: null,
|
||||
creatorId: userIds.system,
|
||||
createdAt: hoursAgo(7),
|
||||
updatedAt: hoursAgo(7),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'Conference room display flickers during video calls',
|
||||
queueId: facilitiesQueue.id,
|
||||
status: 'open',
|
||||
ownerId: userIds.facilities,
|
||||
creatorId: userIds.system,
|
||||
createdAt: daysAgo(1, 14, 20),
|
||||
updatedAt: hoursAgo(4),
|
||||
startedAt: daysAgo(1, 15, 0),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'New hire laptop provisioning for Monday start',
|
||||
queueId: supportQueue.id,
|
||||
status: 'resolved',
|
||||
ownerId: userIds.dispatcher,
|
||||
creatorId: userIds.system,
|
||||
createdAt: daysAgo(6, 13, 30),
|
||||
updatedAt: daysAgo(1, 16, 45),
|
||||
startedAt: daysAgo(6, 14, 0),
|
||||
resolvedAt: daysAgo(1, 16, 45),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'Temperature alert in server closet B',
|
||||
queueId: facilitiesQueue.id,
|
||||
status: 'in_progress',
|
||||
ownerId: userIds.facilities,
|
||||
creatorId: userIds.system,
|
||||
createdAt: hoursAgo(18),
|
||||
updatedAt: hoursAgo(2),
|
||||
startedAt: hoursAgo(17),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'Quarterly access review export requested',
|
||||
queueId: securityQueue.id,
|
||||
status: 'closed',
|
||||
ownerId: userIds.security,
|
||||
creatorId: userIds.system,
|
||||
createdAt: daysAgo(9, 10, 0),
|
||||
updatedAt: daysAgo(3, 11, 20),
|
||||
startedAt: daysAgo(9, 10, 30),
|
||||
resolvedAt: daysAgo(3, 11, 20),
|
||||
}),
|
||||
await ensureTicket(db, {
|
||||
subject: 'POS terminal receipt printer jam at front desk',
|
||||
queueId: fieldQueue.id,
|
||||
status: 'new',
|
||||
ownerId: null,
|
||||
creatorId: userIds.system,
|
||||
createdAt: hoursAgo(5),
|
||||
updatedAt: hoursAgo(5),
|
||||
}),
|
||||
];
|
||||
|
||||
const demoTicketIds = demoTickets.map((ticket) => ticket.id);
|
||||
if (demoTicketIds.length > 0) {
|
||||
await db.delete(customFieldValues).where(inArray(customFieldValues.ticket_id, demoTicketIds));
|
||||
await db.delete(transactions).where(inArray(transactions.ticket_id, demoTicketIds));
|
||||
}
|
||||
|
||||
const ticketBySubject = new Map(demoTickets.map((ticket) => [ticket.subject, ticket]));
|
||||
|
||||
const txRows = [
|
||||
{
|
||||
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: daysAgo(4, 8, 40),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'open',
|
||||
creator_id: userIds.dispatcher,
|
||||
created_at: daysAgo(4, 9, 10),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
|
||||
transaction_type: 'Correspond',
|
||||
data: { body: 'I reset my password this morning and now the VPN client rejects the new password. Browser login works.' },
|
||||
creator_id: userIds.system,
|
||||
created_at: daysAgo(4, 9, 12),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
|
||||
transaction_type: 'Comment',
|
||||
data: { body: 'Likely stale cached credentials. Ask user to clear saved VPN profile and confirm MFA prompt.' },
|
||||
creator_id: userIds.dispatcher,
|
||||
created_at: hoursAgo(3),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: daysAgo(2, 10, 15),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'in_progress',
|
||||
creator_id: userIds.technician,
|
||||
created_at: daysAgo(2, 11, 0),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
|
||||
transaction_type: 'Comment',
|
||||
data: { body: 'Device reaches Wi-Fi but sync service returns 409. Pulling logs before factory reset.' },
|
||||
creator_id: userIds.technician,
|
||||
created_at: hoursAgo(1),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Badge reader intermittently denies access at north entrance')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: hoursAgo(7),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Badge reader intermittently denies access at north entrance')!.id,
|
||||
transaction_type: 'Correspond',
|
||||
data: { body: 'Three employees reported failures between 07:40 and 08:05. Security desk can override manually.' },
|
||||
creator_id: userIds.security,
|
||||
created_at: hoursAgo(6),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: daysAgo(1, 14, 20),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'open',
|
||||
creator_id: userIds.facilities,
|
||||
created_at: daysAgo(1, 15, 0),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
|
||||
transaction_type: 'Comment',
|
||||
data: { body: 'Cable path looks strained. Spare HDMI and USB-C adapters staged in the room.' },
|
||||
creator_id: userIds.facilities,
|
||||
created_at: hoursAgo(4),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: daysAgo(6, 13, 30),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'open',
|
||||
creator_id: userIds.dispatcher,
|
||||
created_at: daysAgo(6, 14, 0),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
|
||||
transaction_type: 'Correspond',
|
||||
data: { body: 'Laptop imaged, account created, and pickup instructions sent to hiring manager.' },
|
||||
creator_id: userIds.dispatcher,
|
||||
created_at: daysAgo(1, 16, 20),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'open',
|
||||
new_value: 'resolved',
|
||||
creator_id: userIds.dispatcher,
|
||||
created_at: daysAgo(1, 16, 45),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: hoursAgo(18),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'in_progress',
|
||||
creator_id: userIds.facilities,
|
||||
created_at: hoursAgo(17),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
|
||||
transaction_type: 'Comment',
|
||||
data: { body: 'Portable cooling installed. HVAC vendor scheduled; rack intake is back under threshold.' },
|
||||
creator_id: userIds.facilities,
|
||||
created_at: hoursAgo(2),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Quarterly access review export requested')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: daysAgo(9, 10, 0),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('Quarterly access review export requested')!.id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'closed',
|
||||
creator_id: userIds.security,
|
||||
created_at: daysAgo(3, 11, 20),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('POS terminal receipt printer jam at front desk')!.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: userIds.system,
|
||||
created_at: hoursAgo(5),
|
||||
},
|
||||
{
|
||||
ticket_id: ticketBySubject.get('POS terminal receipt printer jam at front desk')!.id,
|
||||
transaction_type: 'Correspond',
|
||||
data: { body: 'Front desk can still email receipts, but lunch rush needs a working printer.' },
|
||||
creator_id: userIds.system,
|
||||
created_at: hoursAgo(5),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(transactions).values(txRows);
|
||||
|
||||
const fieldRows = [
|
||||
['VPN access fails after password reset', impactField.id, 'Medium'],
|
||||
['VPN access fails after password reset', channelField.id, 'Portal'],
|
||||
['VPN access fails after password reset', locationField.id, 'Remote'],
|
||||
['Warehouse scanner ASSET-1042 will not sync inventory', impactField.id, 'High'],
|
||||
['Warehouse scanner ASSET-1042 will not sync inventory', channelField.id, 'Phone'],
|
||||
['Warehouse scanner ASSET-1042 will not sync inventory', locationField.id, 'Warehouse A'],
|
||||
['Warehouse scanner ASSET-1042 will not sync inventory', assetField.id, 'ASSET-1042'],
|
||||
['Badge reader intermittently denies access at north entrance', impactField.id, 'High'],
|
||||
['Badge reader intermittently denies access at north entrance', channelField.id, 'Walk-up'],
|
||||
['Badge reader intermittently denies access at north entrance', locationField.id, 'North entrance'],
|
||||
['Conference room display flickers during video calls', impactField.id, 'Medium'],
|
||||
['Conference room display flickers during video calls', channelField.id, 'Email'],
|
||||
['Conference room display flickers during video calls', locationField.id, 'Room 4B'],
|
||||
['New hire laptop provisioning for Monday start', impactField.id, 'Low'],
|
||||
['New hire laptop provisioning for Monday start', channelField.id, 'Portal'],
|
||||
['New hire laptop provisioning for Monday start', assetField.id, 'ASSET-2201'],
|
||||
['New hire laptop provisioning for Monday start', outcomeField.id, 'Completed'],
|
||||
['Temperature alert in server closet B', impactField.id, 'Critical'],
|
||||
['Temperature alert in server closet B', channelField.id, 'Monitoring'],
|
||||
['Temperature alert in server closet B', locationField.id, 'Server closet B'],
|
||||
['Quarterly access review export requested', impactField.id, 'Low'],
|
||||
['Quarterly access review export requested', channelField.id, 'Portal'],
|
||||
['Quarterly access review export requested', outcomeField.id, 'Completed'],
|
||||
['POS terminal receipt printer jam at front desk', impactField.id, 'Medium'],
|
||||
['POS terminal receipt printer jam at front desk', channelField.id, 'Phone'],
|
||||
['POS terminal receipt printer jam at front desk', locationField.id, 'Front desk'],
|
||||
] as const;
|
||||
|
||||
await db.insert(customFieldValues).values(fieldRows.map(([subject, fieldId, value]) => ({
|
||||
ticket_id: ticketBySubject.get(subject)!.id,
|
||||
custom_field_id: fieldId,
|
||||
value,
|
||||
})));
|
||||
|
||||
console.log(`${reset ? 'Reset and seeded' : 'Seeded'} ${demoTickets.length} demo tickets across 4 queues`);
|
||||
|
||||
// ── Dashboard seeding ──
|
||||
const dashboardViews = [
|
||||
{ name: 'Open tickets', filters: [{ field: 'status', operator: 'is', value: 'open' }] },
|
||||
{ name: 'My tickets', filters: [{ field: 'owner', operator: 'is', value: userIds.dispatcher }] },
|
||||
{ name: 'Unassigned', filters: [{ field: 'owner', operator: 'is', value: 'unassigned' }] },
|
||||
{ name: 'All tickets', filters: [] },
|
||||
];
|
||||
|
||||
const viewRecords: Record<string, string> = {};
|
||||
for (const v of dashboardViews) {
|
||||
const [row] = await db.insert(views).values({
|
||||
name: v.name,
|
||||
filters: v.filters,
|
||||
is_public: true,
|
||||
}).returning();
|
||||
if (row) viewRecords[v.name] = row.id;
|
||||
}
|
||||
|
||||
const [dashboard] = await db.insert(dashboards).values({
|
||||
name: 'Support overview',
|
||||
description: 'Daily support team dashboard',
|
||||
is_default: true,
|
||||
}).returning();
|
||||
|
||||
if (dashboard) {
|
||||
const widgetDefs = [
|
||||
{ view: 'Open tickets', type: 'count', title: 'Open tickets', x: 0, y: 0, w: 3, h: 1 },
|
||||
{ view: 'My tickets', type: 'count', title: 'My tickets', x: 3, y: 0, w: 3, h: 1 },
|
||||
{ view: 'Unassigned', type: 'count', title: 'Unassigned', x: 6, y: 0, w: 3, h: 1 },
|
||||
{ view: 'All tickets', type: 'count', title: 'Total tickets', x: 9, y: 0, w: 3, h: 1 },
|
||||
{ view: 'Open tickets', type: 'status_chart', title: 'Status breakdown', x: 0, y: 1, w: 4, h: 2 },
|
||||
{ view: 'Open tickets', type: 'ticket_list', title: 'Recent open', x: 4, y: 1, w: 5, h: 2, config: { limit: 5 } },
|
||||
{ view: 'All tickets', type: 'grouped_counts', title: 'By queue', x: 9, y: 1, w: 3, h: 2, config: { group_by: 'queue' } },
|
||||
];
|
||||
|
||||
for (const w of widgetDefs) {
|
||||
await db.insert(dashboardWidgets).values({
|
||||
dashboard_id: dashboard.id,
|
||||
view_id: viewRecords[w.view],
|
||||
title: w.title,
|
||||
widget_type: w.type,
|
||||
position: { x: w.x, y: w.y, w: w.w, h: w.h },
|
||||
config: w.config ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded dashboard "${dashboard.name}" with ${widgetDefs.length} widgets`);
|
||||
}
|
||||
|
||||
console.log('Demo data ready');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
11
src/index.ts
11
src/index.ts
@@ -10,6 +10,11 @@ import { createQueuesRouter } from './routes/queues.ts';
|
||||
import { createScripsRouter } from './routes/scrips.ts';
|
||||
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';
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
@@ -31,6 +36,11 @@ 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('/views', createViewsRouter(getDb()));
|
||||
app.route('/dashboards', createDashboardsRouter(getDb()));
|
||||
app.route('/teams', createTeamsRouter(getDb()));
|
||||
|
||||
export default app;
|
||||
export { app };
|
||||
@@ -41,6 +51,7 @@ if (Bun.main === import.meta.path) {
|
||||
fetch: app.fetch,
|
||||
port: config.SERVER_PORT,
|
||||
hostname: config.SERVER_HOST,
|
||||
development: false,
|
||||
});
|
||||
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -7,10 +7,19 @@ export type Ticket = InferSelectModel<typeof tickets>;
|
||||
export const CreateTicketSchema = z.object({
|
||||
subject: z.string().min(1),
|
||||
queue_id: z.string().uuid(),
|
||||
description: z.string().trim().optional(),
|
||||
custom_fields: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const UpdateTicketSchema = z.object({
|
||||
subject: z.string().min(1).optional(),
|
||||
status: z.string().min(1).optional(),
|
||||
owner_id: z.string().uuid().optional(),
|
||||
owner_id: z.string().uuid().nullable().optional(),
|
||||
team_id: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export const CommentSchema = z.object({
|
||||
body: z.string().min(1),
|
||||
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
|
||||
internal: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { customFields } from '../db/schema.ts';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { customFields, queueCustomFields } from '../db/schema.ts';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
|
||||
function makeFieldKey(value: string): string {
|
||||
const key = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return key || 'field';
|
||||
}
|
||||
|
||||
export function createCustomFieldsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
@@ -17,12 +26,14 @@ 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' });
|
||||
}
|
||||
|
||||
const [cf] = await db.insert(customFields).values({
|
||||
key,
|
||||
name,
|
||||
field_type,
|
||||
values: values ?? null,
|
||||
@@ -37,5 +48,94 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
||||
return c.json(cf, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Custom field not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof customFields.$inferInsert> = {};
|
||||
if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key));
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
if (body.field_type !== undefined) updateData.field_type = String(body.field_type);
|
||||
if (body.values !== undefined) updateData.values = body.values ?? null;
|
||||
if (body.max_values !== undefined) updateData.max_values = Number(body.max_values);
|
||||
if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null;
|
||||
|
||||
const [updated] = await db.update(customFields)
|
||||
.set(updateData)
|
||||
.where(eq(customFields.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.get('/queues/:queueId', async (c) => {
|
||||
const queueId = c.req.param('queueId');
|
||||
const assignments = await db.query.queueCustomFields.findMany({
|
||||
where: eq(queueCustomFields.queue_id, queueId),
|
||||
orderBy: asc(queueCustomFields.sort_order),
|
||||
});
|
||||
const fieldIds = assignments.map((assignment) => assignment.custom_field_id);
|
||||
const fields = fieldIds.length > 0
|
||||
? await db.query.customFields.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, fieldIds),
|
||||
})
|
||||
: [];
|
||||
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
||||
|
||||
return c.json(assignments.map((assignment) => ({
|
||||
...assignment,
|
||||
custom_field: fieldMap.get(assignment.custom_field_id) ?? null,
|
||||
})));
|
||||
});
|
||||
|
||||
router.post('/queues/:queueId', async (c) => {
|
||||
const queueId = c.req.param('queueId');
|
||||
const body = await c.req.json();
|
||||
const customFieldId = body.custom_field_id;
|
||||
|
||||
if (!customFieldId) {
|
||||
throw new HTTPException(400, { message: 'custom_field_id is required' });
|
||||
}
|
||||
|
||||
const [assignment] = await db.insert(queueCustomFields).values({
|
||||
queue_id: queueId,
|
||||
custom_field_id: customFieldId,
|
||||
sort_order: Number(body.sort_order ?? 0),
|
||||
}).onConflictDoNothing().returning();
|
||||
|
||||
if (assignment) {
|
||||
return c.json(assignment, 201);
|
||||
}
|
||||
|
||||
const existing = await db.query.queueCustomFields.findFirst({
|
||||
where: and(
|
||||
eq(queueCustomFields.queue_id, queueId),
|
||||
eq(queueCustomFields.custom_field_id, customFieldId),
|
||||
),
|
||||
});
|
||||
|
||||
return c.json(existing, 200);
|
||||
});
|
||||
|
||||
router.delete('/queues/:queueId/:fieldId', async (c) => {
|
||||
const queueId = c.req.param('queueId');
|
||||
const fieldId = c.req.param('fieldId');
|
||||
|
||||
await db.delete(queueCustomFields).where(and(
|
||||
eq(queueCustomFields.queue_id, queueId),
|
||||
eq(queueCustomFields.custom_field_id, fieldId),
|
||||
));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
386
src/routes/dashboards.ts
Normal file
386
src/routes/dashboards.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import {
|
||||
dashboards,
|
||||
dashboardWidgets,
|
||||
tickets,
|
||||
customFieldValues,
|
||||
customFields,
|
||||
lifecycles,
|
||||
queues,
|
||||
views,
|
||||
} from '../db/schema.ts';
|
||||
|
||||
function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string {
|
||||
if (def.statuses.initial.includes(status)) return 'initial';
|
||||
if (def.statuses.active.includes(status)) return 'active';
|
||||
if (def.statuses.inactive.includes(status)) return 'inactive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function createDashboardsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// ── Dashboards CRUD ──
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.dashboards.findMany({
|
||||
orderBy: asc(dashboards.name),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name ?? '').trim();
|
||||
if (!name) {
|
||||
throw new HTTPException(400, { message: 'name is required' });
|
||||
}
|
||||
|
||||
const [dashboard] = await db.insert(dashboards).values({
|
||||
name,
|
||||
description: body.description ?? null,
|
||||
team_id: body.team_id || null,
|
||||
layout: body.layout ?? [],
|
||||
is_default: body.is_default ?? false,
|
||||
}).returning();
|
||||
|
||||
if (!dashboard) {
|
||||
throw new HTTPException(500, { message: 'Failed to create dashboard' });
|
||||
}
|
||||
|
||||
return c.json(dashboard, 201);
|
||||
});
|
||||
|
||||
router.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const dashboard = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, id),
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
const widgets = await db.query.dashboardWidgets.findMany({
|
||||
where: eq(dashboardWidgets.dashboard_id, id),
|
||||
orderBy: asc(dashboardWidgets.created_at),
|
||||
});
|
||||
|
||||
return c.json({ ...dashboard, widgets });
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof dashboards.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||
if (body.layout !== undefined) updateData.layout = body.layout;
|
||||
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
if (body.is_default !== undefined) {
|
||||
updateData.is_default = body.is_default;
|
||||
if (body.is_default) {
|
||||
await db.update(dashboards)
|
||||
.set({ is_default: false })
|
||||
.where(eq(dashboards.is_default, true));
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db.update(dashboards)
|
||||
.set(updateData)
|
||||
.where(eq(dashboards.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
await db.delete(dashboards).where(eq(dashboards.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Widgets CRUD ──
|
||||
|
||||
router.get('/:id/widgets', async (c) => {
|
||||
const dashboardId = c.req.param('id');
|
||||
const result = await db.query.dashboardWidgets.findMany({
|
||||
where: eq(dashboardWidgets.dashboard_id, dashboardId),
|
||||
orderBy: asc(dashboardWidgets.created_at),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/:id/widgets', async (c) => {
|
||||
const dashboardId = c.req.param('id');
|
||||
const dashboard = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, dashboardId),
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const title = String(body.title ?? 'Widget').trim();
|
||||
const widgetType = String(body.widget_type ?? 'count').trim();
|
||||
const viewId = String(body.view_id ?? '').trim();
|
||||
|
||||
if (!viewId) {
|
||||
throw new HTTPException(400, { message: 'view_id is required' });
|
||||
}
|
||||
|
||||
const [widget] = await db.insert(dashboardWidgets).values({
|
||||
dashboard_id: dashboardId,
|
||||
view_id: viewId,
|
||||
title,
|
||||
widget_type: widgetType,
|
||||
position: body.position ?? { x: 0, y: 0, w: 4, h: 2 },
|
||||
config: body.config ?? {},
|
||||
}).returning();
|
||||
|
||||
if (!widget) {
|
||||
throw new HTTPException(500, { message: 'Failed to create widget' });
|
||||
}
|
||||
|
||||
return c.json(widget, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id/widgets/:widgetId', async (c) => {
|
||||
const widgetId = c.req.param('widgetId');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.dashboardWidgets.findFirst({
|
||||
where: eq(dashboardWidgets.id, widgetId),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Widget not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof dashboardWidgets.$inferInsert> = {};
|
||||
if (body.title !== undefined) updateData.title = String(body.title).trim();
|
||||
if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type);
|
||||
if (body.position !== undefined) updateData.position = body.position;
|
||||
if (body.config !== undefined) updateData.config = body.config;
|
||||
|
||||
const [updated] = await db.update(dashboardWidgets)
|
||||
.set(updateData)
|
||||
.where(eq(dashboardWidgets.id, widgetId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id/widgets/:widgetId', async (c) => {
|
||||
const widgetId = c.req.param('widgetId');
|
||||
const existing = await db.query.dashboardWidgets.findFirst({
|
||||
where: eq(dashboardWidgets.id, widgetId),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Widget not found' });
|
||||
}
|
||||
|
||||
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Widget data endpoint ──
|
||||
|
||||
router.get('/:id/widgets/:widgetId/data', async (c) => {
|
||||
const widgetId = c.req.param('widgetId');
|
||||
|
||||
const widget = await db.query.dashboardWidgets.findFirst({
|
||||
where: eq(dashboardWidgets.id, widgetId),
|
||||
});
|
||||
|
||||
if (!widget) {
|
||||
throw new HTTPException(404, { message: 'Widget not found' });
|
||||
}
|
||||
|
||||
const view = await db.query.views.findFirst({
|
||||
where: eq(views.id, widget.view_id),
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
return c.json({ error: 'View not found' }, 404);
|
||||
}
|
||||
|
||||
// Apply saved view filters
|
||||
const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[];
|
||||
let result = await db.query.tickets.findMany({
|
||||
orderBy: asc(tickets.created_at),
|
||||
});
|
||||
|
||||
for (const f of savedFilters) {
|
||||
if (f.field === 'status') {
|
||||
result = result.filter((t) => t.status === f.value);
|
||||
} else if (f.field === 'queue') {
|
||||
result = result.filter((t) => t.queue_id === f.value);
|
||||
} else if (f.field === 'owner') {
|
||||
result = f.value === 'unassigned'
|
||||
? result.filter((t) => !t.owner_id)
|
||||
: result.filter((t) => t.owner_id === f.value);
|
||||
} else if (f.field.startsWith('cf.')) {
|
||||
const cfKey = f.field.slice(3);
|
||||
const ticketIds = result.map((t) => t.id);
|
||||
if (ticketIds.length > 0) {
|
||||
const cfValues = await db.query.customFieldValues.findMany({
|
||||
where: (table, { and, inArray, eq }) =>
|
||||
and(
|
||||
inArray(table.ticket_id, ticketIds),
|
||||
eq(table.value, f.value),
|
||||
),
|
||||
});
|
||||
const matchingIds = new Set(cfValues.map((v) => v.ticket_id));
|
||||
// Also find the field ID for the key
|
||||
const cfField = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.key, cfKey),
|
||||
});
|
||||
if (cfField) {
|
||||
const cfValuesForField = await db.query.customFieldValues.findMany({
|
||||
where: (table, { and, inArray, eq }) =>
|
||||
and(
|
||||
inArray(table.ticket_id, ticketIds),
|
||||
eq(table.custom_field_id, cfField.id),
|
||||
eq(table.value, f.value),
|
||||
),
|
||||
});
|
||||
const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id));
|
||||
result = result.filter((t) => matchSet.has(t.id));
|
||||
} else {
|
||||
result = result.filter((t) => matchingIds.has(t.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
|
||||
|
||||
// Find lifecycle for status classification
|
||||
const queueIds = [...new Set(result.map((r) => r.queue_id))];
|
||||
const queueRecords = queueIds.length > 0
|
||||
? await db.query.queues.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, queueIds),
|
||||
})
|
||||
: [];
|
||||
const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[];
|
||||
const lifecycleRecords = lifecycleIds.length > 0
|
||||
? await db.query.lifecycles.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, lifecycleIds),
|
||||
})
|
||||
: [];
|
||||
const lifecycleByQueue = new Map<string, { statuses: { initial: string[]; active: string[]; inactive: string[] } }>();
|
||||
for (const qr of queueRecords) {
|
||||
if (qr.lifecycle_id) {
|
||||
const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id);
|
||||
if (lc) lifecycleByQueue.set(qr.id, lc.definition as any);
|
||||
}
|
||||
}
|
||||
|
||||
// Get owner usernames
|
||||
const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[];
|
||||
const ownerUsers = ownerIds.length > 0
|
||||
? await db.query.users.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, ownerIds),
|
||||
})
|
||||
: [];
|
||||
const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username]));
|
||||
|
||||
// Get queue names
|
||||
const queueName = new Map(queueRecords.map((q) => [q.id, q.name]));
|
||||
|
||||
switch (widget.widget_type) {
|
||||
case 'count': {
|
||||
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'ticket_list': {
|
||||
const slice = result.slice(0, limit).map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
owner_id: ticket.owner_id,
|
||||
owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null,
|
||||
queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8),
|
||||
updated_at: ticket.updated_at?.toISOString(),
|
||||
}));
|
||||
return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'status_chart': {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const ticket of result) {
|
||||
counts[ticket.status] = (counts[ticket.status] ?? 0) + 1;
|
||||
}
|
||||
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'grouped_counts': {
|
||||
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
|
||||
const groups: Record<string, number> = {};
|
||||
|
||||
if (groupBy === 'owner') {
|
||||
for (const ticket of result) {
|
||||
const label = ticket.owner_id
|
||||
? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8))
|
||||
: 'Unassigned';
|
||||
groups[label] = (groups[label] ?? 0) + 1;
|
||||
}
|
||||
} else if (groupBy === 'queue') {
|
||||
for (const ticket of result) {
|
||||
const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8);
|
||||
groups[label] = (groups[label] ?? 0) + 1;
|
||||
}
|
||||
} else if (groupBy.startsWith('cf.')) {
|
||||
const cfKey = groupBy.slice(3);
|
||||
const cfField = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.key, cfKey),
|
||||
});
|
||||
if (cfField) {
|
||||
const ticketIds = result.map((t) => t.id);
|
||||
const cfValues = ticketIds.length > 0
|
||||
? await db.query.customFieldValues.findMany({
|
||||
where: (table, { and, inArray, eq }) =>
|
||||
and(
|
||||
inArray(table.ticket_id, ticketIds),
|
||||
eq(table.custom_field_id, cfField.id),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
for (const v of cfValues) {
|
||||
groups[v.value] = (groups[v.value] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
default:
|
||||
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { lifecycles } from '../db/schema.ts';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
|
||||
export function createLifecyclesRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
@@ -34,5 +34,29 @@ export function createLifecyclesRouter(db: Db): Hono {
|
||||
return c.json(lifecycle, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Lifecycle not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof lifecycles.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
if (body.definition !== undefined) updateData.definition = body.definition;
|
||||
|
||||
const [updated] = await db.update(lifecycles)
|
||||
.set(updateData)
|
||||
.where(eq(lifecycles.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { queues } from '../db/schema.ts';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { CreateQueueSchema } from '../models/queue.ts';
|
||||
|
||||
export function createQueuesRouter(db: Db): Hono {
|
||||
@@ -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) {
|
||||
@@ -32,5 +33,31 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
return c.json(queue, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Queue not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof queues.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null;
|
||||
if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null;
|
||||
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
|
||||
const [updated] = await db.update(queues)
|
||||
.set(updateData)
|
||||
.where(eq(queues.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
98
src/routes/teams.ts
Normal file
98
src/routes/teams.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { teams, teamMembers, users, dashboards } from '../db/schema.ts';
|
||||
|
||||
export function createTeamsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /teams — list all with member details
|
||||
router.get('/', async (c) => {
|
||||
const allTeams = await db.query.teams.findMany({
|
||||
orderBy: asc(teams.name),
|
||||
});
|
||||
|
||||
const result = await Promise.all(allTeams.map(async (team) => {
|
||||
const members = await db.query.teamMembers.findMany({
|
||||
where: eq(teamMembers.team_id, team.id),
|
||||
});
|
||||
const memberUsers = members.length > 0
|
||||
? await db.query.users.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, members.map((m) => m.user_id)),
|
||||
})
|
||||
: [];
|
||||
return { ...team, members: memberUsers };
|
||||
}));
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST /teams
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name ?? '').trim();
|
||||
if (!name) throw new HTTPException(400, { message: 'name is required' });
|
||||
|
||||
const [team] = await db.insert(teams).values({
|
||||
name,
|
||||
description: body.description ?? null,
|
||||
}).returning();
|
||||
if (!team) throw new HTTPException(500, { message: 'Failed to create team' });
|
||||
return c.json(team, 201);
|
||||
});
|
||||
|
||||
// PATCH /teams/:id
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
|
||||
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
|
||||
|
||||
const updateData: Partial<typeof teams.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||
|
||||
const [updated] = await db.update(teams).set(updateData).where(eq(teams.id, id)).returning();
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /teams/:id
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = await db.query.teams.findFirst({ where: eq(teams.id, id) });
|
||||
if (!existing) throw new HTTPException(404, { message: 'Team not found' });
|
||||
await db.delete(teams).where(eq(teams.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /teams/:id/members — add member
|
||||
router.post('/:id/members', async (c) => {
|
||||
const teamId = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const userId = String(body.user_id ?? '').trim();
|
||||
if (!userId) throw new HTTPException(400, { message: 'user_id is required' });
|
||||
|
||||
const team = await db.query.teams.findFirst({ where: eq(teams.id, teamId) });
|
||||
if (!team) throw new HTTPException(404, { message: 'Team not found' });
|
||||
|
||||
const [member] = await db.insert(teamMembers).values({
|
||||
team_id: teamId,
|
||||
user_id: userId,
|
||||
}).returning();
|
||||
if (!member) throw new HTTPException(500, { message: 'Failed to add member' });
|
||||
return c.json(member, 201);
|
||||
});
|
||||
|
||||
// DELETE /teams/:id/members/:userId
|
||||
router.delete('/:id/members/:userId', async (c) => {
|
||||
const teamId = c.req.param('id');
|
||||
const userId = c.req.param('userId');
|
||||
await db.delete(teamMembers).where(
|
||||
and(eq(teamMembers.team_id, teamId), eq(teamMembers.user_id, userId))
|
||||
);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
189
src/routes/templates.ts
Normal file
189
src/routes/templates.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { asc, desc, eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { customFieldValues, queues, templates, tickets, transactions } from '../db/schema.ts';
|
||||
import { TemplateRenderer } from '../scrip/templates.ts';
|
||||
import type { TemplateContext } from '../scrip/templates.ts';
|
||||
|
||||
function buildDemoContext(): TemplateContext {
|
||||
return {
|
||||
ticket: {
|
||||
id: 1001,
|
||||
subject: 'Replace access badge reader',
|
||||
status: 'open',
|
||||
queue_id: 'demo-queue',
|
||||
owner_id: null,
|
||||
creator_id: 'demo-user',
|
||||
created_at: new Date('2026-06-08T08:00:00.000Z').toISOString(),
|
||||
updated_at: new Date('2026-06-08T09:15:00.000Z').toISOString(),
|
||||
},
|
||||
queue: { name: 'Support Desk' },
|
||||
transaction: {
|
||||
type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: 'new',
|
||||
new_value: 'open',
|
||||
},
|
||||
custom_fields: {
|
||||
impact: 'High',
|
||||
location: 'HQ 2nd floor',
|
||||
channel: 'Portal',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildTicketContext(db: Db, ticketId: number): Promise<TemplateContext> {
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const queue = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, ticket.queue_id),
|
||||
});
|
||||
const latestTx = await db.query.transactions.findFirst({
|
||||
where: eq(transactions.ticket_id, ticket.id),
|
||||
orderBy: desc(transactions.created_at),
|
||||
});
|
||||
const cfValues = await db.query.customFieldValues.findMany({
|
||||
where: eq(customFieldValues.ticket_id, ticket.id),
|
||||
});
|
||||
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
|
||||
const fields = fieldIds.length > 0
|
||||
? await db.query.customFields.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, fieldIds),
|
||||
})
|
||||
: [];
|
||||
const fieldById = new Map(fields.map((field) => [field.id, field]));
|
||||
const customFieldsMap: Record<string, string> = {};
|
||||
|
||||
for (const value of cfValues) {
|
||||
const field = fieldById.get(value.custom_field_id);
|
||||
if (field) customFieldsMap[field.key] = value.value;
|
||||
}
|
||||
|
||||
return {
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
queue_id: ticket.queue_id,
|
||||
owner_id: ticket.owner_id,
|
||||
creator_id: ticket.creator_id,
|
||||
created_at: ticket.created_at?.toISOString() ?? new Date().toISOString(),
|
||||
updated_at: ticket.updated_at?.toISOString() ?? new Date().toISOString(),
|
||||
},
|
||||
queue: {
|
||||
name: queue?.name ?? 'unknown',
|
||||
},
|
||||
transaction: {
|
||||
type: latestTx?.transaction_type ?? 'Preview',
|
||||
field: latestTx?.field ?? null,
|
||||
old_value: latestTx?.old_value ?? null,
|
||||
new_value: latestTx?.new_value ?? null,
|
||||
},
|
||||
custom_fields: customFieldsMap,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTemplatesRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
const renderer = new TemplateRenderer();
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.templates.findMany({
|
||||
orderBy: asc(templates.name),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name ?? '').trim();
|
||||
const subjectTemplate = String(body.subject_template ?? '');
|
||||
const bodyTemplate = String(body.body_template ?? '');
|
||||
|
||||
if (!name || !subjectTemplate || !bodyTemplate) {
|
||||
throw new HTTPException(400, { message: 'name, subject_template, and body_template are required' });
|
||||
}
|
||||
|
||||
const [template] = await db.insert(templates).values({
|
||||
name,
|
||||
queue_id: body.queue_id || null,
|
||||
subject_template: subjectTemplate,
|
||||
body_template: bodyTemplate,
|
||||
}).returning();
|
||||
|
||||
if (!template) {
|
||||
throw new HTTPException(500, { message: 'Failed to create template' });
|
||||
}
|
||||
|
||||
return c.json(template, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.templates.findFirst({
|
||||
where: eq(templates.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Template not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof templates.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||
if (body.queue_id !== undefined) updateData.queue_id = body.queue_id || null;
|
||||
if (body.subject_template !== undefined) updateData.subject_template = String(body.subject_template);
|
||||
if (body.body_template !== undefined) updateData.body_template = String(body.body_template);
|
||||
|
||||
const [updated] = await db.update(templates)
|
||||
.set(updateData)
|
||||
.where(eq(templates.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.templates.findFirst({
|
||||
where: eq(templates.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Template not found' });
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/preview', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const subjectTemplate = String(body.subject_template ?? '');
|
||||
const bodyTemplate = String(body.body_template ?? '');
|
||||
const ticketId = body.ticket_id === undefined || body.ticket_id === null || body.ticket_id === ''
|
||||
? null
|
||||
: Number(body.ticket_id);
|
||||
|
||||
if (!subjectTemplate || !bodyTemplate) {
|
||||
throw new HTTPException(400, { message: 'subject_template and body_template are required' });
|
||||
}
|
||||
|
||||
const context = ticketId ? await buildTicketContext(db, ticketId) : buildDemoContext();
|
||||
return c.json({
|
||||
...renderer.render(subjectTemplate, bodyTemplate, context),
|
||||
context,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema } from '../models/ticket.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts';
|
||||
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||
import { ScripEngine } from '../scrip/engine.ts';
|
||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||
@@ -13,55 +13,269 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
const scripEngine = new ScripEngine(db);
|
||||
const lifecycleValidator = new LifecycleValidator();
|
||||
|
||||
function statusClass(def: LifecycleDefinition, status: string): 'initial' | 'active' | 'inactive' | 'unknown' {
|
||||
if (def.statuses.initial.includes(status)) return 'initial';
|
||||
if (def.statuses.active.includes(status)) return 'active';
|
||||
if (def.statuses.inactive.includes(status)) return 'inactive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// GET / — list tickets
|
||||
router.get('/', async (c) => {
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
const queueId = c.req.query('queue_id');
|
||||
const status = c.req.query('status');
|
||||
const ownerId = c.req.query('owner_id');
|
||||
const teamId = c.req.query('team_id');
|
||||
const query = c.req.query('q')?.trim() ?? '';
|
||||
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
||||
const cfFilters = [...params.entries()]
|
||||
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
||||
.map(([key, value]) => ({
|
||||
key: key.slice(3),
|
||||
value: value.trim(),
|
||||
}));
|
||||
|
||||
// Build SQL WHERE conditions
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
|
||||
if (queueId) {
|
||||
conditions.push(eq(tickets.queue_id, queueId));
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(eq(tickets.status, status));
|
||||
}
|
||||
if (ownerId) {
|
||||
conditions.push(
|
||||
ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId)
|
||||
);
|
||||
}
|
||||
if (teamId) {
|
||||
// Resolve team members and filter tickets by those owner_ids
|
||||
const members = await db.query.teamMembers.findMany({
|
||||
where: eq(teamMembers.team_id, teamId),
|
||||
});
|
||||
const memberIds = members.map((m) => m.user_id);
|
||||
if (memberIds.length > 0) {
|
||||
conditions.push(inArray(tickets.owner_id, memberIds));
|
||||
} else {
|
||||
conditions.push(isNull(tickets.owner_id)); // empty team = no results
|
||||
}
|
||||
}
|
||||
|
||||
// Text search: push to SQL via ilike on ticket columns + queue name join
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(tickets.subject, pattern),
|
||||
ilike(tickets.status, pattern),
|
||||
sql`${tickets.id}::text ILIKE ${pattern}`
|
||||
)!
|
||||
);
|
||||
// Queue name search requires join — keep as post-filter
|
||||
}
|
||||
|
||||
// Custom field filters: use EXISTS subquery
|
||||
for (const cf of cfFilters) {
|
||||
conditions.push(
|
||||
exists(
|
||||
db.select({ n: sql`1` })
|
||||
.from(customFieldValues)
|
||||
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
|
||||
.where(
|
||||
and(
|
||||
eq(customFieldValues.ticket_id, tickets.id),
|
||||
eq(customFields.key, cf.key),
|
||||
eq(customFieldValues.value, cf.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.query.tickets.findMany({
|
||||
where: (t, { and, eq }) => {
|
||||
const conditions = [];
|
||||
if (queueId) conditions.push(eq(t.queue_id, queueId));
|
||||
if (status) conditions.push(eq(t.status, status));
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
},
|
||||
where: conditions.length > 0 ? and(...conditions) : undefined,
|
||||
orderBy: asc(tickets.created_at),
|
||||
limit,
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
// Post-filter for queue name text search (requires in-memory join)
|
||||
let filtered = result;
|
||||
if (query) {
|
||||
const queuesForSearch = await db.query.queues.findMany();
|
||||
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
|
||||
filtered = result.filter((ticket) =>
|
||||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Attach custom field values to all tickets
|
||||
if (filtered.length > 0) {
|
||||
const ticketIds = filtered.map((t) => t.id);
|
||||
const allCfValues = await db.query.customFieldValues.findMany({
|
||||
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
|
||||
});
|
||||
const fieldIds = [...new Set(allCfValues.map((v) => v.custom_field_id))];
|
||||
const allFields = fieldIds.length > 0
|
||||
? await db.query.customFields.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, fieldIds),
|
||||
})
|
||||
: [];
|
||||
const fieldMap = new Map(allFields.map((f) => [f.id, f]));
|
||||
|
||||
const ticketsWithCf = filtered.map((ticket) => {
|
||||
const cfs = allCfValues
|
||||
.filter((v) => v.ticket_id === ticket.id)
|
||||
.map((v) => ({
|
||||
id: v.id,
|
||||
custom_field_id: v.custom_field_id,
|
||||
ticket_id: v.ticket_id,
|
||||
value: v.value,
|
||||
created_at: v.created_at?.toISOString(),
|
||||
custom_field: fieldMap.has(v.custom_field_id) ? {
|
||||
id: v.custom_field_id,
|
||||
key: fieldMap.get(v.custom_field_id)!.key,
|
||||
name: fieldMap.get(v.custom_field_id)!.name,
|
||||
field_type: fieldMap.get(v.custom_field_id)!.field_type,
|
||||
values: fieldMap.get(v.custom_field_id)!.values,
|
||||
max_values: fieldMap.get(v.custom_field_id)!.max_values,
|
||||
pattern: fieldMap.get(v.custom_field_id)!.pattern,
|
||||
} : undefined,
|
||||
}));
|
||||
return { ...ticket, custom_fields: cfs };
|
||||
});
|
||||
|
||||
return c.json(ticketsWithCf);
|
||||
}
|
||||
|
||||
return c.json(filtered);
|
||||
});
|
||||
|
||||
// POST / — create ticket
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateTicketSchema.parse(body);
|
||||
const creatorId = '00000000-0000-0000-0000-000000000000';
|
||||
const customFieldInput = parsed.custom_fields ?? {};
|
||||
const customFieldEntries = Object.entries(customFieldInput)
|
||||
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
|
||||
.filter(([, value]) => value);
|
||||
const queue = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, parsed.queue_id),
|
||||
});
|
||||
|
||||
if (!queue) {
|
||||
throw new HTTPException(422, { message: 'Queue not found' });
|
||||
}
|
||||
|
||||
let initialStatus = 'new';
|
||||
if (queue.lifecycle_id) {
|
||||
const lifecycle = await db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.id, queue.lifecycle_id),
|
||||
});
|
||||
const definition = lifecycle?.definition as LifecycleDefinition | undefined;
|
||||
initialStatus = definition?.statuses.initial[0] ?? initialStatus;
|
||||
}
|
||||
|
||||
let assignedFields: typeof customFields.$inferSelect[] = [];
|
||||
if (customFieldEntries.length > 0) {
|
||||
const assignments = await db.query.queueCustomFields.findMany({
|
||||
where: eq(queueCustomFields.queue_id, parsed.queue_id),
|
||||
});
|
||||
const assignedIds = new Set(assignments.map((assignment) => assignment.custom_field_id));
|
||||
const requestedIds = customFieldEntries.map(([fieldId]) => fieldId);
|
||||
|
||||
for (const fieldId of requestedIds) {
|
||||
if (!assignedIds.has(fieldId)) {
|
||||
throw new HTTPException(422, { message: 'Custom field is not assigned to this queue' });
|
||||
}
|
||||
}
|
||||
|
||||
assignedFields = await db.query.customFields.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, requestedIds),
|
||||
});
|
||||
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
|
||||
|
||||
for (const [fieldId, value] of customFieldEntries) {
|
||||
const field = fieldById.get(fieldId);
|
||||
if (!field) {
|
||||
throw new HTTPException(422, { message: 'Custom field not found' });
|
||||
}
|
||||
if (Array.isArray(field.values) && field.values.length > 0) {
|
||||
const allowed = new Set(field.values.map((option) => String(option)));
|
||||
if (!allowed.has(value)) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
||||
}
|
||||
}
|
||||
if (field.pattern) {
|
||||
const regex = new RegExp(field.pattern);
|
||||
if (!regex.test(value)) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [ticket] = await db.insert(tickets).values({
|
||||
subject: parsed.subject,
|
||||
queue_id: parsed.queue_id,
|
||||
status: 'new',
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
status: initialStatus,
|
||||
creator_id: creatorId,
|
||||
team_id: (queue as any).team_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(500, { message: 'Failed to create ticket' });
|
||||
}
|
||||
|
||||
// Record transaction
|
||||
await db.insert(transactions).values({
|
||||
const txList = [
|
||||
{
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
new_value: initialStatus,
|
||||
creator_id: creatorId,
|
||||
},
|
||||
];
|
||||
|
||||
return c.json(ticket, 201);
|
||||
if (parsed.description) {
|
||||
txList.push({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Correspond',
|
||||
field: null,
|
||||
new_value: null,
|
||||
data: { body: parsed.description },
|
||||
creator_id: creatorId,
|
||||
} as any);
|
||||
}
|
||||
|
||||
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
|
||||
for (const [fieldId, value] of customFieldEntries) {
|
||||
await db.insert(customFieldValues).values({
|
||||
ticket_id: ticket.id,
|
||||
custom_field_id: fieldId,
|
||||
value,
|
||||
});
|
||||
txList.push({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'CustomFieldChange',
|
||||
field: fieldById.get(fieldId)?.key ?? fieldId,
|
||||
new_value: value,
|
||||
creator_id: creatorId,
|
||||
} as any);
|
||||
}
|
||||
|
||||
const createdTransactions = await db.insert(transactions).values(txList as any).returning();
|
||||
const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any);
|
||||
const results = await scripEngine.commit(prepared);
|
||||
|
||||
return c.json({ ticket, scrip_results: results }, 201);
|
||||
});
|
||||
|
||||
// GET /:id — get ticket with custom field values
|
||||
router.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
@@ -92,7 +306,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
|
||||
// PATCH /:id — update ticket
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
const parsed = UpdateTicketSchema.parse(body);
|
||||
|
||||
@@ -104,6 +318,8 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
let lifecycleDef: LifecycleDefinition | null = null;
|
||||
|
||||
// Validate lifecycle transition if status is changing
|
||||
if (parsed.status) {
|
||||
const queue = await db.query.queues.findFirst({
|
||||
@@ -116,8 +332,8 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
});
|
||||
|
||||
if (lifecycle) {
|
||||
const def = lifecycle.definition as LifecycleDefinition;
|
||||
const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status);
|
||||
lifecycleDef = lifecycle.definition as LifecycleDefinition;
|
||||
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
|
||||
if (!result.valid) {
|
||||
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
||||
}
|
||||
@@ -149,7 +365,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) {
|
||||
if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'SetOwner' as const,
|
||||
@@ -160,11 +376,43 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.team_id !== undefined && parsed.team_id !== (ticket as any).team_id) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'SetTeam' as const,
|
||||
field: 'team_id',
|
||||
old_value: (ticket as any).team_id ?? null,
|
||||
new_value: parsed.team_id,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
}
|
||||
|
||||
// Update the ticket
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (parsed.subject) updateData.subject = parsed.subject;
|
||||
if (parsed.status) updateData.status = parsed.status;
|
||||
if (parsed.owner_id) updateData.owner_id = parsed.owner_id;
|
||||
if (parsed.status) {
|
||||
updateData.status = parsed.status;
|
||||
|
||||
if (lifecycleDef && parsed.status !== ticket.status) {
|
||||
const fromClass = statusClass(lifecycleDef, ticket.status);
|
||||
const toClass = statusClass(lifecycleDef, parsed.status);
|
||||
const now = new Date();
|
||||
|
||||
if (fromClass === 'initial' && (toClass === 'active' || toClass === 'inactive') && !ticket.started_at) {
|
||||
updateData.started_at = now;
|
||||
}
|
||||
|
||||
if ((fromClass === 'initial' || fromClass === 'active') && toClass === 'inactive') {
|
||||
updateData.resolved_at = now;
|
||||
}
|
||||
|
||||
if (fromClass === 'inactive' && toClass !== 'inactive') {
|
||||
updateData.resolved_at = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id;
|
||||
if (parsed.team_id !== undefined) updateData.team_id = parsed.team_id;
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
const [updated] = await db.update(tickets)
|
||||
@@ -186,7 +434,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
|
||||
// POST /:id/preview — dry-run scrips
|
||||
router.post('/:id/preview', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
const parsed = UpdateTicketSchema.parse(body);
|
||||
|
||||
@@ -198,6 +446,26 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
if (parsed.status) {
|
||||
const queue = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, ticket.queue_id),
|
||||
});
|
||||
|
||||
if (queue?.lifecycle_id) {
|
||||
const lifecycle = await db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.id, queue.lifecycle_id),
|
||||
});
|
||||
|
||||
if (lifecycle) {
|
||||
const lifecycleDef = lifecycle.definition as LifecycleDefinition;
|
||||
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
|
||||
if (!result.valid) {
|
||||
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const txList: any[] = [];
|
||||
|
||||
if (parsed.status && parsed.status !== ticket.status) {
|
||||
@@ -221,7 +489,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
|
||||
// GET /:id/transactions — list transactions for ticket
|
||||
router.get('/:id/transactions', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
const result = await db.query.transactions.findMany({
|
||||
where: eq(transactions.ticket_id, id),
|
||||
@@ -231,5 +499,128 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST /:id/comment — add a comment (reply or internal note)
|
||||
router.post('/:id/comment', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
const parsed = CommentSchema.parse(body);
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
|
||||
|
||||
const [tx] = await db.insert(transactions).values({
|
||||
ticket_id: id,
|
||||
transaction_type: transactionType,
|
||||
data: { body: parsed.body },
|
||||
creator_id: parsed.creator_id,
|
||||
}).returning();
|
||||
|
||||
if (!tx) {
|
||||
throw new HTTPException(500, { message: 'Failed to create comment' });
|
||||
}
|
||||
|
||||
// Run scrips
|
||||
const txList = [tx];
|
||||
const prepared = await scripEngine.prepare(id, txList as any);
|
||||
await scripEngine.commit(prepared);
|
||||
|
||||
return c.json(tx, 201);
|
||||
});
|
||||
|
||||
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
|
||||
router.patch('/:id/custom-fields/:fieldId', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const fieldId = c.req.param('fieldId');
|
||||
const body = await c.req.json();
|
||||
const value = typeof body.value === 'string' ? body.value.trim() : '';
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const assignment = await db.query.queueCustomFields.findFirst({
|
||||
where: and(
|
||||
eq(queueCustomFields.queue_id, ticket.queue_id),
|
||||
eq(queueCustomFields.custom_field_id, fieldId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!assignment) {
|
||||
throw new HTTPException(422, { message: 'Custom field is not assigned to this ticket queue' });
|
||||
}
|
||||
|
||||
const field = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.id, fieldId),
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
throw new HTTPException(404, { message: 'Custom field not found' });
|
||||
}
|
||||
|
||||
if (value && Array.isArray(field.values) && field.values.length > 0) {
|
||||
const allowed = new Set(field.values.map((option) => String(option)));
|
||||
if (!allowed.has(value)) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
||||
}
|
||||
}
|
||||
|
||||
if (value && field.pattern) {
|
||||
const regex = new RegExp(field.pattern);
|
||||
if (!regex.test(value)) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db.query.customFieldValues.findMany({
|
||||
where: and(
|
||||
eq(customFieldValues.ticket_id, id),
|
||||
eq(customFieldValues.custom_field_id, fieldId),
|
||||
),
|
||||
});
|
||||
const oldValue = existing.map((item) => item.value).join(', ');
|
||||
|
||||
await db.delete(customFieldValues).where(and(
|
||||
eq(customFieldValues.ticket_id, id),
|
||||
eq(customFieldValues.custom_field_id, fieldId),
|
||||
));
|
||||
|
||||
if (value) {
|
||||
await db.insert(customFieldValues).values({
|
||||
ticket_id: id,
|
||||
custom_field_id: fieldId,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
await db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, id));
|
||||
|
||||
const [tx] = await db.insert(transactions).values({
|
||||
ticket_id: id,
|
||||
transaction_type: 'CustomFieldChange',
|
||||
field: field.key,
|
||||
old_value: oldValue || null,
|
||||
new_value: value || null,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
|
||||
const prepared = await scripEngine.prepare(id, [tx] as any);
|
||||
await scripEngine.commit(prepared);
|
||||
|
||||
return c.json(tx, 200);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
78
src/routes/users.ts
Normal file
78
src/routes/users.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
|
||||
export function createUsersRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.users.findMany({
|
||||
orderBy: asc(users.username),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const username = String(body.username ?? '').trim();
|
||||
const email = body.email ? String(body.email).trim() : null;
|
||||
|
||||
if (!username) {
|
||||
throw new HTTPException(400, { message: 'username is required' });
|
||||
}
|
||||
|
||||
const [user] = await db.insert(users).values({
|
||||
username,
|
||||
email,
|
||||
}).returning();
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(500, { message: 'Failed to create user' });
|
||||
}
|
||||
|
||||
return c.json(user, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof users.$inferInsert> = {};
|
||||
if (body.username !== undefined) updateData.username = String(body.username).trim();
|
||||
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
|
||||
|
||||
const [updated] = await db.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
84
src/routes/views.ts
Normal file
84
src/routes/views.ts
Normal 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;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import type { Transporter } from 'nodemailer';
|
||||
import Handlebars from 'handlebars';
|
||||
import { config } from '../config.ts';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { customFieldValues, transactions } from '../db/schema.ts';
|
||||
import * as schema from '../db/schema.ts';
|
||||
import { customFieldValues, tickets, transactions, users } from '../db/schema.ts';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export interface ActionExecutor {
|
||||
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
|
||||
@@ -13,7 +16,7 @@ export interface ActionPayload {
|
||||
scripName: string;
|
||||
actionType: string;
|
||||
actionConfig: Record<string, unknown>;
|
||||
ticketId?: string;
|
||||
ticketId?: number;
|
||||
recipients?: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
@@ -21,12 +24,15 @@ export interface ActionPayload {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
field_id?: string;
|
||||
field_key?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export class SendEmail implements ActionExecutor {
|
||||
private transporter: Transporter | null = null;
|
||||
|
||||
constructor(private db: Db) {}
|
||||
|
||||
private getTransporter(): Transporter {
|
||||
if (!this.transporter) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
@@ -39,8 +45,55 @@ export class SendEmail implements ActionExecutor {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
private async resolveRecipients(payload: ActionPayload): Promise<string[]> {
|
||||
const configuredRecipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
|
||||
const recipients = new Set(configuredRecipients.filter(Boolean));
|
||||
const sources = payload.actionConfig['recipient_sources'] ?? payload.actionConfig['recipient_source'];
|
||||
const recipientSources = Array.isArray(sources)
|
||||
? sources.map((source) => String(source))
|
||||
: sources
|
||||
? [String(sources)]
|
||||
: [];
|
||||
|
||||
if (recipientSources.length === 0 || !payload.ticketId) {
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
const ticket = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, payload.ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const source of recipientSources) {
|
||||
if (['requester', 'requestor', 'requestors', 'creator', 'ticket_creator'].includes(source)) {
|
||||
userIds.add(ticket.creator_id);
|
||||
}
|
||||
if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) {
|
||||
userIds.add(ticket.owner_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size === 0) {
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
const rows = await this.db.query.users.findMany({
|
||||
where: inArray(users.id, Array.from(userIds)),
|
||||
});
|
||||
|
||||
for (const user of rows) {
|
||||
if (user.email) recipients.add(user.email);
|
||||
}
|
||||
|
||||
return Array.from(recipients);
|
||||
}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const recipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
|
||||
const recipients = await this.resolveRecipients(payload);
|
||||
const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
|
||||
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
|
||||
|
||||
@@ -91,25 +144,279 @@ export class Webhook implements ActionExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string): unknown {
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHandlebars(template: string, context: Record<string, unknown>): string {
|
||||
const instance = Handlebars.create();
|
||||
instance.registerHelper('json', (value: unknown) => JSON.stringify(value, null, 2));
|
||||
return instance.compile(template)(context);
|
||||
}
|
||||
|
||||
export class FetchMetadata implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
const rawUrl = String(payload.actionConfig['url'] ?? '');
|
||||
const method = String(payload.actionConfig['method'] ?? 'GET').toUpperCase();
|
||||
const headers = (payload.actionConfig['headers'] ?? {}) as Record<string, string>;
|
||||
const requestBodyTemplate = String(payload.actionConfig['body'] ?? '');
|
||||
const commentTemplate = String(
|
||||
payload.actionConfig['comment_template'] ??
|
||||
'External metadata\n\n{{json metadata}}',
|
||||
);
|
||||
const internal = payload.actionConfig['internal'] !== false;
|
||||
|
||||
if (!ticketId || !rawUrl) {
|
||||
return { success: false, message: 'FetchMetadata: missing ticket_id or URL' };
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, message: `FetchMetadata: ticket ${ticketId} not found` };
|
||||
}
|
||||
|
||||
const baseContext = {
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
queue_id: ticket.queue_id,
|
||||
owner_id: ticket.owner_id,
|
||||
creator_id: ticket.creator_id,
|
||||
created_at: ticket.created_at?.toISOString(),
|
||||
updated_at: ticket.updated_at?.toISOString(),
|
||||
},
|
||||
};
|
||||
const url = renderHandlebars(rawUrl, baseContext);
|
||||
const requestBody = requestBodyTemplate
|
||||
? renderHandlebars(requestBodyTemplate, baseContext)
|
||||
: undefined;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { Accept: 'application/json', ...headers },
|
||||
body: ['GET', 'HEAD'].includes(method) ? undefined : requestBody,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
const metadata = parseResponseBody(responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, message: `FetchMetadata failed: HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
const commentBody = renderHandlebars(commentTemplate, {
|
||||
...baseContext,
|
||||
metadata,
|
||||
response: {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
body: metadata,
|
||||
text: responseText,
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: internal ? 'Comment' : 'Correspond',
|
||||
data: {
|
||||
body: commentBody,
|
||||
metadata,
|
||||
source_url: url,
|
||||
},
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
|
||||
await this.db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId));
|
||||
|
||||
return { success: true, message: `Metadata fetched and added to ticket ${ticketId}` };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, message: `FetchMetadata failed: ${message}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptResult = string | { success?: boolean; message?: string } | undefined | null;
|
||||
|
||||
export class RunScript implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const script = String(payload.actionConfig['script'] ?? payload.actionConfig['code'] ?? '');
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
|
||||
if (!script.trim()) {
|
||||
return { success: false, message: 'RunScript: no script configured' };
|
||||
}
|
||||
|
||||
if (!ticketId) {
|
||||
return { success: false, message: 'RunScript: missing ticket_id' };
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return { success: false, message: `RunScript: ticket ${ticketId} not found` };
|
||||
}
|
||||
|
||||
const helpers = {
|
||||
addComment: async (body: string, options?: { internal?: boolean; creator_id?: string }) => {
|
||||
const [tx] = await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: options?.internal === false ? 'Correspond' : 'Comment',
|
||||
data: { body },
|
||||
creator_id: options?.creator_id ?? '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
return tx;
|
||||
},
|
||||
createTransaction: async (data: {
|
||||
transaction_type: string;
|
||||
field?: string | null;
|
||||
old_value?: string | null;
|
||||
new_value?: string | null;
|
||||
data?: unknown;
|
||||
creator_id?: string;
|
||||
}) => {
|
||||
const [tx] = await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: data.transaction_type,
|
||||
field: data.field ?? null,
|
||||
old_value: data.old_value ?? null,
|
||||
new_value: data.new_value ?? null,
|
||||
data: data.data,
|
||||
creator_id: data.creator_id ?? '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
return tx;
|
||||
},
|
||||
updateTicket: async (data: Partial<typeof tickets.$inferInsert>) => {
|
||||
const [updated] = await this.db.update(tickets)
|
||||
.set({ ...data, updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId))
|
||||
.returning();
|
||||
return updated;
|
||||
},
|
||||
touchTicket: async () => {
|
||||
await this.db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId));
|
||||
},
|
||||
};
|
||||
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
const fn = new AsyncFunction(
|
||||
'context',
|
||||
`"use strict";
|
||||
const { ticket, payload, actionConfig, helpers, db, schema, orm, fetch, console } = context;
|
||||
${script}`,
|
||||
) as (context: Record<string, unknown>) => Promise<ScriptResult>;
|
||||
|
||||
const result = await fn({
|
||||
ticket,
|
||||
payload,
|
||||
actionConfig: payload.actionConfig,
|
||||
helpers,
|
||||
db: this.db,
|
||||
schema,
|
||||
orm: { and, eq, inArray },
|
||||
fetch,
|
||||
console,
|
||||
});
|
||||
|
||||
if (typeof result === 'string') {
|
||||
return { success: true, message: result };
|
||||
}
|
||||
if (result && typeof result === 'object') {
|
||||
return {
|
||||
success: result.success !== false,
|
||||
message: result.message ?? 'RunScript completed',
|
||||
};
|
||||
}
|
||||
return { success: true, message: 'RunScript completed' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, message: `RunScript failed: ${message}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SetCustomField implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const fieldId = payload.field_id ?? String(payload.actionConfig['field_id'] ?? '');
|
||||
const fieldRef =
|
||||
payload.field_id ??
|
||||
payload.field_key ??
|
||||
String(payload.actionConfig['field_id'] ?? payload.actionConfig['field_key'] ?? payload.actionConfig['field'] ?? '');
|
||||
const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
|
||||
const ticketId = payload.ticketId ?? String(payload.actionConfig['ticket_id'] ?? '');
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
|
||||
if (!fieldId || !value || !ticketId) {
|
||||
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' };
|
||||
if (!fieldRef || !value || !ticketId) {
|
||||
return { success: false, message: 'SetCustomField: missing field reference, value, or ticket_id' };
|
||||
}
|
||||
|
||||
try {
|
||||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(fieldRef);
|
||||
const field = await this.db.query.customFields.findFirst({
|
||||
where: (row, { or, eq }) =>
|
||||
isUuid
|
||||
? or(eq(row.id, fieldRef), eq(row.key, fieldRef), eq(row.name, fieldRef))
|
||||
: or(eq(row.key, fieldRef), eq(row.name, fieldRef)),
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
return { success: false, message: `SetCustomField: unknown field ${fieldRef}` };
|
||||
}
|
||||
|
||||
const existing = await this.db.query.customFieldValues.findMany({
|
||||
where: and(
|
||||
eq(customFieldValues.ticket_id, ticketId),
|
||||
eq(customFieldValues.custom_field_id, field.id),
|
||||
),
|
||||
});
|
||||
const oldValue = existing.map((row) => row.value).join(', ');
|
||||
|
||||
await this.db.delete(customFieldValues).where(and(
|
||||
eq(customFieldValues.ticket_id, ticketId),
|
||||
eq(customFieldValues.custom_field_id, field.id),
|
||||
));
|
||||
|
||||
await this.db.insert(customFieldValues).values({
|
||||
custom_field_id: fieldId,
|
||||
custom_field_id: field.id,
|
||||
ticket_id: ticketId,
|
||||
value,
|
||||
});
|
||||
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
|
||||
|
||||
await this.db.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, ticketId));
|
||||
|
||||
await this.db.insert(transactions).values({
|
||||
ticket_id: ticketId,
|
||||
transaction_type: 'CustomFieldChange',
|
||||
field: field.key,
|
||||
old_value: oldValue || null,
|
||||
new_value: value,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
|
||||
return { success: true, message: `${field.name} set to "${value}"` };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, message: `SetCustomField failed: ${message}` };
|
||||
@@ -121,7 +428,7 @@ export class CreateTransaction implements ActionExecutor {
|
||||
constructor(private db: Db) {}
|
||||
|
||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
||||
const ticketId = payload.ticketId ?? String(payload.actionConfig['ticket_id'] ?? '');
|
||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||
const transactionType = String(payload.actionConfig['transaction_type'] ?? '');
|
||||
const field = payload.actionConfig['field'] as string | undefined ?? null;
|
||||
const oldValue = payload.actionConfig['old_value'] as string | undefined ?? null;
|
||||
@@ -150,8 +457,10 @@ export class CreateTransaction implements ActionExecutor {
|
||||
|
||||
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
|
||||
return {
|
||||
SendEmail: new SendEmail(),
|
||||
SendEmail: new SendEmail(db),
|
||||
Webhook: new Webhook(),
|
||||
FetchMetadata: new FetchMetadata(db),
|
||||
RunScript: new RunScript(db),
|
||||
SetCustomField: new SetCustomField(db),
|
||||
CreateTransaction: new CreateTransaction(db),
|
||||
};
|
||||
|
||||
@@ -7,8 +7,29 @@ export interface ConditionEvaluateContext {
|
||||
lifecycleDef?: LifecycleDefinition;
|
||||
}
|
||||
|
||||
export interface ConditionConfig {
|
||||
from_status?: unknown;
|
||||
to_status?: unknown;
|
||||
field_key?: unknown;
|
||||
field_id?: unknown;
|
||||
field?: unknown;
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface ConditionEvaluator {
|
||||
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean;
|
||||
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean;
|
||||
}
|
||||
|
||||
function matchesStatusFilter(value: string | null, filter: unknown): boolean {
|
||||
if (filter === undefined || filter === null || filter === '') return true;
|
||||
if (value === null) return false;
|
||||
const normalizedValue = value.toLowerCase();
|
||||
if (Array.isArray(filter)) {
|
||||
return filter.map((item) => String(item).toLowerCase()).includes(normalizedValue);
|
||||
}
|
||||
return normalizedValue === String(filter).toLowerCase();
|
||||
}
|
||||
|
||||
export class OnCreate implements ConditionEvaluator {
|
||||
@@ -18,19 +39,25 @@ export class OnCreate implements ConditionEvaluator {
|
||||
}
|
||||
|
||||
export class OnStatusChange implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
|
||||
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
return transactions.some((tx) =>
|
||||
tx.transaction_type === 'StatusChange' &&
|
||||
matchesStatusFilter(tx.old_value, config?.from_status) &&
|
||||
matchesStatusFilter(tx.new_value, config?.to_status)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class OnResolve implements ConditionEvaluator {
|
||||
private lifecycleValidator = new LifecycleValidator();
|
||||
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const lifecycleDef = context?.lifecycleDef;
|
||||
|
||||
return transactions.some((tx) => {
|
||||
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
|
||||
if (!matchesStatusFilter(tx.old_value, config?.from_status)) return false;
|
||||
if (!matchesStatusFilter(tx.new_value, config?.to_status)) return false;
|
||||
|
||||
if (lifecycleDef) {
|
||||
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
|
||||
@@ -41,10 +68,25 @@ export class OnResolve implements ConditionEvaluator {
|
||||
}
|
||||
}
|
||||
|
||||
export class OnCustomFieldChange implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const fieldFilter = config?.field_key ?? config?.field_id ?? config?.field;
|
||||
const newValueFilter = config?.new_value ?? config?.value;
|
||||
|
||||
return transactions.some((tx) =>
|
||||
tx.transaction_type === 'CustomFieldChange' &&
|
||||
matchesStatusFilter(tx.field, fieldFilter) &&
|
||||
matchesStatusFilter(tx.old_value, config?.old_value) &&
|
||||
matchesStatusFilter(tx.new_value, newValueFilter)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||
OnCreate: new OnCreate(),
|
||||
OnStatusChange: new OnStatusChange(),
|
||||
OnResolve: new OnResolve(),
|
||||
OnCustomFieldChange: new OnCustomFieldChange(),
|
||||
};
|
||||
|
||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Transaction } from '../models/transaction.ts';
|
||||
import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
|
||||
import { eq, asc, inArray } from 'drizzle-orm';
|
||||
import { getConditionEvaluator } from './conditions.ts';
|
||||
import type { ConditionEvaluateContext } from './conditions.ts';
|
||||
import type { ConditionConfig, ConditionEvaluateContext } from './conditions.ts';
|
||||
import { getActionExecutor } from './actions.ts';
|
||||
import type { ActionPayload } from './actions.ts';
|
||||
import { TemplateRenderer } from './templates.ts';
|
||||
@@ -35,7 +35,7 @@ export class ScripEngine {
|
||||
}
|
||||
|
||||
async prepare(
|
||||
ticketId: string,
|
||||
ticketId: number,
|
||||
transactions: Transaction[],
|
||||
): Promise<PreparedScrip[]> {
|
||||
const ticketRecord = await this.db.query.tickets.findFirst({
|
||||
@@ -103,7 +103,12 @@ export class ScripEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions, conditionContext)) {
|
||||
if (!evaluator.evaluate(
|
||||
ticketRecord as unknown as Ticket,
|
||||
transactions,
|
||||
conditionContext,
|
||||
scrip.condition_config as ConditionConfig,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export class TemplateRenderer {
|
||||
|
||||
export interface TemplateContext {
|
||||
ticket: {
|
||||
id: string;
|
||||
id: number;
|
||||
subject: string;
|
||||
status: string;
|
||||
queue_id: string;
|
||||
|
||||
@@ -27,5 +27,6 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"exclude": ["web", "node_modules"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,7 @@ pnpm dev
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open [http://127.0.0.1:3100](http://127.0.0.1:3100) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const appRoot = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
outputFileTracingRoot: appRoot,
|
||||
turbopack: {
|
||||
root: appRoot,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://127.0.0.1:9876/:path*',
|
||||
source: "/api/:path*",
|
||||
destination: "http://127.0.0.1:9876/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
95
web/package-lock.json
generated
95
web/package-lock.json
generated
@@ -9,15 +9,21 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.77.0",
|
||||
"shadcn": "^4.10.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -943,6 +949,18 @@
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -1981,6 +1999,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -2261,6 +2285,39 @@
|
||||
"tailwindcss": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||
@@ -3947,6 +4004,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -7217,6 +7284,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -8002,6 +8079,22 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.77.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz",
|
||||
"integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --webpack -H 127.0.0.1 --port 3100",
|
||||
"dev:prod": "next build && next start -H 127.0.0.1 --port 3100",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start -H 127.0.0.1 --port 3100",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
589
web/src/app/dashboards/[id]/page.tsx
Normal file
589
web/src/app/dashboards/[id]/page.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
GripIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
RefreshCwIcon,
|
||||
LayoutGridIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getDashboard,
|
||||
createWidget,
|
||||
deleteWidget,
|
||||
updateWidget,
|
||||
getWidgetData,
|
||||
getViews,
|
||||
getTeams,
|
||||
updateDashboard,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
SavedView,
|
||||
Team,
|
||||
WidgetData,
|
||||
} from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { CountWidget } from "@/components/widgets/count-widget";
|
||||
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
|
||||
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
|
||||
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
|
||||
return {
|
||||
gridColumn: `${position.x + 1} / span ${position.w}`,
|
||||
gridRow: `${position.y + 1} / span ${position.h}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
||||
const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]);
|
||||
const [views, setViews] = useState<SavedView[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
|
||||
// Add widget dialog
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addViewId, setAddViewId] = useState("");
|
||||
const [addTitle, setAddTitle] = useState("");
|
||||
const [addType, setAddType] = useState("count");
|
||||
const [addGroupBy, setAddGroupBy] = useState("owner");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
const { data, error } = await getDashboard(id);
|
||||
if (error || !data) {
|
||||
setError(error ?? "Dashboard not found");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setDashboard(data);
|
||||
const widgetList = data.widgets ?? [];
|
||||
setWidgets(widgetList);
|
||||
|
||||
// Fetch data for each widget
|
||||
for (const widget of widgetList) {
|
||||
const { data: wData } = await getWidgetData(id, widget.id);
|
||||
if (wData) {
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard();
|
||||
getViews().then(({ data }) => { if (data) setViews(data); });
|
||||
getTeams().then(({ data }) => { if (data) setTeams(data); });
|
||||
}, [fetchDashboard]);
|
||||
|
||||
// Auto-refresh: only refresh widget data, not structure
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !dashboard) return;
|
||||
const interval = setInterval(() => {
|
||||
for (const widget of widgets) {
|
||||
getWidgetData(dashboard.id, widget.id).then(({ data: wData }) => {
|
||||
if (wData) {
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, dashboard?.id]);
|
||||
|
||||
const handleAddWidget = async () => {
|
||||
if (!addViewId || !addTitle.trim()) return;
|
||||
setAdding(true);
|
||||
// Smart positioning: fill a 3-column grid (4 units each in 12-col grid)
|
||||
const COLS = 3; const W = 4; const H = 2;
|
||||
const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`));
|
||||
let x = 0; let y = 0;
|
||||
while (occupied.has(`${x},${y}`)) {
|
||||
x += W;
|
||||
if (x >= COLS * W) { x = 0; y += H; }
|
||||
}
|
||||
const pos = { x, y, w: W, h: H };
|
||||
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
|
||||
const { data, error } = await createWidget(id, {
|
||||
view_id: addViewId,
|
||||
title: addTitle.trim(),
|
||||
widget_type: addType,
|
||||
position: pos,
|
||||
config,
|
||||
});
|
||||
if (!error && data) {
|
||||
setWidgets((prev) => [...prev, data]);
|
||||
const { data: wData } = await getWidgetData(id, data.id);
|
||||
if (wData) {
|
||||
setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w)));
|
||||
}
|
||||
setAddOpen(false);
|
||||
setAddViewId("");
|
||||
setAddTitle("");
|
||||
setAddType("count");
|
||||
}
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
const handleDeleteWidget = async (widgetId: string) => {
|
||||
await deleteWidget(id, widgetId);
|
||||
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||
};
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [resizingId, setResizingId] = useState<string | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Resize: track mousedown → mousemove → mouseup
|
||||
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setResizingId(widgetId);
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const widget = widgets.find((w) => w.id === widgetId);
|
||||
if (!widget || !gridRef.current) return;
|
||||
|
||||
const startW = widget.position.w;
|
||||
const startH = widget.position.h;
|
||||
const gridWidth = gridRef.current.offsetWidth;
|
||||
const unitSize = gridWidth / 12;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = Math.round((ev.clientX - startX) / unitSize);
|
||||
const dy = Math.round((ev.clientY - startY) / unitSize);
|
||||
const newW = Math.max(1, Math.min(12, startW + dx));
|
||||
const newH = Math.max(1, startH + dy);
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === widgetId
|
||||
? { ...w, position: { ...w.position, w: newW, h: newH } }
|
||||
: w
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
setResizingId(null);
|
||||
|
||||
// Resolve overlaps using latest state via functional updater
|
||||
setWidgets((current) => {
|
||||
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
|
||||
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
let hasOverlap = false;
|
||||
for (let i = 0; i < resolved.length; i++) {
|
||||
for (let j = i + 1; j < resolved.length; j++) {
|
||||
const a = resolved[i].position;
|
||||
const b = resolved[j].position;
|
||||
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
|
||||
hasOverlap = true;
|
||||
const toMove = widgetId === resolved[i].id ? j : i;
|
||||
const fixedW = resolved[widgetId === resolved[i].id ? i : j];
|
||||
resolved[toMove] = {
|
||||
...resolved[toMove],
|
||||
position: { ...resolved[toMove].position, y: fixedW.position.y + fixedW.position.h },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasOverlap) break;
|
||||
}
|
||||
|
||||
// Persist changed positions
|
||||
for (const w of resolved) {
|
||||
const orig = current.find((o) => o.id === w.id);
|
||||
if (orig && (orig.position.y !== w.position.y || orig.position.h !== w.position.h)) {
|
||||
updateWidget(id, w.id, { position: w.position });
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
// Mouse-based drag: mousedown on grip → mousemove → mouseup
|
||||
const handleDragMouseDown = (e: React.MouseEvent, widgetId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!gridRef.current) return;
|
||||
setDraggingId(widgetId);
|
||||
|
||||
const widget = widgets.find((w) => w.id === widgetId);
|
||||
if (!widget) return;
|
||||
const unitSize = gridRef.current.offsetWidth / 12;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startGridX = widget.position.x;
|
||||
const startGridY = widget.position.y;
|
||||
|
||||
// Store offset from widget origin to mouse for visual tracking
|
||||
const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
|
||||
if (widgetEl) {
|
||||
const rect = widgetEl.getBoundingClientRect();
|
||||
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = Math.round((ev.clientX - startX) / unitSize);
|
||||
const dy = Math.round((ev.clientY - startY) / unitSize);
|
||||
const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
|
||||
const newY = Math.max(0, startGridY + dy);
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === widgetId
|
||||
? { ...w, position: { ...w.position, x: newX, y: newY } }
|
||||
: w
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
setDraggingId(null);
|
||||
|
||||
setWidgets((current) => {
|
||||
const updated = current.find((w) => w.id === widgetId);
|
||||
if (!updated) return current;
|
||||
|
||||
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
|
||||
|
||||
// Push overlapping widgets down
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
let hasOverlap = false;
|
||||
for (let i = 0; i < resolved.length; i++) {
|
||||
for (let j = i + 1; j < resolved.length; j++) {
|
||||
const a = resolved[i].position;
|
||||
const b = resolved[j].position;
|
||||
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
|
||||
hasOverlap = true;
|
||||
const moveIdx = resolved[i].id === widgetId ? j : i;
|
||||
const fixedIdx = moveIdx === i ? j : i;
|
||||
resolved[moveIdx] = {
|
||||
...resolved[moveIdx],
|
||||
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasOverlap) break;
|
||||
}
|
||||
|
||||
// Persist changed positions
|
||||
for (const w of resolved) {
|
||||
const orig = current.find((o) => o.id === w.id);
|
||||
if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) {
|
||||
updateWidget(id, w.id, { position: w.position });
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
|
||||
if (!widget.data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (widget.data.type) {
|
||||
case "count":
|
||||
return <CountWidget data={widget.data} />;
|
||||
case "ticket_list":
|
||||
return <TicketListWidget data={widget.data} />;
|
||||
case "status_chart":
|
||||
return <StatusChartWidget data={widget.data} />;
|
||||
case "grouped_counts":
|
||||
return <GroupedCountsWidget data={widget.data} />;
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">Unknown type: {widget.data.type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<p className="text-sm text-muted-foreground">{error ?? "Dashboard not found"}</p>
|
||||
<Link href="/" className="text-sm text-primary hover:underline">
|
||||
Go to ticket list
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background/80">
|
||||
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-3 lg:px-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
<LayoutGridIcon className="h-3.5 w-3.5" />
|
||||
Dashboard
|
||||
</div>
|
||||
<h1 className="mt-1 text-xl font-semibold text-foreground">{dashboard.name}</h1>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
value={dashboard.team_id ?? ""}
|
||||
onChange={async (e) => {
|
||||
const teamId = e.target.value || null;
|
||||
await updateDashboard(dashboard.id, { team_id: teamId });
|
||||
setDashboard((prev) => prev ? { ...prev, team_id: teamId } : prev);
|
||||
}}
|
||||
className="h-7 rounded border border-border bg-card px-2 text-xs text-muted-foreground outline-none"
|
||||
>
|
||||
<option value="">No team</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{dashboard.description && (
|
||||
<p className="text-sm text-muted-foreground">{dashboard.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
className={cn("h-8 border-border/80", editMode ? "bg-primary/20 text-primary border-primary/40" : "bg-card/70")}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
{editMode ? "Done" : "Edit"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={cn("h-8 border-border/80", autoRefresh ? "bg-primary/20 text-primary" : "bg-card/70")}
|
||||
>
|
||||
<RefreshCwIcon className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
|
||||
{autoRefresh ? "Live" : "Auto"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchDashboard}
|
||||
className="h-8 border-border/80 bg-card/70"
|
||||
>
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)} className="h-8 bg-primary shadow-sm">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add widget
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-auto p-5 lg:p-6">
|
||||
{widgets.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<LayoutGridIcon className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No widgets yet</p>
|
||||
{editMode ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add your first widget
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditMode(true)}>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
Enter edit mode
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-widget-id={widget.id}
|
||||
className={cn(
|
||||
"group relative transition-none",
|
||||
draggingId === widget.id && "z-10",
|
||||
resizingId === widget.id && "select-none",
|
||||
)}
|
||||
style={widgetGridStyle(widget.position)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{editMode && (
|
||||
<div
|
||||
className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex active:cursor-grabbing"
|
||||
onMouseDown={(e) => handleDragMouseDown(e, widget.id)}
|
||||
>
|
||||
<GripIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
{renderWidget(widget)}
|
||||
{editMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWidget(widget.id)}
|
||||
className="absolute right-2 top-2 z-10 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
|
||||
title="Remove widget"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 z-10 hidden h-5 w-5 cursor-se-resize items-center justify-center group-hover:flex"
|
||||
onMouseDown={(e) => handleResizeStart(e, widget.id)}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" className="text-muted-foreground">
|
||||
<path d="M0 10 L10 0" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5 10 L10 5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M0 10 L10 10" stroke="transparent" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add widget</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a saved view and widget type to add to this dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Widget title</label>
|
||||
<input
|
||||
value={addTitle}
|
||||
onChange={(e) => setAddTitle(e.target.value)}
|
||||
placeholder="e.g. Open tickets"
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Saved view</label>
|
||||
<select
|
||||
value={addViewId}
|
||||
onChange={(e) => {
|
||||
setAddViewId(e.target.value);
|
||||
const view = views.find((v) => v.id === e.target.value);
|
||||
if (view && !addTitle) setAddTitle(view.name);
|
||||
}}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="">Select a view...</option>
|
||||
{views.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Widget type</label>
|
||||
<select
|
||||
value={addType}
|
||||
onChange={(e) => setAddType(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="count">Count (big number)</option>
|
||||
<option value="ticket_list">Ticket list (mini table)</option>
|
||||
<option value="status_chart">Status chart (donut)</option>
|
||||
<option value="grouped_counts">Grouped counts (bar chart)</option>
|
||||
</select>
|
||||
</div>
|
||||
{addType === "grouped_counts" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">Group by</label>
|
||||
<select
|
||||
value={addGroupBy}
|
||||
onChange={(e) => setAddGroupBy(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="queue">Queue</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addViewId || !addTitle.trim() || adding}
|
||||
onClick={handleAddWidget}
|
||||
>
|
||||
{adding ? "Adding..." : "Add widget"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,72 +49,83 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(0.982 0.006 106);
|
||||
--foreground: oklch(0.19 0.018 248);
|
||||
--card: oklch(0.996 0.003 106);
|
||||
--card-foreground: oklch(0.19 0.018 248);
|
||||
--popover: oklch(0.996 0.003 106);
|
||||
--popover-foreground: oklch(0.19 0.018 248);
|
||||
--primary: oklch(0.31 0.046 243);
|
||||
--primary-foreground: oklch(0.99 0.003 106);
|
||||
--secondary: oklch(0.945 0.01 105);
|
||||
--secondary-foreground: oklch(0.25 0.026 244);
|
||||
--muted: oklch(0.948 0.008 106);
|
||||
--muted-foreground: oklch(0.49 0.023 250);
|
||||
--accent: oklch(0.925 0.024 184);
|
||||
--accent-foreground: oklch(0.21 0.028 246);
|
||||
--destructive: oklch(0.55 0.18 27);
|
||||
--border: oklch(0.865 0.014 102);
|
||||
--input: oklch(0.84 0.015 102);
|
||||
--ring: oklch(0.58 0.068 185);
|
||||
--chart-1: oklch(0.62 0.095 184);
|
||||
--chart-2: oklch(0.53 0.078 243);
|
||||
--chart-3: oklch(0.64 0.12 77);
|
||||
--chart-4: oklch(0.55 0.15 28);
|
||||
--chart-5: oklch(0.44 0.055 257);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.245 0.026 248);
|
||||
--sidebar-foreground: oklch(0.93 0.012 108);
|
||||
--sidebar-primary: oklch(0.69 0.105 184);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.022 248);
|
||||
--sidebar-accent: oklch(0.31 0.031 248);
|
||||
--sidebar-accent-foreground: oklch(0.98 0.006 106);
|
||||
--sidebar-border: oklch(1 0 0 / 11%);
|
||||
--sidebar-ring: oklch(0.66 0.102 184);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.18 0.018 248);
|
||||
--foreground: oklch(0.94 0.011 105);
|
||||
--card: oklch(0.225 0.022 248);
|
||||
--card-foreground: oklch(0.94 0.011 105);
|
||||
--popover: oklch(0.225 0.022 248);
|
||||
--popover-foreground: oklch(0.94 0.011 105);
|
||||
--primary: oklch(0.74 0.105 184);
|
||||
--primary-foreground: oklch(0.17 0.018 248);
|
||||
--secondary: oklch(0.27 0.026 248);
|
||||
--secondary-foreground: oklch(0.94 0.011 105);
|
||||
--muted: oklch(0.28 0.023 248);
|
||||
--muted-foreground: oklch(0.7 0.019 105);
|
||||
--accent: oklch(0.31 0.043 184);
|
||||
--accent-foreground: oklch(0.94 0.011 105);
|
||||
--destructive: oklch(0.68 0.17 24);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 16%);
|
||||
--ring: oklch(0.68 0.095 184);
|
||||
--chart-1: oklch(0.74 0.105 184);
|
||||
--chart-2: oklch(0.7 0.105 74);
|
||||
--chart-3: oklch(0.66 0.12 25);
|
||||
--chart-4: oklch(0.61 0.08 245);
|
||||
--chart-5: oklch(0.8 0.04 108);
|
||||
--sidebar: oklch(0.145 0.018 248);
|
||||
--sidebar-foreground: oklch(0.94 0.011 105);
|
||||
--sidebar-primary: oklch(0.74 0.105 184);
|
||||
--sidebar-primary-foreground: oklch(0.17 0.018 248);
|
||||
--sidebar-accent: oklch(0.24 0.026 248);
|
||||
--sidebar-accent-foreground: oklch(0.94 0.011 105);
|
||||
--sidebar-border: oklch(1 0 0 / 11%);
|
||||
--sidebar-ring: oklch(0.68 0.095 184);
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -124,6 +135,10 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "cv01" 1, "ss03" 1;
|
||||
background-image:
|
||||
linear-gradient(to right, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px);
|
||||
background-size: 44px 44px;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
|
||||
import { Suspense } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
const inter = Inter({
|
||||
const ibmPlexSans = IBM_Plex_Sans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
@@ -26,7 +27,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||
className={`${ibmPlexSans.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
|
||||
1467
web/src/app/page.tsx
1467
web/src/app/page.tsx
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect, Suspense, createContext, useContext } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
CircleIcon,
|
||||
LayoutGridIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
InboxIcon,
|
||||
ClockIcon,
|
||||
SettingsIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftIcon,
|
||||
CommandIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets, getQueues } from "@/lib/api";
|
||||
import type { Queue } from "@/lib/types";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SidebarCollapsedContext = createContext(false);
|
||||
|
||||
function useSidebarCollapsed() {
|
||||
return useContext(SidebarCollapsedContext);
|
||||
}
|
||||
|
||||
interface ViewCounts {
|
||||
all: number;
|
||||
my: number;
|
||||
@@ -22,6 +34,46 @@ interface ViewCounts {
|
||||
recent: number;
|
||||
}
|
||||
|
||||
function SidebarNavItem({
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
count?: number;
|
||||
active: boolean;
|
||||
}) {
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
"group flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
collapsed ? "justify-center w-full" : "justify-between",
|
||||
active
|
||||
? "bg-sidebar-accent text-sidebar-foreground font-medium"
|
||||
: "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 font-normal"
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex items-center min-w-0", collapsed ? "" : "gap-2.5")}>
|
||||
<Icon className={cn("w-4 h-4 flex-shrink-0", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</span>
|
||||
{!collapsed && count !== undefined && count > 0 && (
|
||||
<span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -33,52 +85,93 @@ function SidebarNav() {
|
||||
recent: 0,
|
||||
});
|
||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
||||
const [newDashboardName, setNewDashboardName] = useState("");
|
||||
const [addingDashboard, setAddingDashboard] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getTickets().then(({ data }) => {
|
||||
async function load() {
|
||||
// Find current user
|
||||
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
||||
const data = ticketRes.data;
|
||||
const users = userRes.data ?? [];
|
||||
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
|
||||
const myId = currentUser?.id ?? null;
|
||||
setCurrentUserId(myId);
|
||||
|
||||
if (data) {
|
||||
const now = Date.now();
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
setCounts({
|
||||
all: data.length,
|
||||
my: 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 = allTeams.filter((t) =>
|
||||
(t.members ?? []).some((m) => m.id === myId)
|
||||
);
|
||||
setMyTeamId(userTeams[0]?.id ?? null);
|
||||
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||
const visible = allDashboards.filter((d) =>
|
||||
!d.team_id || teamIds.has(d.team_id)
|
||||
);
|
||||
setDashboards(visible);
|
||||
}
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
const views = [
|
||||
{
|
||||
label: "All tickets",
|
||||
href: "/",
|
||||
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",
|
||||
@@ -101,64 +194,127 @@ function SidebarNav() {
|
||||
<>
|
||||
<div className="mb-4">
|
||||
{views.map((view) => {
|
||||
const Icon = view.icon;
|
||||
const active =
|
||||
pathname === "/" &&
|
||||
(view.param ? currentView === view.param : !currentView);
|
||||
return (
|
||||
<Link
|
||||
<SidebarNavItem
|
||||
key={view.label}
|
||||
href={view.href}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors mb-0.5",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{view.label}
|
||||
</span>
|
||||
{view.count > 0 && (
|
||||
<span className="text-xs tabular-nums text-[#8a8f98]">
|
||||
{view.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
icon={view.icon}
|
||||
label={view.label}
|
||||
count={view.count}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{dashboards.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
||||
Dashboards
|
||||
</div>
|
||||
)}
|
||||
{dashboards.length > 0 && (
|
||||
<div className="px-2.5">
|
||||
<select
|
||||
value={dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (id === "_new") {
|
||||
setAddingDashboard(true);
|
||||
e.target.value = dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? "";
|
||||
return;
|
||||
}
|
||||
if (id) window.location.href = `/dashboards/${id}`;
|
||||
}}
|
||||
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
|
||||
>
|
||||
{dashboards.map((dash) => (
|
||||
<option key={dash.id} value={dash.id}>{dash.name}</option>
|
||||
))}
|
||||
<option value="_new">+ New dashboard</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{addingDashboard && (
|
||||
<div className="mt-1 px-2">
|
||||
<input
|
||||
value={newDashboardName}
|
||||
onChange={(e) => setNewDashboardName(e.target.value)}
|
||||
placeholder="Dashboard name"
|
||||
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
|
||||
autoFocus
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && newDashboardName.trim()) {
|
||||
const { data } = await createDashboard({ name: newDashboardName.trim(), is_default: false });
|
||||
if (data) {
|
||||
setDashboards((prev) => [...prev, data]);
|
||||
setNewDashboardName("");
|
||||
setAddingDashboard(false);
|
||||
window.location.href = `/dashboards/${data.id}`;
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setNewDashboardName("");
|
||||
setAddingDashboard(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!newDashboardName.trim()) {
|
||||
setNewDashboardName("");
|
||||
setAddingDashboard(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{savedViews.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
||||
Saved views
|
||||
</div>
|
||||
)}
|
||||
{savedViews.map((view) => {
|
||||
const active =
|
||||
pathname === "/" && searchParams.get("view_id") === view.id;
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={view.id}
|
||||
href={`/?view_id=${view.id}`}
|
||||
icon={ClockIcon}
|
||||
label={view.name}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queues.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold text-[#8a8f98] uppercase tracking-wider">
|
||||
<div className="mt-5">
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
||||
Queues
|
||||
</div>
|
||||
)}
|
||||
{queues.map((queue) => {
|
||||
const active =
|
||||
pathname === "/" && searchParams.get("queue") === queue.id;
|
||||
return (
|
||||
<Link
|
||||
<SidebarNavItem
|
||||
key={queue.id}
|
||||
href={`/?queue=${queue.id}`}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-2 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#8a8f98] flex-shrink-0" />
|
||||
{queue.name}
|
||||
</span>
|
||||
{queue.count > 0 && (
|
||||
<span className="text-xs tabular-nums text-[#8a8f98]">
|
||||
{queue.count}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
icon={CircleIcon}
|
||||
label={queue.name}
|
||||
count={queue.count}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -169,26 +325,18 @@ function SidebarNav() {
|
||||
|
||||
function SidebarBottom() {
|
||||
const pathname = usePathname();
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
return (
|
||||
<div className="border-t border-[rgba(255,255,255,0.05)] p-2">
|
||||
<Link
|
||||
<div className="border-t border-sidebar-border/50 p-2">
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
pathname === "/admin"
|
||||
? "bg-[rgba(255,255,255,0.05)] text-[#f7f8f8] font-medium"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.03)] font-normal"
|
||||
)}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 flex-shrink-0" />
|
||||
Admin
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 mt-0.5">
|
||||
<div className="w-5 h-5 rounded-full bg-[#5e6ad2] flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-[#f7f8f8] text-[10px] font-semibold">U</span>
|
||||
</div>
|
||||
<span className="text-[13px] text-[#8a8f98] truncate">User</span>
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -196,6 +344,7 @@ function SidebarBottom() {
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
@@ -216,32 +365,45 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col bg-[#0f1011] border-r border-[rgba(255,255,255,0.05)]">
|
||||
<aside
|
||||
className={cn(
|
||||
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border/50 transition-all duration-200",
|
||||
sidebarCollapsed ? "w-[56px]" : "w-[232px]"
|
||||
)}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="h-11 flex items-center px-3 border-b border-[rgba(255,255,255,0.05)]">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-[#5e6ad2] flex items-center justify-center">
|
||||
<span className="text-[#f7f8f8] text-[11px] font-semibold">
|
||||
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>
|
||||
<span className="font-semibold text-[#f7f8f8] text-sm tracking-tight">
|
||||
Tessera
|
||||
</span>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
||||
<nav className="flex-1 overflow-y-auto py-2.5 px-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-1.5 px-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-7 bg-[#191a1b] rounded-md animate-pulse"
|
||||
className="h-7 bg-sidebar-accent rounded-md animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -256,10 +418,25 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
<main className="flex-1 overflow-hidden bg-background/88">{children}</main>
|
||||
|
||||
{/* Command Palette */}
|
||||
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-sidebar-border border-l-0 text-sidebar-foreground/55 hover:text-sidebar-foreground transition-all duration-150"
|
||||
style={{ left: sidebarCollapsed ? 60 : 240 }}
|
||||
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeftIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<PanelLeftCloseIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</SidebarCollapsedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import type { ComponentType, KeyboardEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SearchIcon,
|
||||
PlusIcon,
|
||||
LayoutGridIcon,
|
||||
SettingsIcon,
|
||||
MessageSquareIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets } from "@/lib/api";
|
||||
import type { Ticket } from "@/lib/types";
|
||||
|
||||
interface CommandItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
action: () => void;
|
||||
category?: string;
|
||||
}
|
||||
@@ -28,8 +32,19 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
|
||||
const commands: CommandItem[] = [
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getTickets().then(({ data }) => {
|
||||
if (data) setTickets(data);
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const alwaysCommands: CommandItem[] = [
|
||||
{
|
||||
id: "new-ticket",
|
||||
label: "New ticket",
|
||||
@@ -40,16 +55,6 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
},
|
||||
category: "Actions",
|
||||
},
|
||||
{
|
||||
id: "all-tickets",
|
||||
label: "All tickets",
|
||||
icon: LayoutGridIcon,
|
||||
action: () => {
|
||||
onOpenChange(false);
|
||||
router.push("/");
|
||||
},
|
||||
category: "Navigate",
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
label: "Go to admin",
|
||||
@@ -60,33 +65,64 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
},
|
||||
category: "Navigate",
|
||||
},
|
||||
{
|
||||
id: "all-tickets",
|
||||
label: "All tickets",
|
||||
icon: LayoutGridIcon,
|
||||
action: () => {
|
||||
onOpenChange(false);
|
||||
router.push("/");
|
||||
},
|
||||
category: "Navigate",
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = commands.filter((cmd) =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase())
|
||||
const alwaysFiltered = alwaysCommands.filter((cmd) =>
|
||||
cmd.label.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
const ticketCommands: CommandItem[] = tickets
|
||||
.filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
|
||||
.map((t) => ({
|
||||
id: `ticket-${t.id}`,
|
||||
label: t.subject,
|
||||
icon: MessageSquareIcon,
|
||||
action: () => {
|
||||
onOpenChange(false);
|
||||
router.push(`/tickets/${t.id}`);
|
||||
},
|
||||
category: "Tickets",
|
||||
}));
|
||||
|
||||
const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
|
||||
return [...alwaysFiltered, ...ticketCommands];
|
||||
}, [onOpenChange, query, router, tickets]);
|
||||
|
||||
const grouped = useMemo(
|
||||
() =>
|
||||
filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
|
||||
const cat = cmd.category || "Other";
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(cmd);
|
||||
return acc;
|
||||
}, {});
|
||||
}, {}),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
queueMicrotask(() => {
|
||||
setQuery("");
|
||||
setSelectedIndex(0);
|
||||
});
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
queueMicrotask(() => setSelectedIndex(0));
|
||||
}, [query]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||
@@ -114,27 +150,27 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="fixed top-[20%] left-1/2 -translate-x-1/2 w-full max-w-md">
|
||||
<div className="bg-[#191a1b] rounded-xl shadow-2xl border border-[rgba(255,255,255,0.08)] overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 border-b border-[rgba(255,255,255,0.05)]">
|
||||
<SearchIcon className="w-4 h-4 text-[#8a8f98] flex-shrink-0" />
|
||||
<div className="bg-popover rounded-xl shadow-2xl border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 border-b border-border">
|
||||
<SearchIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a command or search..."
|
||||
className="w-full h-10 bg-transparent text-sm text-[#f7f8f8] placeholder:text-[#8a8f98] outline-none"
|
||||
className="w-full h-10 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div ref={listRef} className="max-h-64 overflow-y-auto p-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-sm text-[#8a8f98]">
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-[#8a8f98]">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{category}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
@@ -144,10 +180,10 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-sm text-left transition-colors ${
|
||||
className={`w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-sm text-left transition-all duration-150 ${
|
||||
isSelected
|
||||
? "bg-[#5e6ad2] text-white"
|
||||
: "text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.05)]"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground hover:bg-accent"
|
||||
}`}
|
||||
onClick={item.action}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
@@ -160,7 +196,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-[rgba(255,255,255,0.05)] px-3 py-2 flex items-center justify-between text-xs text-[#8a8f98]">
|
||||
<div className="border-t border-border px-3 py-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>↑↓ Navigate</span>
|
||||
<span>↵ Select</span>
|
||||
<span>Esc Dismiss</span>
|
||||
|
||||
34
web/src/components/theme-toggle.tsx
Normal file
34
web/src/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { SunIcon, MoonIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setMounted(true));
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="w-8 h-8" />;
|
||||
}
|
||||
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-150"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark ? (
|
||||
<SunIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<MoonIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
21
web/src/components/widgets/count-widget.tsx
Normal file
21
web/src/components/widgets/count-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
web/src/components/widgets/grouped-counts-widget.tsx
Normal file
39
web/src/components/widgets/grouped-counts-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
web/src/components/widgets/status-chart-widget.tsx
Normal file
76
web/src/components/widgets/status-chart-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
web/src/components/widgets/ticket-list-widget.tsx
Normal file
59
web/src/components/widgets/ticket-list-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import type {
|
||||
Ticket,
|
||||
Queue,
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetData,
|
||||
Team,
|
||||
User,
|
||||
Transaction,
|
||||
SavedView,
|
||||
Scrip,
|
||||
Template,
|
||||
TemplatePreview,
|
||||
Lifecycle,
|
||||
LifecycleDefinition,
|
||||
CustomField,
|
||||
QueueCustomField,
|
||||
PreviewResult,
|
||||
UpdateResult,
|
||||
} from "./types";
|
||||
@@ -28,50 +38,102 @@ async function request<T>(url: string, options?: RequestInit): Promise<{ data: T
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTickets(params?: { queue_id?: string; status?: string }): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||
export async function getTickets(params?: {
|
||||
queue_id?: string;
|
||||
status?: string;
|
||||
q?: string;
|
||||
owner_id?: string;
|
||||
team_id?: string;
|
||||
custom_fields?: Record<string, string>;
|
||||
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.q) sp.set("q", params.q);
|
||||
if (params?.owner_id) sp.set("owner_id", params.owner_id);
|
||||
if (params?.team_id) sp.set("team_id", params.team_id);
|
||||
if (params?.custom_fields) {
|
||||
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
||||
if (value) sp.set(`cf.${fieldId}`, value);
|
||||
}
|
||||
}
|
||||
const qs = sp.toString();
|
||||
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
export async function getTicket(id: string): Promise<{ data: Ticket | null; error: string | null }> {
|
||||
export async function getTicket(id: number): Promise<{ data: Ticket | null; error: string | null }> {
|
||||
return request<Ticket>(`/tickets/${id}`);
|
||||
}
|
||||
|
||||
export async function createTicket(data: { subject: string; queue_id: string }): Promise<{ data: Ticket | null; error: string | null }> {
|
||||
return request<Ticket>("/tickets", { method: "POST", body: JSON.stringify(data) });
|
||||
export async function createTicket(data: {
|
||||
subject: string;
|
||||
queue_id: string;
|
||||
description?: string;
|
||||
custom_fields?: Record<string, string>;
|
||||
}): Promise<{ data: UpdateResult | null; error: string | null }> {
|
||||
return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateTicket(id: string, data: { subject?: string; status?: string }): 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) });
|
||||
}
|
||||
|
||||
export async function previewTicket(id: string, data: { status?: string }): Promise<{ data: PreviewResult | null; error: string | null }> {
|
||||
export async function previewTicket(id: number, data: { status?: string }): Promise<{ data: PreviewResult | null; error: string | null }> {
|
||||
return request<PreviewResult>(`/tickets/${id}/preview`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getTicketTransactions(id: string): Promise<{ data: Transaction[] | null; error: string | null }> {
|
||||
export async function getTicketTransactions(id: number): Promise<{ data: Transaction[] | null; error: string | null }> {
|
||||
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
||||
}
|
||||
|
||||
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
||||
return request<Queue[]>("/queues");
|
||||
}
|
||||
|
||||
export async function createQueue(data: { name: string; description?: string }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
export async function getUsers(): Promise<{ data: User[] | null; error: string | null }> {
|
||||
return request<User[]>("/users");
|
||||
}
|
||||
|
||||
export async function createUser(data: {
|
||||
username: string;
|
||||
email?: string | null;
|
||||
}): Promise<{ data: User | null; error: string | null }> {
|
||||
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, data: {
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
}): Promise<{ data: User | null; error: string | null }> {
|
||||
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
|
||||
return request<Scrip[]>("/scrips");
|
||||
}
|
||||
|
||||
export async function createScrip(data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
queue_id?: string | null;
|
||||
condition_type: string;
|
||||
condition_config?: Record<string, unknown>;
|
||||
action_type: string;
|
||||
action_config?: Record<string, unknown>;
|
||||
template_id?: string | null;
|
||||
@@ -84,8 +146,10 @@ export async function createScrip(data: {
|
||||
|
||||
export async function updateScrip(id: string, data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
queue_id?: string | null;
|
||||
condition_type?: string;
|
||||
condition_config?: Record<string, unknown>;
|
||||
action_type?: string;
|
||||
action_config?: Record<string, unknown>;
|
||||
template_id?: string | null;
|
||||
@@ -96,26 +160,216 @@ export async function updateScrip(id: string, data: {
|
||||
return request<Scrip>(`/scrips/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getTemplates(): Promise<{ data: Template[] | null; error: string | null }> {
|
||||
return request<Template[]>("/templates");
|
||||
}
|
||||
|
||||
export async function createTemplate(data: {
|
||||
name: string;
|
||||
queue_id?: string | null;
|
||||
subject_template: string;
|
||||
body_template: string;
|
||||
}): Promise<{ data: Template | null; error: string | null }> {
|
||||
return request<Template>("/templates", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateTemplate(id: string, data: {
|
||||
name?: string;
|
||||
queue_id?: string | null;
|
||||
subject_template?: string;
|
||||
body_template?: string;
|
||||
}): Promise<{ data: Template | null; error: string | null }> {
|
||||
return request<Template>(`/templates/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function previewTemplate(data: {
|
||||
subject_template: string;
|
||||
body_template: string;
|
||||
ticket_id?: number | null;
|
||||
}): Promise<{ data: TemplatePreview | null; error: string | null }> {
|
||||
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/templates/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
|
||||
return request<Lifecycle[]>("/lifecycles");
|
||||
}
|
||||
|
||||
export async function createLifecycle(data: {
|
||||
name: string;
|
||||
definition: Record<string, unknown>;
|
||||
definition: Record<string, unknown> | LifecycleDefinition;
|
||||
}): Promise<{ data: Lifecycle | null; error: string | null }> {
|
||||
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateLifecycle(id: string, data: {
|
||||
name?: string;
|
||||
definition?: Record<string, unknown> | LifecycleDefinition;
|
||||
}): Promise<{ data: Lifecycle | null; error: string | null }> {
|
||||
return request<Lifecycle>(`/lifecycles/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
|
||||
return request<CustomField[]>("/custom-fields");
|
||||
}
|
||||
|
||||
export async function getQueueCustomFields(queueId: string): Promise<{ data: QueueCustomField[] | null; error: string | null }> {
|
||||
return request<QueueCustomField[]>(`/custom-fields/queues/${queueId}`);
|
||||
}
|
||||
|
||||
export async function assignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: QueueCustomField | null; error: string | null }> {
|
||||
return request<QueueCustomField>(`/custom-fields/queues/${queueId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ custom_field_id: customFieldId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unassignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/custom-fields/queues/${queueId}/${customFieldId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function updateTicketCustomField(ticketId: number, customFieldId: string, value: string): Promise<{ data: Transaction | null; error: string | null }> {
|
||||
return request<Transaction>(`/tickets/${ticketId}/custom-fields/${customFieldId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ value }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCustomField(data: {
|
||||
key?: string;
|
||||
name: string;
|
||||
field_type: string;
|
||||
values?: unknown | null;
|
||||
max_values?: number;
|
||||
pattern?: string | null;
|
||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateCustomField(id: string, data: {
|
||||
key?: string;
|
||||
name?: string;
|
||||
field_type?: string;
|
||||
values?: unknown | null;
|
||||
max_values?: number;
|
||||
pattern?: string | null;
|
||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> {
|
||||
return request<SavedView[]>("/views");
|
||||
}
|
||||
|
||||
export async function createView(data: {
|
||||
name: string;
|
||||
filters: { field: string; operator: string; value: string }[];
|
||||
sort_key?: string;
|
||||
columns?: { key: string; label: string; width: number; visible: boolean }[];
|
||||
is_public?: boolean;
|
||||
}): Promise<{ data: SavedView | null; error: string | null }> {
|
||||
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateView(id: string, data: {
|
||||
name?: string;
|
||||
filters?: { field: string; operator: string; value: string }[];
|
||||
sort_key?: string;
|
||||
columns?: unknown[];
|
||||
is_public?: boolean;
|
||||
}): Promise<{ data: SavedView | null; error: string | null }> {
|
||||
return request<SavedView>(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
|
||||
return request<Dashboard[]>("/dashboards");
|
||||
}
|
||||
|
||||
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||
return request<Dashboard>(`/dashboards/${id}`);
|
||||
}
|
||||
|
||||
export async function createDashboard(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
team_id?: string | null;
|
||||
is_default?: boolean;
|
||||
}): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateDashboard(id: string, data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
team_id?: string | null;
|
||||
is_default?: boolean;
|
||||
layout?: unknown[];
|
||||
}): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
|
||||
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
|
||||
}
|
||||
|
||||
export async function createWidget(dashboardId: string, data: {
|
||||
view_id: string;
|
||||
title: string;
|
||||
widget_type: string;
|
||||
position?: { x: number; y: number; w: number; h: number };
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
|
||||
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateWidget(dashboardId: string, widgetId: string, data: {
|
||||
title?: string;
|
||||
widget_type?: string;
|
||||
position?: { x: number; y: number; w: number; h: number };
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
|
||||
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
|
||||
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
|
||||
}
|
||||
|
||||
export async function getTeams(): Promise<{ data: Team[] | null; error: string | null }> {
|
||||
return request<Team[]>("/teams");
|
||||
}
|
||||
|
||||
export async function createTeam(data: { name: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
|
||||
return request<Team>("/teams", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateTeam(id: string, data: { name?: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
|
||||
return request<Team>(`/teams/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteTeam(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/teams/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function addTeamMember(teamId: string, userId: string): Promise<{ data: unknown | null; error: string | null }> {
|
||||
return request<unknown>(`/teams/${teamId}/members`, { method: "POST", body: JSON.stringify({ user_id: userId }) });
|
||||
}
|
||||
|
||||
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
id: number;
|
||||
subject: string;
|
||||
queue_id: string;
|
||||
status: string;
|
||||
owner_id: string | null;
|
||||
team_id: string | null;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -17,11 +18,19 @@ 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;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
ticket_id: number;
|
||||
transaction_type: string;
|
||||
field: string | null;
|
||||
old_value: string | null;
|
||||
@@ -35,13 +44,16 @@ export interface Scrip {
|
||||
id: string;
|
||||
queue_id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
condition_type: string;
|
||||
condition_config: Record<string, unknown>;
|
||||
action_type: string;
|
||||
action_config: Record<string, unknown>;
|
||||
template_id: string | null;
|
||||
stage: string;
|
||||
sort_order: number;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
@@ -50,6 +62,13 @@ export interface Template {
|
||||
queue_id: string | null;
|
||||
subject_template: string;
|
||||
body_template: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TemplatePreview {
|
||||
subject: string;
|
||||
body: string;
|
||||
context: unknown;
|
||||
}
|
||||
|
||||
export interface Lifecycle {
|
||||
@@ -65,16 +84,26 @@ export interface LifecycleDefinition {
|
||||
|
||||
export interface CustomField {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
field_type: string;
|
||||
values: unknown | null;
|
||||
max_values: number;
|
||||
pattern: string | null;
|
||||
}
|
||||
|
||||
export interface QueueCustomField {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
custom_field_id: string;
|
||||
sort_order: number;
|
||||
custom_field: CustomField | null;
|
||||
}
|
||||
|
||||
export interface CustomFieldValue {
|
||||
id: string;
|
||||
custom_field_id: string;
|
||||
ticket_id: string;
|
||||
ticket_id: number;
|
||||
value: string;
|
||||
custom_field?: CustomField;
|
||||
}
|
||||
@@ -101,3 +130,71 @@ 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;
|
||||
}
|
||||
|
||||
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatTicketId(id: number): string {
|
||||
return `TKT-${String(id).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user