Compare commits
51 Commits
2501bcbad1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392f941046 | ||
|
|
1c92f488f7 | ||
|
|
631365ab07 | ||
|
|
653139ad0d | ||
|
|
9679734e3f | ||
|
|
70f0924d4b | ||
|
|
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 |
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera
|
||||
DATABASE_URL=postgres://tessera:tessera@127.0.0.1:5435/tessera
|
||||
SERVER_HOST=127.0.0.1
|
||||
SERVER_PORT=9876
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -36,3 +36,12 @@ bun.lock
|
||||
|
||||
# Codegraph index (MCP tool)
|
||||
.codegraph
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Nimbalyst local plans
|
||||
nimbalyst-local/plans/
|
||||
|
||||
# Runtime data
|
||||
/data
|
||||
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -14,8 +14,10 @@ tessera/
|
||||
│ ├── models/ # TypeScript types + Zod schemas
|
||||
│ ├── scrip/ # Scrip engine (prepare/commit two-phase)
|
||||
│ └── lifecycle/ # State machine validator
|
||||
├── web/ # Frontend: Next.js 15 + shadcn/ui
|
||||
│ └── src/app/ # App Router pages
|
||||
├── web/ # Frontend: Next.js 16 + shadcn/ui
|
||||
│ ├── src/app/ # App Router pages
|
||||
│ ├── src/components/ # Reusable components + widgets
|
||||
│ └── src/lib/ # API client + types + utils
|
||||
├── drizzle/ # SQL migration files
|
||||
└── docs/ # Architecture + design specs
|
||||
```
|
||||
@@ -24,9 +26,9 @@ tessera/
|
||||
|
||||
**Backend:** Bun runtime, Hono web framework, Drizzle ORM, PostgreSQL 17, Zod validation, Handlebars templates, nodemailer
|
||||
|
||||
**Frontend:** Next.js 15 App Router, shadcn/ui (Tailwind CSS), next-themes (light/dark), React Hook Form + Zod, TanStack Table, date-fns, lucide-react icons
|
||||
**Frontend:** Next.js 16 App Router (Turbopack), shadcn/ui (Tailwind CSS), next-themes, date-fns, lucide-react icons
|
||||
|
||||
**Fonts:** Inter (variable, with cv01+ss03 OpenType features), JetBrains Mono
|
||||
**Fonts:** Inter (variable), JetBrains Mono
|
||||
|
||||
## Running Locally
|
||||
|
||||
@@ -34,50 +36,52 @@ tessera/
|
||||
- Bun (`nix-shell -p bun` or install globally)
|
||||
- Node.js 22+ (`nix-shell -p nodejs_22`)
|
||||
- Docker (for PostgreSQL)
|
||||
- PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=*** -e POSTGRES_DB=tessera -p 127.0.0.1:5433:5432 postgres:17-alpine`
|
||||
- PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=tessera -e POSTGRES_DB=tessera -p 127.0.0.1:5435:5432 postgres:17-alpine`
|
||||
|
||||
### Start backend
|
||||
```bash
|
||||
cd ~/projects/tessera
|
||||
cp .env.example .env # Edit DATABASE_URL to point to postgres://tessera:***@127.0.0.1:5433/tessera
|
||||
cp .env.example .env
|
||||
npm run dev:backend # Starts API on port 9876
|
||||
```
|
||||
|
||||
### Run migrations
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run db:seed # Optional demo data for UI review
|
||||
npm run db:seed:reset # Reset local app data, then recreate demo data
|
||||
npm run db:seed # Demo data
|
||||
npm run db:seed:reset # Reset + re-seed
|
||||
```
|
||||
|
||||
### Start frontend
|
||||
```bash
|
||||
cd web
|
||||
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server)
|
||||
npm run build # Production build
|
||||
npm run start # Production server on 127.0.0.1:3100
|
||||
npm install # Use npm, NOT bun
|
||||
bun run dev # Dev server on 127.0.0.1:3100 (HMR)
|
||||
```
|
||||
|
||||
**Note:** `bun run dev` (Turbopack) has WebSocket HMR issues in this environment. Use production mode only.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints are served by the backend on port 9876. The frontend proxies `/api/*` to the backend via `next.config.ts`.
|
||||
All endpoints on port 9876. Frontend proxies `/api/*` via `next.config.ts`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | /health | Health check |
|
||||
| GET | /tickets | List tickets (?queue_id=&status=) |
|
||||
| GET | /tickets | List tickets (?queue_id=&status=&owner_id=&team_id=&q=&limit=&cf.*=) |
|
||||
| POST | /tickets | Create ticket |
|
||||
| GET | /tickets/:id | Get ticket with custom fields |
|
||||
| PATCH | /tickets/:id | Update ticket (validates lifecycle, runs scrips) |
|
||||
| PATCH | /tickets/:id | Update ticket (validates lifecycle, runs scrips, returns scrip_results) |
|
||||
| POST | /tickets/:id/preview | Dry-run scrips for status change |
|
||||
| POST | /tickets/:id/comment | Add comment to ticket |
|
||||
| GET | /tickets/:id/transactions | List ticket transactions |
|
||||
| GET/POST | /queues | List/create queues |
|
||||
| GET/POST/PATCH | /scrips | CRUD scrips |
|
||||
| GET/POST | /custom-fields | List/create custom fields |
|
||||
| GET/POST | /lifecycles | List/create lifecycles |
|
||||
| GET/POST/PATCH | /queues | CRUD queues |
|
||||
| GET/POST/PATCH/DELETE | /scrips | CRUD scrips |
|
||||
| GET/POST/PATCH | /custom-fields | CRUD custom fields |
|
||||
| GET/POST/PATCH | /lifecycles | CRUD lifecycles |
|
||||
| GET/POST/PATCH/DELETE | /users | CRUD users |
|
||||
| GET/POST/PATCH/DELETE | /templates | CRUD templates + POST /preview |
|
||||
| GET/POST/PATCH/DELETE | /views | CRUD saved views |
|
||||
| GET/POST/PATCH/DELETE | /teams | CRUD teams + POST/DELETE members |
|
||||
| GET/POST/PATCH/DELETE | /dashboards | CRUD dashboards + widgets + widget data |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
@@ -85,32 +89,22 @@ All endpoints are served by the backend on port 9876. The frontend proxies `/api
|
||||
- **Transaction-centric:** Every state change creates a transaction record. The scrip engine runs on transactions.
|
||||
- **Two-phase scrip engine:** Prepare (no side effects) then Commit (execute actions). Supports dry-run mode.
|
||||
- **Lifecycle state machines:** Per-queue configurable status transitions with wildcard support.
|
||||
- **Light mode is default.** Dark mode available via theme toggle (next-themes).
|
||||
- **SQL-level filtering:** Ticket filters (status, queue, owner, team, custom fields) pushed to PostgreSQL via Drizzle WHERE clauses.
|
||||
- **No ORM for frontend:** Drizzle is only on the backend. Frontend uses a typed fetch wrapper (`web/src/lib/api.ts`).
|
||||
- **Dev server over production:** Use `bun run dev` (port 3100) with HMR. Build+restart only when dev server has issues.
|
||||
- **Design consistency:** See `docs/design-system.md` for the design rules applied across the app.
|
||||
|
||||
## Git Workflow
|
||||
|
||||
Repo: `https://git.gjermund.xyz/gjermund/tessera`
|
||||
|
||||
Push via HTTPS with token auth (SSH port 2222 is not configured on Gitea):
|
||||
```bash
|
||||
git remote set-url origin https://gjermund:TOKEN@git.gjermund.xyz/gjermund/tessera.git
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
All code is written by **OpenCode** (AI coding agent). Hermes writes specs, OpenCode writes code, Gjermund reviews.
|
||||
|
||||
OpenCode server: `opencode serve --port 4096` (Gjermund attaches with `opencode --attach http://127.0.0.1:4096`)
|
||||
|
||||
After OpenCode makes changes:
|
||||
1. `cd web && npx next build` — verify zero errors
|
||||
2. `npm run start` — restart production server on 127.0.0.1:3100
|
||||
3. `git push` — push to origin
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **Frontend shows skeleton/blank page:** The frontend dev server (Turbopack) has WebSocket HMR issues. Use production mode (`npx next build` + `npx next start`).
|
||||
- **Frontend shows skeleton/blank page:** Dev server may have HMR issues. Kill port 3100, rebuild with `npm run build`, restart with `npm run start`.
|
||||
- **Backend not running on 9876:** Restart with `bun run src/index.ts`. Check port with `ss -tlnp | grep 9876`.
|
||||
- **Database connection refused:** Docker container may be stopped. `docker start tessera-db`.
|
||||
- **Build errors after migration:** Run `bun run src/db/migrate.ts` to apply new migrations.
|
||||
|
||||
118
docs/design-system.md
Normal file
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`)
|
||||
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;
|
||||
12
drizzle/migrations/0008_sturdy_prism.sql
Normal file
12
drizzle/migrations/0008_sturdy_prism.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "transaction_attachments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"transaction_id" uuid NOT NULL,
|
||||
"filename" text NOT NULL,
|
||||
"mime_type" text DEFAULT 'application/octet-stream' NOT NULL,
|
||||
"size_bytes" integer DEFAULT 0 NOT NULL,
|
||||
"storage_path" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "transaction_attachments" ADD CONSTRAINT "transaction_attachments_transaction_id_transactions_id_fk" FOREIGN KEY ("transaction_id") REFERENCES "public"."transactions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "transaction_attachments_tx_id_idx" ON "transaction_attachments" USING btree ("transaction_id");
|
||||
1
drizzle/migrations/0009_tiny_lady_vermin.sql
Normal file
1
drizzle/migrations/0009_tiny_lady_vermin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "transaction_attachments" ALTER COLUMN "transaction_id" DROP NOT NULL;
|
||||
15
drizzle/migrations/0010_misty_morg.sql
Normal file
15
drizzle/migrations/0010_misty_morg.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "ticket_links" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ticket_id" integer NOT NULL,
|
||||
"target_ticket_id" integer NOT NULL,
|
||||
"link_type" text NOT NULL,
|
||||
"creator_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "ticket_links_ticket_target_type_unique" UNIQUE("ticket_id","target_ticket_id","link_type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_target_ticket_id_tickets_id_fk" FOREIGN KEY ("target_ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ticket_links_ticket_id_idx" ON "ticket_links" USING btree ("ticket_id");--> statement-breakpoint
|
||||
CREATE INDEX "ticket_links_target_ticket_id_idx" ON "ticket_links" USING btree ("target_ticket_id");
|
||||
2
drizzle/migrations/0011_breezy_tyrannus.sql
Normal file
2
drizzle/migrations/0011_breezy_tyrannus.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "users" ADD COLUMN "password_hash" text;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'staff' NOT NULL;
|
||||
12
drizzle/migrations/0012_living_photon.sql
Normal file
12
drizzle/migrations/0012_living_photon.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "queue_permissions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"queue_id" uuid NOT NULL,
|
||||
"team_id" uuid NOT NULL,
|
||||
"right_name" text NOT NULL,
|
||||
CONSTRAINT "queue_permissions_queue_team_right_unique" UNIQUE("queue_id","team_id","right_name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "queue_permissions_queue_id_idx" ON "queue_permissions" USING btree ("queue_id");--> statement-breakpoint
|
||||
CREATE INDEX "queue_permissions_team_id_idx" ON "queue_permissions" USING btree ("team_id");
|
||||
12
drizzle/migrations/0013_bored_silvermane.sql
Normal file
12
drizzle/migrations/0013_bored_silvermane.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "user_permissions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"queue_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"right_name" text NOT NULL,
|
||||
CONSTRAINT "user_permissions_queue_user_right_unique" UNIQUE("queue_id","user_id","right_name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "user_permissions_queue_id_idx" ON "user_permissions" USING btree ("queue_id");--> statement-breakpoint
|
||||
CREATE INDEX "user_permissions_user_id_idx" ON "user_permissions" USING btree ("user_id");
|
||||
1
drizzle/migrations/0014_cloudy_siren.sql
Normal file
1
drizzle/migrations/0014_cloudy_siren.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "transactions" ADD COLUMN "time_worked_minutes" integer DEFAULT 0;
|
||||
15
drizzle/migrations/0015_tense_patch.sql
Normal file
15
drizzle/migrations/0015_tense_patch.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "notifications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"ticket_id" integer,
|
||||
"type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text,
|
||||
"read" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "notifications_user_read_idx" ON "notifications" USING btree ("user_id","read");
|
||||
12
drizzle/migrations/0016_famous_maximus.sql
Normal file
12
drizzle/migrations/0016_famous_maximus.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "api_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");
|
||||
1
drizzle/migrations/0017_redundant_the_renegades.sql
Normal file
1
drizzle/migrations/0017_redundant_the_renegades.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "scrips" ADD COLUMN "applicable_trans_types" text;
|
||||
2
drizzle/migrations/0018_dapper_jack_power.sql
Normal file
2
drizzle/migrations/0018_dapper_jack_power.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "custom_fields" ADD COLUMN "validation_config" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "custom_fields" ADD COLUMN "default_value" text;
|
||||
9
drizzle/migrations/0019_watcher_tables.sql
Normal file
9
drizzle/migrations/0019_watcher_tables.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE ticket_watchers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(ticket_id, user_id)
|
||||
);
|
||||
CREATE INDEX ticket_watchers_ticket_id_idx ON ticket_watchers(ticket_id);
|
||||
CREATE INDEX ticket_watchers_user_id_idx ON ticket_watchers(user_id);
|
||||
15
drizzle/migrations/0020_sla_tables.sql
Normal file
15
drizzle/migrations/0020_sla_tables.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE sla_policies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
queue_id UUID REFERENCES queues(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
response_time_minutes INTEGER,
|
||||
resolution_time_minutes INTEGER,
|
||||
disabled BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX sla_policies_queue_id_idx ON sla_policies(queue_id);
|
||||
|
||||
ALTER TABLE tickets ADD COLUMN sla_response_deadline TIMESTAMPTZ;
|
||||
ALTER TABLE tickets ADD COLUMN sla_resolution_deadline TIMESTAMPTZ;
|
||||
ALTER TABLE tickets ADD COLUMN sla_breached TEXT;
|
||||
1
drizzle/migrations/0021_romantic_captain_midlands.sql
Normal file
1
drizzle/migrations/0021_romantic_captain_midlands.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "queues" ADD COLUMN "mail_alias" text;
|
||||
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
1418
drizzle/migrations/meta/0008_snapshot.json
Normal file
1418
drizzle/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1418
drizzle/migrations/meta/0009_snapshot.json
Normal file
1418
drizzle/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1550
drizzle/migrations/meta/0010_snapshot.json
Normal file
1550
drizzle/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1563
drizzle/migrations/meta/0011_snapshot.json
Normal file
1563
drizzle/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1669
drizzle/migrations/meta/0012_snapshot.json
Normal file
1669
drizzle/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1775
drizzle/migrations/meta/0013_snapshot.json
Normal file
1775
drizzle/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1782
drizzle/migrations/meta/0014_snapshot.json
Normal file
1782
drizzle/migrations/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1910
drizzle/migrations/meta/0015_snapshot.json
Normal file
1910
drizzle/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1999
drizzle/migrations/meta/0016_snapshot.json
Normal file
1999
drizzle/migrations/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2005
drizzle/migrations/meta/0017_snapshot.json
Normal file
2005
drizzle/migrations/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2018
drizzle/migrations/meta/0018_snapshot.json
Normal file
2018
drizzle/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2024
drizzle/migrations/meta/0021_snapshot.json
Normal file
2024
drizzle/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,139 @@
|
||||
"when": 1780904200000,
|
||||
"tag": "0002_short_custom_field_keys",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1780995910694,
|
||||
"tag": "0003_dry_caretaker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1780996807814,
|
||||
"tag": "0004_sturdy_natasha_romanoff",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1781004398567,
|
||||
"tag": "0005_spotty_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1781008559188,
|
||||
"tag": "0006_nosy_black_queen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781009018666,
|
||||
"tag": "0007_flimsy_roughhouse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781039674211,
|
||||
"tag": "0008_sturdy_prism",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1781039770418,
|
||||
"tag": "0009_tiny_lady_vermin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1781040536590,
|
||||
"tag": "0010_misty_morg",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1781042321413,
|
||||
"tag": "0011_breezy_tyrannus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781043175153,
|
||||
"tag": "0012_living_photon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1781043729230,
|
||||
"tag": "0013_bored_silvermane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1781045611610,
|
||||
"tag": "0014_cloudy_siren",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1781078349499,
|
||||
"tag": "0015_tense_patch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1781078511943,
|
||||
"tag": "0016_famous_maximus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781095552496,
|
||||
"tag": "0017_redundant_the_renegades",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1781551130161,
|
||||
"tag": "0018_dapper_jack_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1781552000000,
|
||||
"tag": "0019_watcher_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1781552001000,
|
||||
"tag": "0020_sla_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1781552215621,
|
||||
"tag": "0021_romantic_captain_midlands",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1783
package-lock.json
generated
Normal file
1783
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"jose": "^6.2.3",
|
||||
"nodemailer": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
28
scripts/seed-users.ts
Normal file
28
scripts/seed-users.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { users } from '../src/db/schema.ts';
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
|
||||
const db = drizzle(pool);
|
||||
|
||||
const BATCH = 100;
|
||||
const TOTAL = 1000;
|
||||
const password = await Bun.password.hash('password');
|
||||
|
||||
console.log(`Inserting ${TOTAL} users...`);
|
||||
for (let i = 0; i < TOTAL; i += BATCH) {
|
||||
const batch = [];
|
||||
for (let j = i; j < Math.min(i + BATCH, TOTAL); j++) {
|
||||
const n = String(j).padStart(4, '0');
|
||||
batch.push({
|
||||
username: `user${n}`,
|
||||
email: `user${n}@test.local`,
|
||||
role: 'staff',
|
||||
password_hash: password,
|
||||
});
|
||||
}
|
||||
await db.insert(users).values(batch as any).onConflictDoNothing();
|
||||
process.stdout.write('.');
|
||||
}
|
||||
console.log(`\nDone. ${TOTAL} users seeded.`);
|
||||
await pool.end();
|
||||
144
src/auth/middleware.ts
Normal file
144
src/auth/middleware.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import * as jose from 'jose';
|
||||
import { config } from '../config.ts';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users, apiTokens } from '../db/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: AuthUser;
|
||||
}
|
||||
}
|
||||
|
||||
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||
|
||||
export async function createToken(user: { id: string; username: string; role: string }): Promise<string> {
|
||||
return await new jose.SignJWT({ username: user.username, role: user.role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(user.id)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
async function verifyJwt(token: string): Promise<AuthUser | null> {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, secret);
|
||||
return {
|
||||
userId: payload.sub!,
|
||||
username: payload.username as string,
|
||||
role: payload.role as string,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyApiToken(db: Db, token: string): Promise<AuthUser | null> {
|
||||
try {
|
||||
// Find all tokens and verify against hash
|
||||
const allTokens = await db.query.apiTokens.findMany();
|
||||
for (const t of allTokens) {
|
||||
const valid = await Bun.password.verify(token, t.token_hash);
|
||||
if (valid) {
|
||||
// Update last_used_at
|
||||
await db.update(apiTokens)
|
||||
.set({ last_used_at: new Date() } as any)
|
||||
.where(eq(apiTokens.id, t.id));
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, t.user_id),
|
||||
});
|
||||
if (user) {
|
||||
return {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractToken(c: Context): string | null {
|
||||
const auth = c.req.header('Authorization');
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
|
||||
const cookie = c.req.header('Cookie');
|
||||
if (cookie) {
|
||||
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAuthMiddleware(db: Db) {
|
||||
async function verifyToken(token: string): Promise<AuthUser | null> {
|
||||
if (token.startsWith('tessera_')) {
|
||||
return await verifyApiToken(db, token);
|
||||
}
|
||||
return await verifyJwt(token);
|
||||
}
|
||||
|
||||
async function requireAuth(c: Context, next: Next) {
|
||||
const token = extractToken(c);
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Authentication required' });
|
||||
}
|
||||
const user = await verifyToken(token);
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||
}
|
||||
c.set('user', user);
|
||||
await next();
|
||||
}
|
||||
|
||||
async function requireAdmin(c: Context, next: Next) {
|
||||
const token = extractToken(c);
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Authentication required' });
|
||||
}
|
||||
const user = await verifyToken(token);
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
throw new HTTPException(403, { message: 'Admin access required' });
|
||||
}
|
||||
c.set('user', user);
|
||||
await next();
|
||||
}
|
||||
|
||||
async function optionalAuth(c: Context, next: Next) {
|
||||
const token = extractToken(c);
|
||||
if (token) {
|
||||
const user = await verifyToken(token);
|
||||
if (user) {
|
||||
c.set('user', user);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
return { requireAuth, requireAdmin, optionalAuth };
|
||||
}
|
||||
|
||||
export function getUserId(c: Context): string {
|
||||
const user = c.get('user');
|
||||
return user?.userId ?? '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
86
src/auth/permissions.ts
Normal file
86
src/auth/permissions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Context } from 'hono';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { teamMembers, queuePermissions, userPermissions } from '../db/schema.ts';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { AuthUser } from './middleware.ts';
|
||||
|
||||
export type TicketRight = 'ticket.view' | 'ticket.create' | 'ticket.reply' | 'ticket.comment' | 'ticket.modify' | 'queue.admin';
|
||||
|
||||
const RIGHT_HIERARCHY: Record<TicketRight, TicketRight[]> = {
|
||||
'queue.admin': ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'],
|
||||
'ticket.modify': ['ticket.view', 'ticket.reply', 'ticket.comment', 'ticket.modify'],
|
||||
'ticket.reply': ['ticket.view', 'ticket.reply'],
|
||||
'ticket.comment': ['ticket.view', 'ticket.comment'],
|
||||
'ticket.create': ['ticket.create'],
|
||||
'ticket.view': ['ticket.view'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a user has a specific right on a queue.
|
||||
* Admins bypass all permission checks.
|
||||
* Rights come from two sources: team memberships and per-user grants.
|
||||
* Higher rights imply lower rights (e.g., queue.admin implies ticket.view).
|
||||
*/
|
||||
export async function userHasRight(
|
||||
db: Db,
|
||||
user: AuthUser,
|
||||
queueId: string,
|
||||
right: TicketRight,
|
||||
): Promise<boolean> {
|
||||
// Admins have all rights
|
||||
if (user.role === 'admin') return true;
|
||||
|
||||
const neededRights = RIGHT_HIERARCHY[right] ?? [right];
|
||||
|
||||
// Check per-user permissions first (direct grant)
|
||||
const userPerm = await db.query.userPermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn, inArray: inArr }) =>
|
||||
and(
|
||||
eqFn(table.user_id, user.userId),
|
||||
eqFn(table.queue_id, queueId),
|
||||
inArr(table.right_name, neededRights),
|
||||
),
|
||||
});
|
||||
|
||||
if (userPerm) return true;
|
||||
|
||||
// Check team permissions (inherited)
|
||||
const memberships = await db.query.teamMembers.findMany({
|
||||
where: eq(teamMembers.user_id, user.userId),
|
||||
});
|
||||
|
||||
const teamIds = memberships.map((m) => m.team_id);
|
||||
if (teamIds.length === 0) return false;
|
||||
|
||||
const teamPerm = await db.query.queuePermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn, inArray: inArr }) =>
|
||||
and(
|
||||
inArr(table.team_id, teamIds),
|
||||
eqFn(table.queue_id, queueId),
|
||||
inArr(table.right_name, neededRights),
|
||||
),
|
||||
});
|
||||
|
||||
return teamPerm !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific right on a queue. Throws 403 if the user lacks the right.
|
||||
*/
|
||||
export async function requireRight(
|
||||
c: Context,
|
||||
db: Db,
|
||||
queueId: string,
|
||||
right: TicketRight,
|
||||
): Promise<void> {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Authentication required' });
|
||||
}
|
||||
|
||||
const has = await userHasRight(db, user, queueId, right);
|
||||
if (!has) {
|
||||
throw new HTTPException(403, { message: `Missing required right: ${right}` });
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,14 @@ const configSchema = z.object({
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('tessera@localhost'),
|
||||
UPLOAD_DIR: z.string().default('./data/uploads'),
|
||||
JWT_SECRET: z.string().default('tessera-dev-secret-change-in-production'),
|
||||
// Inbound email
|
||||
MAIL_TRANSPORT: z.enum(['mailtm', 'webhook', 'none']).default('none'),
|
||||
MAILTM_POLL_SECONDS: z.coerce.number().int().positive().default(30),
|
||||
MAILTM_ADDRESS: z.string().optional(),
|
||||
MAILTM_ACCOUNT_ID: z.string().optional(),
|
||||
MAILTM_TOKEN: z.string().optional(),
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
|
||||
166
src/db/schema.ts
166
src/db/schema.ts
@@ -1,10 +1,12 @@
|
||||
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { relations, sql } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
username: text('username').notNull().unique(),
|
||||
email: text('email'),
|
||||
password_hash: text('password_hash'),
|
||||
role: text('role').notNull().default('staff'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
@@ -13,6 +15,8 @@ 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' }),
|
||||
mail_alias: text('mail_alias'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
@@ -29,16 +33,33 @@ export const tickets = pgTable('tickets', {
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id),
|
||||
status: text('status').notNull(),
|
||||
owner_id: uuid('owner_id').references(() => users.id),
|
||||
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
|
||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
started_at: timestamp('started_at', { withTimezone: true }),
|
||||
resolved_at: timestamp('resolved_at', { withTimezone: true }),
|
||||
sla_response_deadline: timestamp('sla_response_deadline', { withTimezone: true }),
|
||||
sla_resolution_deadline: timestamp('sla_resolution_deadline', { withTimezone: true }),
|
||||
sla_breached: text('sla_breached'), // 'response' | 'resolution' | 'both' | null
|
||||
}, (table) => ({
|
||||
queueIdIdx: index('tickets_queue_id_idx').on(table.queue_id),
|
||||
statusIdx: index('tickets_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const slaPolicies = pgTable('sla_policies', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').references(() => queues.id, { onDelete: 'set null' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
response_time_minutes: integer('response_time_minutes'),
|
||||
resolution_time_minutes: integer('resolution_time_minutes'),
|
||||
disabled: boolean('disabled').notNull().default(false),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
queueIdIdx: index('sla_policies_queue_id_idx').on(table.queue_id),
|
||||
}));
|
||||
|
||||
export const transactions = pgTable('transactions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
@@ -47,6 +68,7 @@ export const transactions = pgTable('transactions', {
|
||||
old_value: text('old_value'),
|
||||
new_value: text('new_value'),
|
||||
data: jsonb('data'),
|
||||
time_worked_minutes: integer('time_worked_minutes').default(0),
|
||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
@@ -76,6 +98,7 @@ export const scrips = pgTable('scrips', {
|
||||
stage: text('stage').notNull().default('TransactionCreate'),
|
||||
sort_order: integer('sort_order').notNull().default(0),
|
||||
disabled: boolean('disabled').notNull().default(false),
|
||||
applicable_trans_types: text('applicable_trans_types'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
|
||||
@@ -89,6 +112,8 @@ export const customFields = pgTable('custom_fields', {
|
||||
values: jsonb('values'),
|
||||
max_values: integer('max_values').notNull().default(1),
|
||||
pattern: text('pattern'),
|
||||
validation_config: jsonb('validation_config').default(sql`'{}'::jsonb`),
|
||||
default_value: text('default_value'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
@@ -112,3 +137,142 @@ export const customFieldValues = pgTable('custom_field_values', {
|
||||
ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id),
|
||||
cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id),
|
||||
}));
|
||||
|
||||
export const views = pgTable('views', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
filters: jsonb('filters').notNull().default('[]'),
|
||||
sort_key: text('sort_key').default('updated'),
|
||||
columns: jsonb('columns').default('[]'),
|
||||
is_public: boolean('is_public').default(false),
|
||||
creator_id: uuid('creator_id').references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const teams = pgTable('teams', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull().unique(),
|
||||
description: text('description'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const teamsRelations = relations(teams, ({ many }) => ({
|
||||
members: many(teamMembers),
|
||||
}));
|
||||
|
||||
export const teamMembers = pgTable('team_members', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
}, (table) => ({
|
||||
uniqueMember: unique('team_members_team_id_user_id_unique').on(table.team_id, table.user_id),
|
||||
}));
|
||||
|
||||
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
|
||||
team: one(teams, { fields: [teamMembers.team_id], references: [teams.id] }),
|
||||
user: one(users, { fields: [teamMembers.user_id], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const dashboards = pgTable('dashboards', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
|
||||
layout: jsonb('layout').default('[]'),
|
||||
is_default: boolean('is_default').default(false),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const transactionAttachments = pgTable('transaction_attachments', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
transaction_id: uuid('transaction_id').references(() => transactions.id, { onDelete: 'cascade' }),
|
||||
filename: text('filename').notNull(),
|
||||
mime_type: text('mime_type').notNull().default('application/octet-stream'),
|
||||
size_bytes: integer('size_bytes').notNull().default(0),
|
||||
storage_path: text('storage_path').notNull(),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
transactionIdIdx: index('transaction_attachments_tx_id_idx').on(table.transaction_id),
|
||||
}));
|
||||
|
||||
export const queuePermissions = pgTable('queue_permissions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
|
||||
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
|
||||
right_name: text('right_name').notNull(),
|
||||
}, (table) => ({
|
||||
uniqueRight: unique('queue_permissions_queue_team_right_unique').on(table.queue_id, table.team_id, table.right_name),
|
||||
queueIdIdx: index('queue_permissions_queue_id_idx').on(table.queue_id),
|
||||
teamIdIdx: index('queue_permissions_team_id_idx').on(table.team_id),
|
||||
}));
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
token_hash: text('token_hash').notNull().unique(),
|
||||
last_used_at: timestamp('last_used_at', { withTimezone: true }),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.user_id),
|
||||
}));
|
||||
|
||||
export const ticketWatchers = pgTable('ticket_watchers', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
uniqueWatcher: unique('ticket_watchers_ticket_id_user_id_unique').on(table.ticket_id, table.user_id),
|
||||
ticketIdIdx: index('ticket_watchers_ticket_id_idx').on(table.ticket_id),
|
||||
userIdIdx: index('ticket_watchers_user_id_idx').on(table.user_id),
|
||||
}));
|
||||
|
||||
export const notifications = pgTable('notifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
ticket_id: integer('ticket_id').references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'assigned', 'mentioned', 'commented', 'scrip_fired'
|
||||
title: text('title').notNull(),
|
||||
body: text('body'),
|
||||
read: boolean('read').notNull().default(false),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('notifications_user_id_idx').on(table.user_id),
|
||||
unreadIdx: index('notifications_user_read_idx').on(table.user_id, table.read),
|
||||
}));
|
||||
|
||||
export const userPermissions = pgTable('user_permissions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
right_name: text('right_name').notNull(),
|
||||
}, (table) => ({
|
||||
uniqueRight: unique('user_permissions_queue_user_right_unique').on(table.queue_id, table.user_id, table.right_name),
|
||||
queueIdIdx: index('user_permissions_queue_id_idx').on(table.queue_id),
|
||||
userIdIdx: index('user_permissions_user_id_idx').on(table.user_id),
|
||||
}));
|
||||
|
||||
export const ticketLinks = pgTable('ticket_links', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
target_ticket_id: integer('target_ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
link_type: text('link_type').notNull(),
|
||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
uniqueLink: unique('ticket_links_ticket_target_type_unique').on(table.ticket_id, table.target_ticket_id, table.link_type),
|
||||
ticketIdIdx: index('ticket_links_ticket_id_idx').on(table.ticket_id),
|
||||
targetTicketIdIdx: index('ticket_links_target_ticket_id_idx').on(table.target_ticket_id),
|
||||
}));
|
||||
|
||||
export const dashboardWidgets = pgTable('dashboard_widgets', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
|
||||
view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
widget_type: text('widget_type').notNull(),
|
||||
position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'),
|
||||
config: jsonb('config').default('{}'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
133
src/db/seed.ts
133
src/db/seed.ts
@@ -7,11 +7,17 @@ import {
|
||||
customFieldValues,
|
||||
lifecycles,
|
||||
queueCustomFields,
|
||||
queuePermissions,
|
||||
queues,
|
||||
scrips,
|
||||
teamMembers,
|
||||
teams,
|
||||
templates,
|
||||
tickets,
|
||||
transactions,
|
||||
views,
|
||||
dashboards,
|
||||
dashboardWidgets,
|
||||
users,
|
||||
} from './schema.ts';
|
||||
|
||||
@@ -49,7 +55,7 @@ function createSeedDb(pool: Pool) {
|
||||
}
|
||||
|
||||
type Db = ReturnType<typeof createSeedDb>;
|
||||
type UserSeed = { id: string; username: string; email: string };
|
||||
type UserSeed = { id: string; username: string; email: string; role?: string; password_hash?: string };
|
||||
type QueueSeed = { name: string; description: string };
|
||||
type FieldSeed = {
|
||||
key?: string;
|
||||
@@ -70,12 +76,19 @@ function makeFieldKey(value: string): string {
|
||||
}
|
||||
|
||||
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
||||
const setData = {
|
||||
username: seed.username,
|
||||
email: seed.email,
|
||||
role: seed.role ?? 'staff',
|
||||
password_hash: seed.password_hash ?? null,
|
||||
};
|
||||
|
||||
const existingById = await db.query.users.findFirst({
|
||||
where: eq(users.id, seed.id),
|
||||
});
|
||||
if (existingById) {
|
||||
await db.update(users)
|
||||
.set({ username: seed.username, email: seed.email })
|
||||
.set(setData)
|
||||
.where(eq(users.id, seed.id));
|
||||
return existingById.id;
|
||||
}
|
||||
@@ -85,12 +98,12 @@ async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
||||
});
|
||||
if (existingByUsername) {
|
||||
await db.update(users)
|
||||
.set({ email: seed.email })
|
||||
.set(setData)
|
||||
.where(eq(users.id, existingByUsername.id));
|
||||
return existingByUsername.id;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(users).values(seed).returning();
|
||||
const [created] = await db.insert(users).values({ ...seed, ...setData }).returning();
|
||||
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
|
||||
return created.id;
|
||||
}
|
||||
@@ -312,8 +325,12 @@ async function ensureTicket(
|
||||
|
||||
async function resetDatabase(db: Db) {
|
||||
await db.delete(customFieldValues);
|
||||
await db.delete(queuePermissions);
|
||||
await db.delete(transactions);
|
||||
await db.delete(queueCustomFields);
|
||||
await db.delete(dashboardWidgets);
|
||||
await db.delete(dashboards);
|
||||
await db.delete(views);
|
||||
await db.delete(scrips);
|
||||
await db.delete(templates);
|
||||
await db.delete(tickets);
|
||||
@@ -340,34 +357,68 @@ async function main() {
|
||||
await resetDatabase(db);
|
||||
}
|
||||
|
||||
const userPassword = await Bun.password.hash('password');
|
||||
const adminPassword = await Bun.password.hash('admin');
|
||||
|
||||
const userIds = {
|
||||
system: await ensureUser(db, {
|
||||
id: SYSTEM_USER_ID,
|
||||
username: 'system',
|
||||
email: 'system@tessera.local',
|
||||
}),
|
||||
admin: await ensureUser(db, {
|
||||
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
username: 'admin',
|
||||
email: 'admin@tessera.local',
|
||||
role: 'admin',
|
||||
password_hash: adminPassword,
|
||||
}),
|
||||
dispatcher: await ensureUser(db, {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'maria.dispatch',
|
||||
email: 'maria.dispatch@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
technician: await ensureUser(db, {
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
username: 'liam.field',
|
||||
email: 'liam.field@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
facilities: await ensureUser(db, {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
username: 'nora.facilities',
|
||||
email: 'nora.facilities@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
security: await ensureUser(db, {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
username: 'sam.security',
|
||||
email: 'sam.security@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
};
|
||||
|
||||
// Create demo team and assign all staff users to it
|
||||
const [supportTeam] = await db.insert(teams).values({
|
||||
name: 'Support team',
|
||||
description: 'Demo support team with full queue access',
|
||||
}).onConflictDoUpdate({
|
||||
target: teams.name,
|
||||
set: { description: 'Demo support team with full queue access' },
|
||||
}).returning();
|
||||
|
||||
if (supportTeam) {
|
||||
// Add all staff users to the team
|
||||
const staffIds = [userIds.dispatcher, userIds.technician, userIds.facilities, userIds.security];
|
||||
for (const userId of staffIds) {
|
||||
await db.insert(teamMembers).values({
|
||||
team_id: supportTeam.id,
|
||||
user_id: userId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycle = await ensureLifecycle(db);
|
||||
|
||||
const supportQueue = await ensureQueue(db, lifecycle.id, {
|
||||
@@ -390,30 +441,30 @@ async function main() {
|
||||
const impactField = await ensureCustomField(db, {
|
||||
key: 'impact',
|
||||
name: 'Impact',
|
||||
field_type: 'select',
|
||||
field_type: 'SelectOne',
|
||||
values: ['Low', 'Medium', 'High', 'Critical'],
|
||||
});
|
||||
const locationField = await ensureCustomField(db, {
|
||||
key: 'location',
|
||||
name: 'Location',
|
||||
field_type: 'text',
|
||||
field_type: 'Text',
|
||||
});
|
||||
const assetField = await ensureCustomField(db, {
|
||||
key: 'asset_tag',
|
||||
name: 'Asset tag',
|
||||
field_type: 'text',
|
||||
field_type: 'Text',
|
||||
pattern: '^ASSET-[0-9]{4}$',
|
||||
});
|
||||
const channelField = await ensureCustomField(db, {
|
||||
key: 'channel',
|
||||
name: 'Channel',
|
||||
field_type: 'select',
|
||||
field_type: 'SelectOne',
|
||||
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
|
||||
});
|
||||
const outcomeField = await ensureCustomField(db, {
|
||||
key: 'resolution_outcome',
|
||||
name: 'Resolution outcome',
|
||||
field_type: 'select',
|
||||
field_type: 'SelectOne',
|
||||
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
|
||||
});
|
||||
|
||||
@@ -426,6 +477,20 @@ async function main() {
|
||||
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
|
||||
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
|
||||
|
||||
// Grant the support team full access to all demo queues
|
||||
if (supportTeam) {
|
||||
const allRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify'];
|
||||
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
|
||||
for (const right of allRights) {
|
||||
await db.insert(queuePermissions).values({
|
||||
queue_id: queue.id,
|
||||
team_id: supportTeam.id,
|
||||
right_name: right,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTemplate = await ensureTemplate(
|
||||
db,
|
||||
'Demo resolution note',
|
||||
@@ -775,6 +840,56 @@ async function main() {
|
||||
})));
|
||||
|
||||
console.log(`${reset ? 'Reset and seeded' : 'Seeded'} ${demoTickets.length} demo tickets across 4 queues`);
|
||||
|
||||
// ── Dashboard seeding ──
|
||||
const dashboardViews = [
|
||||
{ name: 'Open tickets', filters: [{ field: 'status', operator: 'is', value: 'open' }] },
|
||||
{ name: 'My tickets', filters: [{ field: 'owner', operator: 'is', value: userIds.dispatcher }] },
|
||||
{ name: 'Unassigned', filters: [{ field: 'owner', operator: 'is', value: 'unassigned' }] },
|
||||
{ name: 'All tickets', filters: [] },
|
||||
];
|
||||
|
||||
const viewRecords: Record<string, string> = {};
|
||||
for (const v of dashboardViews) {
|
||||
const [row] = await db.insert(views).values({
|
||||
name: v.name,
|
||||
filters: v.filters,
|
||||
is_public: true,
|
||||
}).returning();
|
||||
if (row) viewRecords[v.name] = row.id;
|
||||
}
|
||||
|
||||
const [dashboard] = await db.insert(dashboards).values({
|
||||
name: 'Support overview',
|
||||
description: 'Daily support team dashboard',
|
||||
is_default: true,
|
||||
}).returning();
|
||||
|
||||
if (dashboard) {
|
||||
const widgetDefs = [
|
||||
{ view: 'Open tickets', type: 'count', title: 'Open tickets', x: 0, y: 0, w: 3, h: 1 },
|
||||
{ view: 'My tickets', type: 'count', title: 'My tickets', x: 3, y: 0, w: 3, h: 1 },
|
||||
{ view: 'Unassigned', type: 'count', title: 'Unassigned', x: 6, y: 0, w: 3, h: 1 },
|
||||
{ view: 'All tickets', type: 'count', title: 'Total tickets', x: 9, y: 0, w: 3, h: 1 },
|
||||
{ view: 'Open tickets', type: 'status_chart', title: 'Status breakdown', x: 0, y: 1, w: 4, h: 2 },
|
||||
{ view: 'Open tickets', type: 'ticket_list', title: 'Recent open', x: 4, y: 1, w: 5, h: 2, config: { limit: 5 } },
|
||||
{ view: 'All tickets', type: 'grouped_counts', title: 'By queue', x: 9, y: 1, w: 3, h: 2, config: { group_by: 'queue' } },
|
||||
];
|
||||
|
||||
for (const w of widgetDefs) {
|
||||
await db.insert(dashboardWidgets).values({
|
||||
dashboard_id: dashboard.id,
|
||||
view_id: viewRecords[w.view],
|
||||
title: w.title,
|
||||
widget_type: w.type,
|
||||
position: { x: w.x, y: w.y, w: w.w, h: w.h },
|
||||
config: w.config ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded dashboard "${dashboard.name}" with ${widgetDefs.length} widgets`);
|
||||
}
|
||||
|
||||
console.log('Demo data ready');
|
||||
} finally {
|
||||
await pool.end();
|
||||
|
||||
250
src/email/mailtm.ts
Normal file
250
src/email/mailtm.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { config } from '../config.ts';
|
||||
import type { InboundEmail } from './types.ts';
|
||||
import type { EmailProcessor } from './processor.ts';
|
||||
|
||||
interface MailTmAccount {
|
||||
id: string;
|
||||
address: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface MailTmMessage {
|
||||
id: string;
|
||||
msgid: string;
|
||||
from: {
|
||||
address: string;
|
||||
name: string;
|
||||
};
|
||||
to: Array<{
|
||||
address: string;
|
||||
name: string;
|
||||
}>;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
seen: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://api.mail.tm';
|
||||
|
||||
/**
|
||||
* mail.tm transport: creates a disposable inbox, polls for new messages,
|
||||
* and feeds them to the EmailProcessor.
|
||||
*/
|
||||
export class MailTmTransport {
|
||||
private account: MailTmAccount | null = null;
|
||||
private processor: EmailProcessor | null = null;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(processor: EmailProcessor) {
|
||||
this.processor = processor;
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
if (this.running) {
|
||||
return this.account?.address ?? '';
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
// If account cached in env, reuse it
|
||||
if (config.MAILTM_ACCOUNT_ID && config.MAILTM_TOKEN) {
|
||||
this.account = {
|
||||
id: config.MAILTM_ACCOUNT_ID,
|
||||
address: config.MAILTM_ADDRESS ?? 'unknown@mail.tm',
|
||||
password: '', // not needed when reusing token
|
||||
token: config.MAILTM_TOKEN,
|
||||
};
|
||||
console.log(`[mailtm] Reusing cached inbox: ${this.account.address}`);
|
||||
} else {
|
||||
this.account = await this.createAccount();
|
||||
console.log(`[mailtm] Created test inbox: ${this.account.address}`);
|
||||
console.log(`[mailtm] To persist this inbox, set in .env:`);
|
||||
console.log(` MAILTM_ADDRESS=${this.account.address}`);
|
||||
console.log(` MAILTM_ACCOUNT_ID=${this.account.id}`);
|
||||
console.log(` MAILTM_TOKEN=${this.account.token}`);
|
||||
}
|
||||
|
||||
// Start polling
|
||||
const seconds = config.MAILTM_POLL_SECONDS;
|
||||
console.log(`[mailtm] Polling every ${seconds}s — use this address: ${this.account.address}`);
|
||||
this.poll(); // immediate first poll
|
||||
this.intervalId = setInterval(() => this.poll(), seconds * 1000);
|
||||
|
||||
return this.account.address;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
console.log('[mailtm] Stopped');
|
||||
}
|
||||
|
||||
private async createAccount(): Promise<MailTmAccount> {
|
||||
// Get available domains first (mail.tm rotates domains)
|
||||
let domain = 'mail.tm';
|
||||
try {
|
||||
const domainResp = await fetch(`${API_BASE}/domains`);
|
||||
if (domainResp.ok) {
|
||||
const data = await domainResp.json() as { 'hydra:member': Array<{ domain: string }> };
|
||||
if (data['hydra:member']?.length > 0) {
|
||||
domain = data['hydra:member'][0]!.domain;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to default domain
|
||||
}
|
||||
|
||||
const username = `tessera-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const password = crypto.randomUUID();
|
||||
|
||||
const resp = await fetch(`${API_BASE}/accounts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: `${username}@${domain}`, password }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
throw new Error(`mail.tm account creation failed: HTTP ${resp.status} ${body}`);
|
||||
}
|
||||
|
||||
const account = (await resp.json()) as { id: string; address: string };
|
||||
|
||||
// Get token
|
||||
const tokenResp = await fetch(`${API_BASE}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: account.address, password }),
|
||||
});
|
||||
|
||||
if (!tokenResp.ok) {
|
||||
throw new Error(`mail.tm token request failed: HTTP ${tokenResp.status}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResp.json()) as { token: string };
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
address: account.address,
|
||||
password,
|
||||
token: tokenData.token,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchMessages(): Promise<MailTmMessage[]> {
|
||||
if (!this.account?.token) return [];
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/messages`, {
|
||||
headers: { Authorization: `Bearer ${this.account.token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
// Token might have expired, try to refresh
|
||||
if (resp.status === 401) {
|
||||
try {
|
||||
this.account = await this.createAccount();
|
||||
return [];
|
||||
} catch {
|
||||
console.error('[mailtm] Failed to refresh account');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as { 'hydra:member': MailTmMessage[] };
|
||||
return (data['hydra:member'] ?? []).filter((m) => !m.seen);
|
||||
} catch (err) {
|
||||
console.error('[mailtm] Error fetching messages:', err instanceof Error ? err.message : String(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getFullMessage(messageId: string): Promise<MailTmMessage | null> {
|
||||
if (!this.account?.token) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/messages/${messageId}`, {
|
||||
headers: { Authorization: `Bearer ${this.account.token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as MailTmMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async markRead(messageId: string): Promise<void> {
|
||||
if (!this.account?.token) return;
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/messages/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.account.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ seen: true }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.running || !this.processor) return;
|
||||
|
||||
const messages = await this.fetchMessages();
|
||||
if (messages.length === 0) return;
|
||||
|
||||
console.log(`[mailtm] Found ${messages.length} new message(s)`);
|
||||
|
||||
for (const summary of messages) {
|
||||
const full = await this.getFullMessage(summary.id);
|
||||
if (!full) continue;
|
||||
|
||||
// Build the from display string
|
||||
const fromName = full.from?.name ?? '';
|
||||
const fromAddr = full.from?.address ?? '';
|
||||
const fromDisplay = fromName ? `${fromName} <${fromAddr}>` : fromAddr;
|
||||
|
||||
// Build the to display string
|
||||
const toDisplay = (full.to ?? [])
|
||||
.map((t) => (t.name ? `${t.name} <${t.address}>` : t.address))
|
||||
.join(', ');
|
||||
|
||||
const inbound: InboundEmail = {
|
||||
from: fromDisplay,
|
||||
fromAddress: fromAddr,
|
||||
to: toDisplay || this.account!.address,
|
||||
subject: full.subject ?? '(no subject)',
|
||||
bodyText: full.text ?? '',
|
||||
bodyHtml: full.html,
|
||||
attachments: [],
|
||||
messageId: full.msgid ?? full.id,
|
||||
receivedAt: new Date(full.createdAt ?? Date.now()),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.processor.process(inbound);
|
||||
console.log(`[mailtm] Processed: ${result.action} — ${result.detail}`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[mailtm] Error processing message ${summary.id}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
await this.markRead(summary.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/email/processor.ts
Normal file
172
src/email/processor.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, queues, lifecycles } from '../db/schema.ts';
|
||||
import { ScripEngine } from '../scrip/engine.ts';
|
||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||
import { resolveUser, resolveQueue, matchTicket } from './resolvers.ts';
|
||||
import type { InboundEmail, ProcessResult } from './types.ts';
|
||||
|
||||
/** Tracks recently seen message IDs for dedup */
|
||||
const seenMessageIds = new Set<string>();
|
||||
const MAX_SEEN_IDS = 2000;
|
||||
|
||||
function isSeen(messageId: string): boolean {
|
||||
if (seenMessageIds.has(messageId)) return true;
|
||||
seenMessageIds.add(messageId);
|
||||
// Prune oldest entries if set grows too large
|
||||
if (seenMessageIds.size > MAX_SEEN_IDS) {
|
||||
const iter = seenMessageIds.values();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const { value, done } = iter.next();
|
||||
if (done) break;
|
||||
seenMessageIds.delete(value!);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the plain from-address from a From header.
|
||||
* Handles "Alice <alice@example.com>" and plain "alice@example.com".
|
||||
*/
|
||||
function parseAddress(raw: string): string {
|
||||
const match = raw.match(/<([^>]+)>/);
|
||||
if (match) return match[1]!.trim().toLowerCase();
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class EmailProcessor {
|
||||
private db: Db;
|
||||
private scripEngine: ScripEngine;
|
||||
private lifecycleValidator: LifecycleValidator;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.db = db;
|
||||
this.scripEngine = new ScripEngine(db);
|
||||
this.lifecycleValidator = new LifecycleValidator();
|
||||
}
|
||||
|
||||
async process(email: InboundEmail): Promise<ProcessResult> {
|
||||
// Dedup by messageId
|
||||
if (isSeen(email.messageId)) {
|
||||
console.log(`[email] Skipping duplicate message: ${email.messageId}`);
|
||||
return { action: 'skipped', detail: `Duplicate messageId: ${email.messageId}` };
|
||||
}
|
||||
|
||||
const fromAddress = parseAddress(email.from);
|
||||
const toAddress = parseAddress(email.to);
|
||||
|
||||
// Resolve user
|
||||
const { userId, isNew } = await resolveUser(this.db, fromAddress);
|
||||
if (isNew) {
|
||||
console.log(`[email] Created stub user for ${fromAddress}`);
|
||||
}
|
||||
|
||||
// Resolve queue
|
||||
const { queueId, queueName } = await resolveQueue(this.db, toAddress);
|
||||
console.log(`[email] Routed to queue "${queueName}"`);
|
||||
|
||||
// Match or create ticket
|
||||
const matchedTicket = await matchTicket(this.db, email.subject);
|
||||
|
||||
if (matchedTicket) {
|
||||
// Reply: add Correspond transaction
|
||||
const [tx] = await this.db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
ticket_id: matchedTicket.id,
|
||||
transaction_type: 'Correspond',
|
||||
data: {
|
||||
body: email.bodyText || email.bodyHtml || '(no body)',
|
||||
from: fromAddress,
|
||||
message_id: email.messageId,
|
||||
},
|
||||
creator_id: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!tx) {
|
||||
return { action: 'skipped', detail: 'Failed to create transaction' };
|
||||
}
|
||||
|
||||
// Update ticket timestamp
|
||||
await this.db
|
||||
.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, matchedTicket.id));
|
||||
|
||||
// Fire scrips (e.g. OnCorrespond → auto-reply)
|
||||
const prepared = await this.scripEngine.prepare(matchedTicket.id, [tx] as any);
|
||||
const results = await this.scripEngine.commit(prepared);
|
||||
|
||||
console.log(
|
||||
`[email] Reply on ticket ${matchedTicket.id}, scrips: ${results.length} (${results.map((r) => r.message).join(', ')})`,
|
||||
);
|
||||
return { action: 'replied', ticketId: matchedTicket.id, detail: `Correspond on ticket ${matchedTicket.id}` };
|
||||
}
|
||||
|
||||
// New ticket
|
||||
const queue = await this.db.query.queues.findFirst({
|
||||
where: eq(queues.id, queueId),
|
||||
});
|
||||
|
||||
let initialStatus = 'new';
|
||||
if (queue?.lifecycle_id) {
|
||||
const lifecycle = await this.db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.id, queue.lifecycle_id),
|
||||
});
|
||||
const definition = lifecycle?.definition as LifecycleDefinition | undefined;
|
||||
initialStatus = definition?.statuses.initial[0] ?? initialStatus;
|
||||
}
|
||||
|
||||
const [ticket] = await this.db
|
||||
.insert(tickets)
|
||||
.values({
|
||||
subject: email.subject,
|
||||
queue_id: queueId,
|
||||
status: initialStatus,
|
||||
creator_id: userId,
|
||||
team_id: (queue as any)?.team_id ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!ticket) {
|
||||
return { action: 'skipped', detail: 'Failed to create ticket' };
|
||||
}
|
||||
|
||||
// Create transaction + correspond in one batch
|
||||
const txList = [
|
||||
{
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: initialStatus,
|
||||
creator_id: userId,
|
||||
},
|
||||
{
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Correspond',
|
||||
field: null,
|
||||
new_value: null,
|
||||
data: {
|
||||
body: email.bodyText || email.bodyHtml || '(no body)',
|
||||
from: fromAddress,
|
||||
message_id: email.messageId,
|
||||
},
|
||||
creator_id: userId,
|
||||
},
|
||||
];
|
||||
|
||||
await this.db.insert(transactions).values(txList as any);
|
||||
|
||||
// Fire scrips on TransactionBatch (OnCreate + OnCorrespond)
|
||||
const prepared = await this.scripEngine.prepare(ticket.id, txList as any, 'TransactionBatch');
|
||||
const results = await this.scripEngine.commit(prepared);
|
||||
|
||||
console.log(
|
||||
`[email] Created ticket ${ticket.id} in "${queueName}", scrips: ${results.length} (${results.map((r) => r.message).join(', ')})`,
|
||||
);
|
||||
return { action: 'created', ticketId: ticket.id, detail: `Ticket ${ticket.id} created in ${queueName}` };
|
||||
}
|
||||
}
|
||||
128
src/email/resolvers.ts
Normal file
128
src/email/resolvers.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { eq, ilike, and } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users, queues, tickets } from '../db/schema.ts';
|
||||
|
||||
/**
|
||||
* Resolve a sender email address to a user record.
|
||||
* Creates a stub "unverified" user if no match is found.
|
||||
*/
|
||||
export async function resolveUser(
|
||||
db: Db,
|
||||
fromAddress: string,
|
||||
): Promise<{ userId: string; isNew: boolean }> {
|
||||
// Try exact match first
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.email, fromAddress),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { userId: existing.id, isNew: false };
|
||||
}
|
||||
|
||||
// Create a stub user with role 'unverified'
|
||||
const username = fromAddress.replace(/@/g, '-at-').replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
const [stub] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username,
|
||||
email: fromAddress,
|
||||
role: 'unverified',
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!stub) {
|
||||
throw new Error(`Failed to create stub user for ${fromAddress}`);
|
||||
}
|
||||
|
||||
return { userId: stub.id, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the "to" address to a queue.
|
||||
* Strategy:
|
||||
* 1. Check for exact mail_alias match on any queue
|
||||
* 2. Check if the local-part matches a queue name
|
||||
* 3. Fallback to the first available queue
|
||||
*/
|
||||
export async function resolveQueue(
|
||||
db: Db,
|
||||
toAddress: string,
|
||||
): Promise<{ queueId: string; queueName: string }> {
|
||||
const localPart = toAddress.split('@')[0]?.toLowerCase() ?? '';
|
||||
const fullAddress = toAddress.trim().toLowerCase();
|
||||
|
||||
// 1. Check mail_alias exact match
|
||||
if (fullAddress) {
|
||||
const byAlias = await db.query.queues.findFirst({
|
||||
where: eq(queues.mail_alias, fullAddress),
|
||||
});
|
||||
if (byAlias) {
|
||||
return { queueId: byAlias.id, queueName: byAlias.name };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check queue name matching the local-part
|
||||
if (localPart) {
|
||||
const byName = await db.query.queues.findFirst({
|
||||
where: eq(queues.name, localPart),
|
||||
});
|
||||
if (byName) {
|
||||
return { queueId: byName.id, queueName: byName.name };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to first queue
|
||||
const allQueues = await db.query.queues.findMany({ limit: 1 });
|
||||
const fallback = allQueues[0];
|
||||
if (fallback) {
|
||||
return { queueId: fallback.id, queueName: fallback.name };
|
||||
}
|
||||
|
||||
throw new Error('No queues exist — cannot route inbound email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan subject for ticket ID patterns.
|
||||
* Supports: TKT-XXXX, TKT-XXXXX, #NNN, [queue-name #NNN]
|
||||
*
|
||||
* Returns the matched ticket if found and not in a closed state,
|
||||
* or null if no ticket was matched.
|
||||
*/
|
||||
export async function matchTicket(
|
||||
db: Db,
|
||||
subject: string,
|
||||
): Promise<{ id: number; subject: string; status: string } | null> {
|
||||
const trimmed = subject.trim();
|
||||
|
||||
// TKT-XXXX or TKT-XXXXX (Tessera display format)
|
||||
const tktMatch = trimmed.match(/\bTKT-(\d{4,5})\b/i);
|
||||
if (tktMatch) {
|
||||
const id = parseInt(tktMatch[1]!, 10);
|
||||
if (!isNaN(id)) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status };
|
||||
}
|
||||
}
|
||||
|
||||
// [queue-name #NNN] (RT-compatible bracket format)
|
||||
const bracketMatch = trimmed.match(/\[[\w-]+\s*#(\d+)\]/i);
|
||||
if (bracketMatch) {
|
||||
const id = parseInt(bracketMatch[1]!, 10);
|
||||
if (!isNaN(id)) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status };
|
||||
}
|
||||
}
|
||||
|
||||
// #NNN (shorthand, requires word boundary before #)
|
||||
const hashMatch = trimmed.match(/(?:^|\s)#(\d+)\b/);
|
||||
if (hashMatch) {
|
||||
const id = parseInt(hashMatch[1]!, 10);
|
||||
if (!isNaN(id)) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
24
src/email/types.ts
Normal file
24
src/email/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface InboundEmailAttachment {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
export interface InboundEmail {
|
||||
from: string; // "Alice <alice@example.com>"
|
||||
fromAddress: string; // "alice@example.com"
|
||||
to: string; // "support@mail.tm"
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
bodyHtml?: string;
|
||||
attachments: InboundEmailAttachment[];
|
||||
messageId: string; // for dedup
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
/** Result of processing one inbound email */
|
||||
export interface ProcessResult {
|
||||
action: 'created' | 'replied' | 'skipped';
|
||||
ticketId?: number;
|
||||
detail: string;
|
||||
}
|
||||
70
src/index.ts
70
src/index.ts
@@ -4,6 +4,7 @@ import { createDb } from './db/index.ts';
|
||||
import type { Db } from './db/index.ts';
|
||||
import { errorHandler } from './middleware/error.ts';
|
||||
import { requestLogger } from './middleware/logging.ts';
|
||||
import { createAuthMiddleware } from './auth/middleware.ts';
|
||||
import healthRouter from './routes/health.ts';
|
||||
import { createTicketsRouter } from './routes/tickets.ts';
|
||||
import { createQueuesRouter } from './routes/queues.ts';
|
||||
@@ -12,6 +13,18 @@ import { createCustomFieldsRouter } from './routes/custom-fields.ts';
|
||||
import { createLifecyclesRouter } from './routes/lifecycles.ts';
|
||||
import { createUsersRouter } from './routes/users.ts';
|
||||
import { createTemplatesRouter } from './routes/templates.ts';
|
||||
import { createViewsRouter } from './routes/views.ts';
|
||||
import { createDashboardsRouter } from './routes/dashboards.ts';
|
||||
import { createTeamsRouter } from './routes/teams.ts';
|
||||
import { createAttachmentsRouter } from './routes/attachments.ts';
|
||||
import { createAuthRouter } from './routes/auth.ts';
|
||||
import { createQueuePermissionsRouter } from './routes/queue-permissions.ts';
|
||||
import { createSlaPoliciesRouter } from './routes/sla-policies.ts';
|
||||
import { createNotificationsRouter } from './routes/notifications.ts';
|
||||
import { createMailgateRouter } from './routes/mailgate.ts';
|
||||
import { startScheduler } from './scrip/scheduler.ts';
|
||||
import { EmailProcessor } from './email/processor.ts';
|
||||
import { MailTmTransport } from './email/mailtm.ts';
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
@@ -27,14 +40,46 @@ const app = new Hono();
|
||||
app.use('*', requestLogger);
|
||||
app.onError(errorHandler);
|
||||
|
||||
const { requireAuth, requireAdmin } = createAuthMiddleware(getDb());
|
||||
|
||||
// Email processor (shared between transport and webhook)
|
||||
const emailProcessor = new EmailProcessor(getDb());
|
||||
|
||||
// Public routes
|
||||
app.route('/health', healthRouter);
|
||||
app.route('/tickets', createTicketsRouter(getDb()));
|
||||
app.route('/queues', createQueuesRouter(getDb()));
|
||||
app.route('/scrips', createScripsRouter(getDb()));
|
||||
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||
app.route('/users', createUsersRouter(getDb()));
|
||||
app.route('/templates', createTemplatesRouter(getDb()));
|
||||
app.route('/', createAuthRouter(getDb()));
|
||||
|
||||
// Mailgate webhook — public endpoint for receiving inbound emails
|
||||
app.route('/mailgate', createMailgateRouter(getDb(), emailProcessor));
|
||||
|
||||
// Ticket routes — require authentication
|
||||
const ticketsWithAuth = new Hono();
|
||||
ticketsWithAuth.use('*', requireAuth);
|
||||
ticketsWithAuth.route('/tickets', createTicketsRouter(getDb()));
|
||||
ticketsWithAuth.route('/', createNotificationsRouter(getDb()));
|
||||
app.route('/', ticketsWithAuth);
|
||||
|
||||
// Attachment serving — require authentication
|
||||
const attachmentsWithAuth = new Hono();
|
||||
attachmentsWithAuth.use('*', requireAuth);
|
||||
attachmentsWithAuth.route('/', createAttachmentsRouter(getDb()));
|
||||
app.route('/', attachmentsWithAuth);
|
||||
|
||||
// Admin routes — require admin role
|
||||
const admin = new Hono();
|
||||
admin.use('*', requireAdmin);
|
||||
admin.route('/queues', createQueuesRouter(getDb()));
|
||||
admin.route('/scrips', createScripsRouter(getDb()));
|
||||
admin.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||
admin.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||
admin.route('/users', createUsersRouter(getDb()));
|
||||
admin.route('/templates', createTemplatesRouter(getDb()));
|
||||
admin.route('/views', createViewsRouter(getDb()));
|
||||
admin.route('/dashboards', createDashboardsRouter(getDb()));
|
||||
admin.route('/teams', createTeamsRouter(getDb()));
|
||||
admin.route('/sla-policies', createSlaPoliciesRouter(getDb()));
|
||||
admin.route('/', createQueuePermissionsRouter(getDb()));
|
||||
app.route('/', admin);
|
||||
|
||||
export default app;
|
||||
export { app };
|
||||
@@ -48,4 +93,15 @@ if (Bun.main === import.meta.path) {
|
||||
development: false,
|
||||
});
|
||||
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
||||
|
||||
// Start the scrip scheduler (runs every 5 minutes)
|
||||
startScheduler(getDb());
|
||||
|
||||
// Start inbound email transport
|
||||
if (config.MAIL_TRANSPORT === 'mailtm') {
|
||||
const transport = new MailTmTransport(emailProcessor);
|
||||
transport.start().catch((err) => {
|
||||
console.error('[email] Failed to start mail.tm transport:', err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ export interface LifecycleDefinition {
|
||||
inactive: string[];
|
||||
};
|
||||
transitions: Record<string, string[]>;
|
||||
transition_rights?: Record<string, string>; // "from→to" → rightName
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
requiredRight?: string; // Named right required for this transition, if any
|
||||
}
|
||||
|
||||
const FALLBACK_RIGHT = 'ticket.modify';
|
||||
|
||||
export class LifecycleValidator {
|
||||
validateTransition(
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
@@ -35,13 +39,15 @@ export class LifecycleValidator {
|
||||
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
|
||||
|
||||
if (allowedTransitions.includes(toStatus)) {
|
||||
return { valid: true };
|
||||
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
|
||||
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
|
||||
}
|
||||
|
||||
// Also handle wildcard "*" -> any transition
|
||||
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
|
||||
if (wildcardTransitions.includes(toStatus)) {
|
||||
return { valid: true };
|
||||
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
|
||||
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -50,6 +56,37 @@ export class LifecycleValidator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required right for a transition using RT's 4-level priority:
|
||||
* 1. exact "from→to"
|
||||
* 2. wildcard from "*→to"
|
||||
* 3. wildcard to "from→*"
|
||||
* 4. full wildcard "*→*"
|
||||
* 5. fallback: ticket.modify
|
||||
*/
|
||||
getRequiredRight(
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
fromStatus: string,
|
||||
toStatus: string,
|
||||
): string | null {
|
||||
const rights = lifecycleDef.transition_rights ?? {};
|
||||
|
||||
// Priority 1: exact match
|
||||
if (rights[`${fromStatus}→${toStatus}`]) return rights[`${fromStatus}→${toStatus}`];
|
||||
|
||||
// Priority 2: wildcard from
|
||||
if (rights[`*→${toStatus}`]) return rights[`*→${toStatus}`];
|
||||
|
||||
// Priority 3: wildcard to
|
||||
if (rights[`${fromStatus}→*`]) return rights[`${fromStatus}→*`];
|
||||
|
||||
// Priority 4: full wildcard
|
||||
if (rights['*→*']) return rights['*→*'];
|
||||
|
||||
// Priority 5: fallback
|
||||
return null;
|
||||
}
|
||||
|
||||
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
|
||||
return lifecycleDef.statuses.inactive.includes(status);
|
||||
}
|
||||
@@ -58,16 +95,12 @@ export class LifecycleValidator {
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
fromStatus: string,
|
||||
): string[] {
|
||||
// Direct transition
|
||||
if (lifecycleDef.transitions[fromStatus]) {
|
||||
return lifecycleDef.transitions[fromStatus]!;
|
||||
}
|
||||
|
||||
// Wildcard transitions
|
||||
if (lifecycleDef.transitions['*']) {
|
||||
return lifecycleDef.transitions['*']!;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
157
src/models/custom-field-validation.ts
Normal file
157
src/models/custom-field-validation.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { CustomField } from './custom-field.ts';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch to the correct type-specific validator based on field.field_type.
|
||||
*/
|
||||
export function validateCustomFieldValue(
|
||||
field: CustomField,
|
||||
value: string,
|
||||
): ValidationResult {
|
||||
if (!value) return { valid: true };
|
||||
|
||||
const config = (field.validation_config ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Backward-compatible pattern check (field-level pattern, not validation_config)
|
||||
if (field.pattern) {
|
||||
try {
|
||||
const regex = new RegExp(field.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return { valid: false, error: 'Value does not match the required pattern' };
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex — skip
|
||||
}
|
||||
}
|
||||
|
||||
switch (field.field_type) {
|
||||
case 'Number':
|
||||
return validateNumberValue(value, config);
|
||||
case 'Date':
|
||||
return validateDateValue(value, config);
|
||||
case 'DateTime':
|
||||
return validateDateTimeValue(value, config);
|
||||
case 'SelectOne':
|
||||
case 'SelectMultiple':
|
||||
return validateSelectValue(value, field, config);
|
||||
case 'Text':
|
||||
case 'Textarea':
|
||||
return validateTextValue(value, config);
|
||||
default:
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
function validateNumberValue(
|
||||
value: string,
|
||||
config: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) {
|
||||
return { valid: false, error: 'Value must be a number' };
|
||||
}
|
||||
if (config.min !== undefined && num < Number(config.min)) {
|
||||
return { valid: false, error: `Value must be at least ${config.min}` };
|
||||
}
|
||||
if (config.max !== undefined && num > Number(config.max)) {
|
||||
return { valid: false, error: `Value must be at most ${config.max}` };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validateDateValue(
|
||||
value: string,
|
||||
config: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
// Accept YYYY-MM-DD format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return { valid: false, error: 'Value must be a date in YYYY-MM-DD format' };
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return { valid: false, error: 'Invalid date' };
|
||||
}
|
||||
if (config.min_date) {
|
||||
const minDate = new Date(String(config.min_date));
|
||||
if (!isNaN(minDate.getTime()) && parsed < minDate) {
|
||||
return { valid: false, error: `Date must be on or after ${config.min_date}` };
|
||||
}
|
||||
}
|
||||
if (config.max_date) {
|
||||
const maxDate = new Date(String(config.max_date));
|
||||
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
|
||||
return { valid: false, error: `Date must be on or before ${config.max_date}` };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validateDateTimeValue(
|
||||
value: string,
|
||||
config: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return { valid: false, error: 'Value must be a valid ISO 8601 datetime' };
|
||||
}
|
||||
if (config.min_date) {
|
||||
const minDate = new Date(String(config.min_date));
|
||||
if (!isNaN(minDate.getTime()) && parsed < minDate) {
|
||||
return { valid: false, error: `Datetime must be on or after ${config.min_date}` };
|
||||
}
|
||||
}
|
||||
if (config.max_date) {
|
||||
const maxDate = new Date(String(config.max_date));
|
||||
if (!isNaN(maxDate.getTime()) && parsed > maxDate) {
|
||||
return { valid: false, error: `Datetime must be on or before ${config.max_date}` };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validateSelectValue(
|
||||
value: string,
|
||||
field: CustomField,
|
||||
config: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
const allowed: string[] = [];
|
||||
|
||||
// Check validation_config.options first, then fall back to field.values
|
||||
if (Array.isArray(config.options)) {
|
||||
allowed.push(...config.options.map(String));
|
||||
} else if (Array.isArray(field.values)) {
|
||||
allowed.push(...field.values.map(String));
|
||||
}
|
||||
|
||||
if (allowed.length > 0 && !allowed.includes(value)) {
|
||||
return { valid: false, error: `Value is not an allowed option. Allowed: ${allowed.join(', ')}` };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function validateTextValue(
|
||||
value: string,
|
||||
config: Record<string, unknown>,
|
||||
): ValidationResult {
|
||||
if (config.min_length !== undefined && value.length < Number(config.min_length)) {
|
||||
return { valid: false, error: `Value must be at least ${config.min_length} characters` };
|
||||
}
|
||||
if (config.max_length !== undefined && value.length > Number(config.max_length)) {
|
||||
return { valid: false, error: `Value must be at most ${config.max_length} characters` };
|
||||
}
|
||||
if (config.pattern && typeof config.pattern === 'string') {
|
||||
try {
|
||||
const regex = new RegExp(config.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return { valid: false, error: 'Value does not match the required pattern' };
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex — skip validation
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
@@ -1,13 +1,71 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { customFields } from '../db/schema.ts';
|
||||
|
||||
export type CustomField = InferSelectModel<typeof customFields>;
|
||||
|
||||
export const CustomFieldType = {
|
||||
Text: 'Text',
|
||||
Textarea: 'Textarea',
|
||||
SelectOne: 'SelectOne',
|
||||
SelectMultiple: 'SelectMultiple',
|
||||
Text: 'Text',
|
||||
Date: 'Date',
|
||||
DateTime: 'DateTime',
|
||||
Number: 'Number',
|
||||
} as const;
|
||||
|
||||
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];
|
||||
|
||||
export const CUSTOM_FIELD_TYPES = Object.values(CustomFieldType) as [string, ...string[]];
|
||||
|
||||
// Validation config per type
|
||||
export const NumberValidationConfig = z.object({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
}).optional();
|
||||
|
||||
export const DateValidationConfig = z.object({
|
||||
min_date: z.string().optional(),
|
||||
max_date: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
export const DateTimeValidationConfig = z.object({
|
||||
min_date: z.string().optional(),
|
||||
max_date: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
export const TextValidationConfig = z.object({
|
||||
min_length: z.number().int().min(0).optional(),
|
||||
max_length: z.number().int().min(0).optional(),
|
||||
pattern: z.string().optional(),
|
||||
}).optional();
|
||||
|
||||
export const SelectValidationConfig = z.object({
|
||||
options: z.array(z.string()).optional(),
|
||||
}).optional();
|
||||
|
||||
// Generic validation config — permissive at the API boundary, refined per type in validation
|
||||
export const CustomFieldValidationConfig = z.record(z.unknown()).nullable().default(null);
|
||||
|
||||
// Schemas for create/update
|
||||
export const CreateCustomFieldSchema = z.object({
|
||||
key: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]),
|
||||
values: z.unknown().nullable().optional(),
|
||||
max_values: z.number().int().min(1).optional().default(1),
|
||||
pattern: z.string().nullable().optional(),
|
||||
validation_config: z.record(z.unknown()).nullable().optional(),
|
||||
default_value: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const UpdateCustomFieldSchema = z.object({
|
||||
key: z.string().optional(),
|
||||
name: z.string().min(1).optional(),
|
||||
field_type: z.enum(CUSTOM_FIELD_TYPES as [string, ...string[]]).optional(),
|
||||
values: z.unknown().nullable().optional(),
|
||||
max_values: z.number().int().min(1).optional(),
|
||||
pattern: z.string().nullable().optional(),
|
||||
validation_config: z.record(z.unknown()).nullable().optional(),
|
||||
default_value: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -15,10 +15,13 @@ export const UpdateTicketSchema = z.object({
|
||||
subject: z.string().min(1).optional(),
|
||||
status: z.string().min(1).optional(),
|
||||
owner_id: z.string().uuid().nullable().optional(),
|
||||
team_id: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
|
||||
export const CommentSchema = z.object({
|
||||
body: z.string().min(1),
|
||||
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
|
||||
internal: z.boolean().optional().default(false),
|
||||
attachment_ids: z.array(z.string()).optional(),
|
||||
time_worked_minutes: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ export const TransactionType = {
|
||||
Comment: 'Comment',
|
||||
CustomField: 'CustomField',
|
||||
Correspond: 'Correspond',
|
||||
LinkCreate: 'LinkCreate',
|
||||
LinkDelete: 'LinkDelete',
|
||||
} as const;
|
||||
|
||||
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];
|
||||
|
||||
190
src/routes/attachments.ts
Normal file
190
src/routes/attachments.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
import { writeFile, unlink } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { config } from '../config.ts';
|
||||
import { transactionAttachments, transactions, tickets } from '../db/schema.ts';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function storageDir(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dir = join(config.UPLOAD_DIR, year, month);
|
||||
ensureDir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.zip': 'application/zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avif': 'image/avif',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.log': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.yaml': 'text/yaml',
|
||||
'.yml': 'text/yaml',
|
||||
};
|
||||
|
||||
function guessMimeType(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
return MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function createAttachmentsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// POST /tickets/:id/attachments — upload files (returns metadata, no transaction created yet)
|
||||
router.post('/tickets/:id/attachments', async (c) => {
|
||||
const ticketId = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const files = formData.getAll('files') as File[];
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new HTTPException(422, { message: 'No files provided' });
|
||||
}
|
||||
|
||||
const dir = storageDir();
|
||||
const result: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
}> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
|
||||
const ext = extname(file.name);
|
||||
const storedName = `${randomUUID()}${ext}`;
|
||||
const storagePath = join(dir, storedName);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(storagePath, buffer);
|
||||
|
||||
const [saved] = await db.insert(transactionAttachments).values({
|
||||
filename: file.name,
|
||||
mime_type: file.type || guessMimeType(file.name),
|
||||
size_bytes: buffer.length,
|
||||
storage_path: storagePath,
|
||||
}).returning();
|
||||
|
||||
if (saved) {
|
||||
result.push({
|
||||
id: saved.id,
|
||||
filename: saved.filename,
|
||||
mime_type: saved.mime_type,
|
||||
size_bytes: saved.size_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ attachments: result }, 201);
|
||||
});
|
||||
|
||||
// GET /attachments/:id — serve/download an attachment
|
||||
router.get('/attachments/:id', async (c) => {
|
||||
const attachmentId = c.req.param('id');
|
||||
|
||||
const attachment = await db.query.transactionAttachments.findFirst({
|
||||
where: eq(transactionAttachments.id, attachmentId),
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new HTTPException(404, { message: 'Attachment not found' });
|
||||
}
|
||||
|
||||
if (!existsSync(attachment.storage_path)) {
|
||||
throw new HTTPException(404, { message: 'Attachment file not found on disk' });
|
||||
}
|
||||
|
||||
const disposition = c.req.query('download') === 'true' ? 'attachment' : 'inline';
|
||||
const stream = createReadStream(attachment.storage_path);
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': attachment.mime_type,
|
||||
'Content-Disposition': `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`,
|
||||
'Content-Length': String(attachment.size_bytes),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /tickets/:id/attachments — list attachments for a ticket
|
||||
router.get('/tickets/:id/attachments', async (c) => {
|
||||
const ticketId = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const ticketTransactions = await db.query.transactions.findMany({
|
||||
where: eq(transactions.ticket_id, ticketId),
|
||||
});
|
||||
|
||||
const txIds = ticketTransactions.map((tx) => tx.id);
|
||||
if (txIds.length === 0) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const attachments = await Promise.all(
|
||||
txIds.map((txId) =>
|
||||
db.query.transactionAttachments.findMany({
|
||||
where: eq(transactionAttachments.transaction_id, txId),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return c.json(attachments.flat());
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
132
src/routes/auth.ts
Normal file
132
src/routes/auth.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { z } from 'zod/v4';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users, apiTokens } from '../db/schema.ts';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { createToken, createAuthMiddleware } from '../auth/middleware.ts';
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export function createAuthRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
const { requireAuth } = createAuthMiddleware(db);
|
||||
|
||||
// POST /auth/login
|
||||
router.post('/auth/login', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = LoginSchema.parse(body);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, parsed.username),
|
||||
});
|
||||
|
||||
if (!user || !user.password_hash) {
|
||||
throw new HTTPException(401, { message: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const valid = await Bun.password.verify(parsed.password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new HTTPException(401, { message: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const token = await createToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /auth/me — return current user from token
|
||||
router.get('/auth/me', requireAuth, async (c) => {
|
||||
const authUser = c.get('user');
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, authUser.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /auth/tokens — create API token
|
||||
router.post('/auth/tokens', requireAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name || 'API token').trim();
|
||||
const authUser = c.get('user');
|
||||
|
||||
const rawToken = `tessera_${crypto.randomUUID().replace(/-/g, '')}`;
|
||||
const tokenHash = await Bun.password.hash(rawToken);
|
||||
|
||||
const [token] = await db.insert(apiTokens).values({
|
||||
user_id: authUser.userId,
|
||||
name,
|
||||
token_hash: tokenHash,
|
||||
}).returning();
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(500, { message: 'Failed to create token' });
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
token: rawToken,
|
||||
created_at: token.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// GET /auth/tokens — list tokens
|
||||
router.get('/auth/tokens', requireAuth, async (c) => {
|
||||
const authUser = c.get('user');
|
||||
const result = await db.query.apiTokens.findMany({
|
||||
where: eq(apiTokens.user_id, authUser.userId),
|
||||
orderBy: desc(apiTokens.created_at),
|
||||
});
|
||||
return c.json(result.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
last_used_at: t.last_used_at,
|
||||
created_at: t.created_at,
|
||||
})));
|
||||
});
|
||||
|
||||
// DELETE /auth/tokens/:id — revoke token
|
||||
router.delete('/auth/tokens/:id', requireAuth, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const authUser = c.get('user');
|
||||
|
||||
// Verify ownership before revoke
|
||||
const allTokens = await db.query.apiTokens.findMany();
|
||||
const existing = allTokens.find((t) => t.id === id && t.user_id === authUser.userId);
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Token not found' });
|
||||
}
|
||||
|
||||
// Raw delete to avoid Drizzle type issue with new apiTokens table
|
||||
await db.execute(sql`DELETE FROM api_tokens WHERE id = ${id}`);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { customFields, queueCustomFields } from '../db/schema.ts';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { CreateCustomFieldSchema, UpdateCustomFieldSchema } from '../models/custom-field.ts';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
function makeFieldKey(value: string): string {
|
||||
const key = value
|
||||
@@ -13,6 +15,10 @@ function makeFieldKey(value: string): string {
|
||||
return key || 'field';
|
||||
}
|
||||
|
||||
function formatZodError(err: ZodError): string {
|
||||
return err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
|
||||
}
|
||||
|
||||
export function createCustomFieldsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
@@ -25,20 +31,28 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, field_type, values, max_values, pattern } = body;
|
||||
const key = makeFieldKey(String(body.key ?? name ?? ''));
|
||||
|
||||
if (!name || !field_type) {
|
||||
throw new HTTPException(400, { message: 'name and field_type are required' });
|
||||
let parsed;
|
||||
try {
|
||||
parsed = CreateCustomFieldSchema.parse(body);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
throw new HTTPException(400, { message: formatZodError(err) });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const key = makeFieldKey(String(parsed.key ?? parsed.name ?? ''));
|
||||
|
||||
const [cf] = await db.insert(customFields).values({
|
||||
key,
|
||||
name,
|
||||
field_type,
|
||||
values: values ?? null,
|
||||
max_values: max_values ?? 1,
|
||||
pattern: pattern ?? null,
|
||||
name: parsed.name,
|
||||
field_type: parsed.field_type,
|
||||
values: (parsed.values as any) ?? null,
|
||||
max_values: parsed.max_values ?? 1,
|
||||
pattern: parsed.pattern ?? null,
|
||||
validation_config: (parsed.validation_config as any) ?? null,
|
||||
default_value: parsed.default_value ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!cf) {
|
||||
@@ -52,6 +66,16 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = UpdateCustomFieldSchema.parse(body);
|
||||
} catch (err) {
|
||||
if (err instanceof ZodError) {
|
||||
throw new HTTPException(400, { message: formatZodError(err) });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existing = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.id, id),
|
||||
});
|
||||
@@ -61,12 +85,18 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof customFields.$inferInsert> = {};
|
||||
if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key));
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
if (body.field_type !== undefined) updateData.field_type = String(body.field_type);
|
||||
if (body.values !== undefined) updateData.values = body.values ?? null;
|
||||
if (body.max_values !== undefined) updateData.max_values = Number(body.max_values);
|
||||
if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null;
|
||||
if (parsed.key !== undefined) updateData.key = makeFieldKey(String(parsed.key));
|
||||
if (parsed.name !== undefined) updateData.name = String(parsed.name);
|
||||
if (parsed.field_type !== undefined) updateData.field_type = String(parsed.field_type);
|
||||
if (parsed.values !== undefined) updateData.values = parsed.values ?? null;
|
||||
if (parsed.max_values !== undefined) updateData.max_values = Number(parsed.max_values);
|
||||
if (parsed.pattern !== undefined) updateData.pattern = parsed.pattern ? String(parsed.pattern) : null;
|
||||
if (parsed.validation_config !== undefined) {
|
||||
updateData.validation_config = (parsed.validation_config as any) ?? null;
|
||||
}
|
||||
if (parsed.default_value !== undefined) {
|
||||
updateData.default_value = parsed.default_value ?? null;
|
||||
}
|
||||
|
||||
const [updated] = await db.update(customFields)
|
||||
.set(updateData)
|
||||
|
||||
465
src/routes/dashboards.ts
Normal file
465
src/routes/dashboards.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import {
|
||||
dashboards,
|
||||
dashboardWidgets,
|
||||
tickets,
|
||||
customFieldValues,
|
||||
customFields,
|
||||
lifecycles,
|
||||
queues,
|
||||
views,
|
||||
} from '../db/schema.ts';
|
||||
|
||||
function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string {
|
||||
if (def.statuses.initial.includes(status)) return 'initial';
|
||||
if (def.statuses.active.includes(status)) return 'active';
|
||||
if (def.statuses.inactive.includes(status)) return 'inactive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function createDashboardsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// ── Dashboards CRUD ──
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.dashboards.findMany({
|
||||
orderBy: asc(dashboards.name),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name ?? '').trim();
|
||||
if (!name) {
|
||||
throw new HTTPException(400, { message: 'name is required' });
|
||||
}
|
||||
|
||||
const [dashboard] = await db.insert(dashboards).values({
|
||||
name,
|
||||
description: body.description ?? null,
|
||||
team_id: body.team_id || null,
|
||||
layout: body.layout ?? [],
|
||||
is_default: body.is_default ?? false,
|
||||
}).returning();
|
||||
|
||||
if (!dashboard) {
|
||||
throw new HTTPException(500, { message: 'Failed to create dashboard' });
|
||||
}
|
||||
|
||||
return c.json(dashboard, 201);
|
||||
});
|
||||
|
||||
router.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const dashboard = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, id),
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
const widgets = await db.query.dashboardWidgets.findMany({
|
||||
where: eq(dashboardWidgets.dashboard_id, id),
|
||||
orderBy: asc(dashboardWidgets.created_at),
|
||||
});
|
||||
|
||||
return c.json({ ...dashboard, widgets });
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof dashboards.$inferInsert> = {};
|
||||
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||
if (body.layout !== undefined) updateData.layout = body.layout;
|
||||
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
if (body.is_default !== undefined) {
|
||||
updateData.is_default = body.is_default;
|
||||
if (body.is_default) {
|
||||
await db.update(dashboards)
|
||||
.set({ is_default: false })
|
||||
.where(eq(dashboards.is_default, true));
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db.update(dashboards)
|
||||
.set(updateData)
|
||||
.where(eq(dashboards.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const existing = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
await db.delete(dashboards).where(eq(dashboards.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Widgets CRUD ──
|
||||
|
||||
router.get('/:id/widgets', async (c) => {
|
||||
const dashboardId = c.req.param('id');
|
||||
const result = await db.query.dashboardWidgets.findMany({
|
||||
where: eq(dashboardWidgets.dashboard_id, dashboardId),
|
||||
orderBy: asc(dashboardWidgets.created_at),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/:id/widgets', async (c) => {
|
||||
const dashboardId = c.req.param('id');
|
||||
const dashboard = await db.query.dashboards.findFirst({
|
||||
where: eq(dashboards.id, dashboardId),
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const title = String(body.title ?? 'Widget').trim();
|
||||
const widgetType = String(body.widget_type ?? 'count').trim();
|
||||
const viewId = String(body.view_id ?? '').trim();
|
||||
|
||||
if (!viewId) {
|
||||
throw new HTTPException(400, { message: 'view_id is required' });
|
||||
}
|
||||
|
||||
const [widget] = await db.insert(dashboardWidgets).values({
|
||||
dashboard_id: dashboardId,
|
||||
view_id: viewId,
|
||||
title,
|
||||
widget_type: widgetType,
|
||||
position: body.position ?? { x: 0, y: 0, w: 4, h: 2 },
|
||||
config: body.config ?? {},
|
||||
}).returning();
|
||||
|
||||
if (!widget) {
|
||||
throw new HTTPException(500, { message: 'Failed to create widget' });
|
||||
}
|
||||
|
||||
return c.json(widget, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id/widgets/:widgetId', async (c) => {
|
||||
const widgetId = c.req.param('widgetId');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.dashboardWidgets.findFirst({
|
||||
where: eq(dashboardWidgets.id, widgetId),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Widget not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof dashboardWidgets.$inferInsert> = {};
|
||||
if (body.title !== undefined) updateData.title = String(body.title).trim();
|
||||
if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type);
|
||||
if (body.position !== undefined) updateData.position = body.position;
|
||||
if (body.config !== undefined) updateData.config = body.config;
|
||||
|
||||
const [updated] = await db.update(dashboardWidgets)
|
||||
.set(updateData)
|
||||
.where(eq(dashboardWidgets.id, widgetId))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id/widgets/:widgetId', async (c) => {
|
||||
const widgetId = c.req.param('widgetId');
|
||||
const existing = await db.query.dashboardWidgets.findFirst({
|
||||
where: eq(dashboardWidgets.id, widgetId),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Widget not found' });
|
||||
}
|
||||
|
||||
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Widget data endpoint ──
|
||||
|
||||
router.get('/:id/widgets/:widgetId/data', async (c) => {
|
||||
const widgetId = c.req.param('widgetId');
|
||||
|
||||
const widget = await db.query.dashboardWidgets.findFirst({
|
||||
where: eq(dashboardWidgets.id, widgetId),
|
||||
});
|
||||
|
||||
if (!widget) {
|
||||
throw new HTTPException(404, { message: 'Widget not found' });
|
||||
}
|
||||
|
||||
const view = await db.query.views.findFirst({
|
||||
where: eq(views.id, widget.view_id),
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
return c.json({ error: 'View not found' }, 404);
|
||||
}
|
||||
|
||||
// Apply saved view filters
|
||||
const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[];
|
||||
let result = await db.query.tickets.findMany({
|
||||
orderBy: asc(tickets.created_at),
|
||||
});
|
||||
|
||||
for (const f of savedFilters) {
|
||||
if (f.field === 'status') {
|
||||
result = result.filter((t) => t.status === f.value);
|
||||
} else if (f.field === 'queue') {
|
||||
result = result.filter((t) => t.queue_id === f.value);
|
||||
} else if (f.field === 'owner') {
|
||||
result = f.value === 'unassigned'
|
||||
? result.filter((t) => !t.owner_id)
|
||||
: result.filter((t) => t.owner_id === f.value);
|
||||
} else if (f.field.startsWith('cf.')) {
|
||||
const cfKey = f.field.slice(3);
|
||||
const ticketIds = result.map((t) => t.id);
|
||||
if (ticketIds.length > 0) {
|
||||
const cfValues = await db.query.customFieldValues.findMany({
|
||||
where: (table, { and, inArray, eq }) =>
|
||||
and(
|
||||
inArray(table.ticket_id, ticketIds),
|
||||
eq(table.value, f.value),
|
||||
),
|
||||
});
|
||||
const matchingIds = new Set(cfValues.map((v) => v.ticket_id));
|
||||
// Also find the field ID for the key
|
||||
const cfField = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.key, cfKey),
|
||||
});
|
||||
if (cfField) {
|
||||
const cfValuesForField = await db.query.customFieldValues.findMany({
|
||||
where: (table, { and, inArray, eq }) =>
|
||||
and(
|
||||
inArray(table.ticket_id, ticketIds),
|
||||
eq(table.custom_field_id, cfField.id),
|
||||
eq(table.value, f.value),
|
||||
),
|
||||
});
|
||||
const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id));
|
||||
result = result.filter((t) => matchSet.has(t.id));
|
||||
} else {
|
||||
result = result.filter((t) => matchingIds.has(t.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Widget-level filters override or add to view filters
|
||||
const widgetFilters = (widget.config as Record<string, unknown>)?.filters as Array<{ field: string; operator: string; value: string }> | undefined;
|
||||
if (widgetFilters) {
|
||||
for (const f of widgetFilters) {
|
||||
if (f.field === 'status') {
|
||||
if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value);
|
||||
else result = result.filter((t) => t.status === f.value);
|
||||
} else if (f.field === 'queue') {
|
||||
if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value);
|
||||
else result = result.filter((t) => t.queue_id === f.value);
|
||||
} else if (f.field === 'owner') {
|
||||
if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id);
|
||||
else result = result.filter((t) => t.owner_id === f.value);
|
||||
} else if (f.field === 'q') {
|
||||
const q = f.value.toLowerCase();
|
||||
result = result.filter((t) =>
|
||||
t.subject.toLowerCase().includes(q) ||
|
||||
String(t.id).includes(q) ||
|
||||
(queueName.get(t.queue_id) ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
|
||||
|
||||
// Find lifecycle for status classification
|
||||
const queueIds = [...new Set(result.map((r) => r.queue_id))];
|
||||
const queueRecords = queueIds.length > 0
|
||||
? await db.query.queues.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, queueIds),
|
||||
})
|
||||
: [];
|
||||
const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[];
|
||||
const lifecycleRecords = lifecycleIds.length > 0
|
||||
? await db.query.lifecycles.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, lifecycleIds),
|
||||
})
|
||||
: [];
|
||||
const lifecycleByQueue = new Map<string, { statuses: { initial: string[]; active: string[]; inactive: string[] } }>();
|
||||
for (const qr of queueRecords) {
|
||||
if (qr.lifecycle_id) {
|
||||
const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id);
|
||||
if (lc) lifecycleByQueue.set(qr.id, lc.definition as any);
|
||||
}
|
||||
}
|
||||
|
||||
// Get owner usernames
|
||||
const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[];
|
||||
const ownerUsers = ownerIds.length > 0
|
||||
? await db.query.users.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, ownerIds),
|
||||
})
|
||||
: [];
|
||||
const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username]));
|
||||
|
||||
// Get queue names
|
||||
const queueName = new Map(queueRecords.map((q) => [q.id, q.name]));
|
||||
|
||||
switch (widget.widget_type) {
|
||||
case 'count': {
|
||||
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'ticket_list': {
|
||||
const slice = result.slice(0, limit).map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
owner_id: ticket.owner_id,
|
||||
owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null,
|
||||
queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8),
|
||||
updated_at: ticket.updated_at?.toISOString(),
|
||||
}));
|
||||
return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'status_chart': {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const ticket of result) {
|
||||
counts[ticket.status] = (counts[ticket.status] ?? 0) + 1;
|
||||
}
|
||||
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'my_tickets': {
|
||||
const authUser = c.get('user');
|
||||
const myTickets = result.filter((t) => t.owner_id === authUser.userId);
|
||||
return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'trend_chart': {
|
||||
const period = ((widget.config as Record<string, unknown>)?.period as string) ?? 'day';
|
||||
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
|
||||
const trendField = ((widget.config as Record<string, unknown>)?.field as string) ?? 'created_at';
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const filtered = result.filter((t) => {
|
||||
const d = trendField === 'updated_at' ? t.updated_at : t.created_at;
|
||||
return d && new Date(d) >= start;
|
||||
});
|
||||
|
||||
const points: Record<string, number> = {};
|
||||
for (const t of filtered) {
|
||||
const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!);
|
||||
let key: string;
|
||||
if (period === 'week') {
|
||||
const weekStart = new Date(d);
|
||||
weekStart.setDate(d.getDate() - d.getDay());
|
||||
key = weekStart.toISOString().slice(0, 10);
|
||||
} else {
|
||||
key = d.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
points[key] = (points[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'overdue': {
|
||||
const dateFieldKey = (widget.config as Record<string, unknown>)?.field_key as string;
|
||||
const now = new Date();
|
||||
const overdue = result.filter((t) => {
|
||||
if (!dateFieldKey) {
|
||||
// No specific field — check if any inactive-adjacent status
|
||||
const lc = lifecycleByQueue.get(t.queue_id);
|
||||
if (lc) {
|
||||
const inactive = lc.statuses.inactive;
|
||||
if (inactive.includes(t.status)) return false; // already resolved
|
||||
}
|
||||
// Check if updated_at is older than 7 days
|
||||
const updated = t.updated_at ? new Date(t.updated_at) : new Date(0);
|
||||
return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return false; // Would need CF value lookup for date field
|
||||
});
|
||||
return c.json({ type: 'overdue', total: overdue.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'grouped_counts': {
|
||||
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
|
||||
const groups: Record<string, number> = {};
|
||||
|
||||
if (groupBy === 'owner') {
|
||||
for (const ticket of result) {
|
||||
const label = ticket.owner_id
|
||||
? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8))
|
||||
: 'Unassigned';
|
||||
groups[label] = (groups[label] ?? 0) + 1;
|
||||
}
|
||||
} else if (groupBy === 'queue') {
|
||||
for (const ticket of result) {
|
||||
const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8);
|
||||
groups[label] = (groups[label] ?? 0) + 1;
|
||||
}
|
||||
} else if (groupBy.startsWith('cf.')) {
|
||||
const cfKey = groupBy.slice(3);
|
||||
const cfField = await db.query.customFields.findFirst({
|
||||
where: eq(customFields.key, cfKey),
|
||||
});
|
||||
if (cfField) {
|
||||
const ticketIds = result.map((t) => t.id);
|
||||
const cfValues = ticketIds.length > 0
|
||||
? await db.query.customFieldValues.findMany({
|
||||
where: (table, { and, inArray, eq }) =>
|
||||
and(
|
||||
inArray(table.ticket_id, ticketIds),
|
||||
eq(table.custom_field_id, cfField.id),
|
||||
),
|
||||
})
|
||||
: [];
|
||||
for (const v of cfValues) {
|
||||
groups[v.value] = (groups[v.value] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
default:
|
||||
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
69
src/routes/mailgate.ts
Normal file
69
src/routes/mailgate.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { z } from 'zod/v4';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { InboundEmail } from '../email/types.ts';
|
||||
import type { EmailProcessor } from '../email/processor.ts';
|
||||
|
||||
const WebhookPayloadSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
body_text: z.string().optional().default(''),
|
||||
body_html: z.string().optional(),
|
||||
message_id: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
mime_type: z.string().optional().default('application/octet-stream'),
|
||||
content_base64: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /mailgate — webhook endpoint for receiving inbound emails.
|
||||
*
|
||||
* Accepts JSON payload from external mail services (SendGrid, Mailgun, etc.).
|
||||
* When MAIL_TRANSPORT is 'webhook', this is the primary inbound path.
|
||||
* When MAIL_TRANSPORT is 'mailtm' or 'none', it's still available as a
|
||||
* secondary path (useful for testing or hybrid setups).
|
||||
*/
|
||||
export function createMailgateRouter(db: Db, processor: EmailProcessor): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = WebhookPayloadSchema.parse(body);
|
||||
|
||||
const inbound: InboundEmail = {
|
||||
from: parsed.from,
|
||||
fromAddress: extractAddress(parsed.from),
|
||||
to: parsed.to,
|
||||
subject: parsed.subject,
|
||||
bodyText: parsed.body_text,
|
||||
bodyHtml: parsed.body_html,
|
||||
messageId: parsed.message_id ?? `${Date.now()}-${crypto.randomUUID()}`,
|
||||
receivedAt: new Date(),
|
||||
attachments: parsed.attachments.map((att) => ({
|
||||
filename: att.filename,
|
||||
mimeType: att.mime_type,
|
||||
content: Buffer.from(att.content_base64, 'base64'),
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await processor.process(inbound);
|
||||
return c.json(result, result.action === 'skipped' ? 200 : 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function extractAddress(raw: string): string {
|
||||
const match = raw.match(/<([^>]+)>/);
|
||||
if (match) return match[1]!.trim().toLowerCase();
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
64
src/routes/notifications.ts
Normal file
64
src/routes/notifications.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { notifications } from '../db/schema.ts';
|
||||
import { and, eq, desc } from 'drizzle-orm';
|
||||
|
||||
export function createNotificationsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /notifications — list notifications for current user
|
||||
router.get('/notifications', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await db.query.notifications.findMany({
|
||||
where: eq(notifications.user_id, user.userId),
|
||||
orderBy: desc(notifications.created_at),
|
||||
// Return last 50
|
||||
});
|
||||
return c.json(result.slice(0, 50));
|
||||
});
|
||||
|
||||
// GET /notifications/unread-count
|
||||
router.get('/notifications/unread-count', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await db.query.notifications.findMany({
|
||||
where: and(
|
||||
eq(notifications.user_id, user.userId),
|
||||
eq(notifications.read, false),
|
||||
),
|
||||
});
|
||||
return c.json({ count: result.length });
|
||||
});
|
||||
|
||||
// PATCH /notifications/:id/read — mark as read
|
||||
router.patch('/notifications/:id/read', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await db.update(notifications).set({ read: true }).where(eq(notifications.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /notifications/read-all — mark all as read
|
||||
router.patch('/notifications/read-all', async (c) => {
|
||||
const user = c.get('user');
|
||||
await db.update(notifications)
|
||||
.set({ read: true })
|
||||
.where(eq(notifications.user_id, user.userId));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Helper to create notifications (used by other routes)
|
||||
export async function createNotification(
|
||||
db: Db,
|
||||
data: { user_id: string; ticket_id?: number; type: string; title: string; body?: string },
|
||||
) {
|
||||
await db.insert(notifications).values({
|
||||
user_id: data.user_id,
|
||||
ticket_id: data.ticket_id ?? null,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
body: data.body ?? null,
|
||||
});
|
||||
}
|
||||
176
src/routes/queue-permissions.ts
Normal file
176
src/routes/queue-permissions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { queuePermissions, userPermissions, teams, queues, users } from '../db/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export function createQueuePermissionsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /queue-permissions — list all permissions (with team + queue names)
|
||||
router.get('/queue-permissions', async (c) => {
|
||||
const all = await db.query.queuePermissions.findMany();
|
||||
|
||||
// Enrich with names
|
||||
const teamIds = [...new Set(all.map((p) => p.team_id))];
|
||||
const queueIds = [...new Set(all.map((p) => p.queue_id))];
|
||||
|
||||
const teamList = teamIds.length > 0
|
||||
? await db.query.teams.findMany({ where: (t, { inArray }) => inArray(t.id, teamIds) })
|
||||
: [];
|
||||
const queueList = queueIds.length > 0
|
||||
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
|
||||
: [];
|
||||
|
||||
const teamById = new Map(teamList.map((t) => [t.id, t]));
|
||||
const queueById = new Map(queueList.map((q) => [q.id, q]));
|
||||
|
||||
const enriched = all.map((p) => ({
|
||||
...p,
|
||||
team_name: teamById.get(p.team_id)?.name ?? p.team_id,
|
||||
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
|
||||
}));
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// GET /queue-permissions/teams-and-queues — return teams and queues for the form
|
||||
router.get('/queue-permissions/teams-and-queues', async (c) => {
|
||||
const [teamList, queueList] = await Promise.all([
|
||||
db.query.teams.findMany(),
|
||||
db.query.queues.findMany(),
|
||||
]);
|
||||
return c.json({ teams: teamList, queues: queueList });
|
||||
});
|
||||
|
||||
// POST /queue-permissions — grant a right
|
||||
router.post('/queue-permissions', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { queue_id, team_id, right_name } = body;
|
||||
|
||||
if (!queue_id || !team_id || !right_name) {
|
||||
throw new HTTPException(422, { message: 'queue_id, team_id, and right_name are required' });
|
||||
}
|
||||
|
||||
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
|
||||
if (!validRights.includes(right_name)) {
|
||||
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await db.query.queuePermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(table.queue_id, queue_id),
|
||||
eqFn(table.team_id, team_id),
|
||||
eqFn(table.right_name, right_name),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return c.json(existing); // Idempotent — return existing
|
||||
}
|
||||
|
||||
const [perm] = await db.insert(queuePermissions).values({
|
||||
queue_id,
|
||||
team_id,
|
||||
right_name,
|
||||
}).returning();
|
||||
|
||||
if (!perm) {
|
||||
throw new HTTPException(500, { message: 'Failed to create permission' });
|
||||
}
|
||||
|
||||
return c.json(perm, 201);
|
||||
});
|
||||
|
||||
// DELETE /queue-permissions/:id — revoke a right
|
||||
router.delete('/queue-permissions/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.queuePermissions.findFirst({
|
||||
where: eq(queuePermissions.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Permission not found' });
|
||||
}
|
||||
|
||||
await db.delete(queuePermissions).where(eq(queuePermissions.id, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /user-permissions — list all per-user permissions
|
||||
router.get('/user-permissions', async (c) => {
|
||||
const all = await db.query.userPermissions.findMany();
|
||||
|
||||
const userIds = [...new Set(all.map((p) => p.user_id))];
|
||||
const queueIds = [...new Set(all.map((p) => p.queue_id))];
|
||||
|
||||
const userList = userIds.length > 0
|
||||
? await db.query.users.findMany({ where: (t, { inArray }) => inArray(t.id, userIds) })
|
||||
: [];
|
||||
const queueList = queueIds.length > 0
|
||||
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
|
||||
: [];
|
||||
|
||||
const userById = new Map(userList.map((u) => [u.id, u]));
|
||||
const queueById = new Map(queueList.map((q) => [q.id, q]));
|
||||
|
||||
const enriched = all.map((p) => ({
|
||||
...p,
|
||||
username: userById.get(p.user_id)?.username ?? p.user_id,
|
||||
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
|
||||
}));
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// POST /user-permissions — grant a right to a user
|
||||
router.post('/user-permissions', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { queue_id, user_id, right_name } = body;
|
||||
|
||||
if (!queue_id || !user_id || !right_name) {
|
||||
throw new HTTPException(422, { message: 'queue_id, user_id, and right_name are required' });
|
||||
}
|
||||
|
||||
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
|
||||
if (!validRights.includes(right_name)) {
|
||||
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
|
||||
}
|
||||
|
||||
const existing = await db.query.userPermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(table.queue_id, queue_id),
|
||||
eqFn(table.user_id, user_id),
|
||||
eqFn(table.right_name, right_name),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) return c.json(existing);
|
||||
|
||||
const [perm] = await db.insert(userPermissions).values({
|
||||
queue_id,
|
||||
user_id,
|
||||
right_name,
|
||||
}).returning();
|
||||
|
||||
if (!perm) {
|
||||
throw new HTTPException(500, { message: 'Failed to create user permission' });
|
||||
}
|
||||
|
||||
return c.json(perm, 201);
|
||||
});
|
||||
|
||||
// DELETE /user-permissions/:id — revoke a user right
|
||||
router.delete('/user-permissions/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await db.delete(userPermissions).where(eq(userPermissions.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
lifecycle_id: parsed.lifecycle_id ?? null,
|
||||
team_id: parsed.team_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!queue) {
|
||||
@@ -48,6 +49,7 @@ export function createQueuesRouter(db: Db): Hono {
|
||||
if (body.name !== undefined) updateData.name = String(body.name);
|
||||
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null;
|
||||
if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null;
|
||||
if (body.team_id !== undefined) updateData.team_id = body.team_id || null;
|
||||
|
||||
const [updated] = await db.update(queues)
|
||||
.set(updateData)
|
||||
|
||||
108
src/routes/sla-policies.ts
Normal file
108
src/routes/sla-policies.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { slaPolicies } from '../db/schema.ts';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateSlaSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
queue_id: z.string().uuid().optional(),
|
||||
description: z.string().optional(),
|
||||
response_time_minutes: z.number().int().positive().optional(),
|
||||
resolution_time_minutes: z.number().int().positive().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const UpdateSlaSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
queue_id: z.string().uuid().nullable().optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
response_time_minutes: z.number().int().positive().nullable().optional(),
|
||||
resolution_time_minutes: z.number().int().positive().nullable().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function createSlaPoliciesRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET / — list all SLA policies
|
||||
router.get('/', async (c) => {
|
||||
const policies = await db.query.slaPolicies.findMany({
|
||||
orderBy: asc(slaPolicies.name),
|
||||
});
|
||||
return c.json(policies);
|
||||
});
|
||||
|
||||
// POST / — create SLA policy
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateSlaSchema.parse(body);
|
||||
|
||||
const [policy] = await db.insert(slaPolicies).values({
|
||||
name: parsed.name,
|
||||
queue_id: parsed.queue_id ?? null,
|
||||
description: parsed.description ?? null,
|
||||
response_time_minutes: parsed.response_time_minutes ?? null,
|
||||
resolution_time_minutes: parsed.resolution_time_minutes ?? null,
|
||||
disabled: parsed.disabled ?? false,
|
||||
}).returning();
|
||||
|
||||
if (!policy) {
|
||||
throw new HTTPException(500, { message: 'Failed to create SLA policy' });
|
||||
}
|
||||
|
||||
return c.json(policy, 201);
|
||||
});
|
||||
|
||||
// PATCH /:id — update SLA policy
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const parsed = UpdateSlaSchema.parse(body);
|
||||
|
||||
const existing = await db.query.slaPolicies.findFirst({
|
||||
where: eq(slaPolicies.id, id),
|
||||
});
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'SLA policy not found' });
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (parsed.name !== undefined) updateData.name = parsed.name;
|
||||
if (parsed.queue_id !== undefined) updateData.queue_id = parsed.queue_id;
|
||||
if (parsed.description !== undefined) updateData.description = parsed.description;
|
||||
if (parsed.response_time_minutes !== undefined) updateData.response_time_minutes = parsed.response_time_minutes;
|
||||
if (parsed.resolution_time_minutes !== undefined) updateData.resolution_time_minutes = parsed.resolution_time_minutes;
|
||||
if (parsed.disabled !== undefined) updateData.disabled = parsed.disabled;
|
||||
|
||||
const [updated] = await db.update(slaPolicies)
|
||||
.set(updateData as any)
|
||||
.where(eq(slaPolicies.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new HTTPException(500, { message: 'Failed to update SLA policy' });
|
||||
}
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// DELETE /:id — delete SLA policy
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.slaPolicies.findFirst({
|
||||
where: eq(slaPolicies.id, id),
|
||||
});
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'SLA policy not found' });
|
||||
}
|
||||
|
||||
await db.delete(slaPolicies).where(eq(slaPolicies.id, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -151,6 +151,21 @@ export function createTemplatesRouter(db: Db): Hono {
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.templates.findFirst({
|
||||
where: eq(templates.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Template not found' });
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/preview', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const subjectTemplate = String(body.subject_template ?? '');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users } from '../db/schema.ts';
|
||||
|
||||
@@ -13,5 +14,73 @@ export function createUsersRouter(db: Db): Hono {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const username = String(body.username ?? '').trim();
|
||||
const email = body.email ? String(body.email).trim() : null;
|
||||
const role = body.role ? String(body.role) : 'staff';
|
||||
const password = body.password ? String(body.password).trim() : null;
|
||||
|
||||
if (!username) {
|
||||
throw new HTTPException(400, { message: 'username is required' });
|
||||
}
|
||||
|
||||
const [user] = await db.insert(users).values({
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
password_hash: password ? await Bun.password.hash(password) : null,
|
||||
}).returning();
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(500, { message: 'Failed to create user' });
|
||||
}
|
||||
|
||||
return c.json(user, 201);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
const updateData: Partial<typeof users.$inferInsert> = {};
|
||||
if (body.username !== undefined) updateData.username = String(body.username).trim();
|
||||
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
|
||||
if (body.role !== undefined) updateData.role = String(body.role);
|
||||
if (body.password !== undefined && String(body.password).trim()) {
|
||||
updateData.password_hash = await Bun.password.hash(String(body.password).trim());
|
||||
}
|
||||
|
||||
const [updated] = await db.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
84
src/routes/views.ts
Normal file
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;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Handlebars from 'handlebars';
|
||||
import { config } from '../config.ts';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import * as schema from '../db/schema.ts';
|
||||
import { customFieldValues, tickets, transactions, users } from '../db/schema.ts';
|
||||
import { customFieldValues, tickets, transactions, users, ticketWatchers } from '../db/schema.ts';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
|
||||
export interface ActionExecutor {
|
||||
@@ -75,6 +75,14 @@ export class SendEmail implements ActionExecutor {
|
||||
if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) {
|
||||
userIds.add(ticket.owner_id);
|
||||
}
|
||||
if (['watchers', 'cc'].includes(source) && payload.ticketId) {
|
||||
const watchers = await this.db.query.ticketWatchers.findMany({
|
||||
where: eq(ticketWatchers.ticket_id, payload.ticketId),
|
||||
});
|
||||
for (const w of watchers) {
|
||||
userIds.add(w.user_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userIds.size === 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||
|
||||
export interface ConditionEvaluateContext {
|
||||
lifecycleDef?: LifecycleDefinition;
|
||||
customFields?: Record<string, string>; // key → value map of CF values
|
||||
}
|
||||
|
||||
export interface ConditionConfig {
|
||||
@@ -16,6 +17,8 @@ export interface ConditionConfig {
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
value?: unknown;
|
||||
link_type?: unknown;
|
||||
breach_type?: unknown;
|
||||
}
|
||||
|
||||
export interface ConditionEvaluator {
|
||||
@@ -82,11 +85,74 @@ export class OnCustomFieldChange implements ConditionEvaluator {
|
||||
}
|
||||
}
|
||||
|
||||
export class OnLinkCreate implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
return transactions.some((tx) => {
|
||||
if (tx.transaction_type !== 'LinkCreate') return false;
|
||||
if (config?.link_type) {
|
||||
const linkType = tx.field;
|
||||
if (!matchesStatusFilter(linkType, config.link_type)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class OnSlaBreach implements ConditionEvaluator {
|
||||
evaluate(ticket: Ticket, _transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const breachType = config?.breach_type ?? 'any';
|
||||
|
||||
// Check if ticket has sla_breached set
|
||||
const breached = (ticket as any).sla_breached as string | null;
|
||||
|
||||
if (breachType === 'any') {
|
||||
return breached === 'response' || breached === 'resolution' || breached === 'both';
|
||||
}
|
||||
if (breachType === 'response') {
|
||||
return breached === 'response' || breached === 'both';
|
||||
}
|
||||
if (breachType === 'resolution') {
|
||||
return breached === 'resolution' || breached === 'both';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class OnOverdue implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, _transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const fieldKey = config?.field_key ?? config?.field_id ?? config?.field;
|
||||
if (!fieldKey) return false;
|
||||
|
||||
const cfValue = context?.customFields?.[String(fieldKey)];
|
||||
if (!cfValue) return false;
|
||||
|
||||
// Parse the date value
|
||||
const dueDate = new Date(cfValue);
|
||||
if (isNaN(dueDate.getTime())) return false;
|
||||
|
||||
// Check if overdue (past due date)
|
||||
if (new Date() <= dueDate) return false;
|
||||
|
||||
// Check that ticket is still active (not in inactive state)
|
||||
const lifecycleDef = context?.lifecycleDef;
|
||||
if (lifecycleDef) {
|
||||
const inactiveStates = lifecycleDef.statuses.inactive;
|
||||
if (inactiveStates.includes(_ticket.status)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||
OnCreate: new OnCreate(),
|
||||
OnStatusChange: new OnStatusChange(),
|
||||
OnResolve: new OnResolve(),
|
||||
OnCustomFieldChange: new OnCustomFieldChange(),
|
||||
OnLinkCreate: new OnLinkCreate(),
|
||||
OnOverdue: new OnOverdue(),
|
||||
OnSlaBreach: new OnSlaBreach(),
|
||||
};
|
||||
|
||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||
|
||||
@@ -37,6 +37,7 @@ export class ScripEngine {
|
||||
async prepare(
|
||||
ticketId: number,
|
||||
transactions: Transaction[],
|
||||
stage: 'TransactionCreate' | 'TransactionBatch' = 'TransactionCreate',
|
||||
): Promise<PreparedScrip[]> {
|
||||
const ticketRecord = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
@@ -53,6 +54,15 @@ export class ScripEngine {
|
||||
const matchingScrips = allScrips.filter((scrip) => {
|
||||
if (scrip.disabled) return false;
|
||||
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
|
||||
if (scrip.stage !== stage) return false;
|
||||
// Filter by applicable transaction types — if set, at least one tx must match
|
||||
if (scrip.applicable_trans_types) {
|
||||
const types = scrip.applicable_trans_types.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
if (types.length > 0 && !types.includes('Any')) {
|
||||
const txTypes = new Set(transactions.map((tx) => tx.transaction_type));
|
||||
if (!types.some((t) => txTypes.has(t))) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -70,10 +80,6 @@ export class ScripEngine {
|
||||
}
|
||||
}
|
||||
|
||||
const conditionContext: ConditionEvaluateContext = {
|
||||
lifecycleDef,
|
||||
};
|
||||
|
||||
const cfValues = await this.db.query.customFieldValues.findMany({
|
||||
where: eq(customFieldValues.ticket_id, ticketId),
|
||||
});
|
||||
@@ -94,6 +100,11 @@ export class ScripEngine {
|
||||
}
|
||||
}
|
||||
|
||||
const conditionContext: ConditionEvaluateContext = {
|
||||
lifecycleDef,
|
||||
customFields: customFieldsMap,
|
||||
};
|
||||
|
||||
const prepared: PreparedScrip[] = [];
|
||||
|
||||
for (const scrip of matchingScrips) {
|
||||
|
||||
170
src/scrip/scheduler.ts
Normal file
170
src/scrip/scheduler.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { transactions, tickets, queues, lifecycles, slaPolicies } from '../db/schema.ts';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { ScripEngine } from './engine.ts';
|
||||
|
||||
const SYSTEM_USER = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
/**
|
||||
* Calculate SLA status for all active tickets.
|
||||
* Checks response and resolution deadlines against current time.
|
||||
*/
|
||||
async function calculateSlaStatus(db: Db): Promise<number> {
|
||||
let breaches = 0;
|
||||
|
||||
// Get all queues with SLA policies
|
||||
const allPolicies = await db.query.slaPolicies.findMany({
|
||||
where: eq(slaPolicies.disabled, false),
|
||||
});
|
||||
|
||||
if (allPolicies.length === 0) return 0;
|
||||
|
||||
// Get all lifecycles to determine inactive statuses
|
||||
const allLifecycles = await db.query.lifecycles.findMany();
|
||||
const inactiveByQueue = new Map<string, Set<string>>();
|
||||
|
||||
const allQueues = await db.query.queues.findMany();
|
||||
for (const q of allQueues) {
|
||||
if (q.lifecycle_id) {
|
||||
const lc = allLifecycles.find((l) => l.id === q.lifecycle_id);
|
||||
if (lc) {
|
||||
const def = lc.definition as any;
|
||||
inactiveByQueue.set(q.id, new Set(def?.statuses?.inactive ?? ['resolved', 'closed']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allTickets = await db.query.tickets.findMany();
|
||||
const now = new Date();
|
||||
|
||||
for (const ticket of allTickets) {
|
||||
// Skip inactive tickets
|
||||
const inactive = inactiveByQueue.get(ticket.queue_id);
|
||||
if (inactive && inactive.has(ticket.status)) continue;
|
||||
if (!inactive && ['resolved', 'closed'].includes(ticket.status)) continue;
|
||||
|
||||
let newBreach: string | null = null;
|
||||
|
||||
// Check response deadline
|
||||
if (ticket.sla_response_deadline && now > new Date(ticket.sla_response_deadline)) {
|
||||
newBreach = 'response';
|
||||
}
|
||||
|
||||
// Check resolution deadline
|
||||
if (ticket.sla_resolution_deadline && now > new Date(ticket.sla_resolution_deadline)) {
|
||||
newBreach = newBreach === 'response' ? 'both' : 'resolution';
|
||||
}
|
||||
|
||||
// Only update if breach status changed
|
||||
if (newBreach && newBreach !== ticket.sla_breached) {
|
||||
await db.update(tickets)
|
||||
.set({ sla_breached: newBreach } as any)
|
||||
.where(eq(tickets.id, ticket.id));
|
||||
|
||||
// Create SlaBreach transaction
|
||||
await db.insert(transactions).values({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'SlaBreach' as any,
|
||||
field: 'sla_breached',
|
||||
old_value: ticket.sla_breached ?? null,
|
||||
new_value: newBreach,
|
||||
creator_id: SYSTEM_USER,
|
||||
} as any);
|
||||
|
||||
breaches++;
|
||||
}
|
||||
}
|
||||
|
||||
return breaches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run scheduled scrips against all active tickets.
|
||||
* Creates a synthetic transaction so conditions like OnOverdue can fire.
|
||||
*/
|
||||
export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> {
|
||||
const engine = new ScripEngine(db);
|
||||
|
||||
// Calculate SLA statuses first
|
||||
const slaBreaches = await calculateSlaStatus(db);
|
||||
if (slaBreaches > 0) {
|
||||
console.log(`[scheduler] SLA breaches detected: ${slaBreaches}`);
|
||||
}
|
||||
|
||||
// Get all lifecycles to determine inactive statuses
|
||||
const allLifecycles = await db.query.lifecycles.findMany();
|
||||
const inactiveByQueue = new Map<string, Set<string>>();
|
||||
|
||||
const allQueues = await db.query.queues.findMany();
|
||||
for (const q of allQueues) {
|
||||
if (q.lifecycle_id) {
|
||||
const lc = allLifecycles.find((l) => l.id === q.lifecycle_id);
|
||||
if (lc) {
|
||||
const def = lc.definition as any;
|
||||
inactiveByQueue.set(q.id, new Set(def?.statuses?.inactive ?? ['resolved', 'closed']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all potentially active tickets
|
||||
const allTickets = await db.query.tickets.findMany();
|
||||
const active = allTickets.filter((t) => {
|
||||
const inactive = inactiveByQueue.get(t.queue_id);
|
||||
if (inactive) return !inactive.has(t.status);
|
||||
return !['resolved', 'closed'].includes(t.status);
|
||||
});
|
||||
|
||||
let fired = 0;
|
||||
|
||||
for (const ticket of active) {
|
||||
try {
|
||||
// Create a synthetic transaction
|
||||
const [tx] = await db.insert(transactions).values({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Comment' as any,
|
||||
field: 'scheduled',
|
||||
data: { body: 'Scheduled scrip evaluation' },
|
||||
creator_id: SYSTEM_USER,
|
||||
} as any).returning();
|
||||
|
||||
if (!tx) continue;
|
||||
|
||||
// Run scrips
|
||||
const prepared = await engine.prepare(ticket.id, [tx as any]);
|
||||
if (prepared.length > 0) {
|
||||
const results = await engine.commit(prepared);
|
||||
const successes = results.filter((r) => r.success);
|
||||
if (successes.length > 0) fired += successes.length;
|
||||
}
|
||||
} catch (err) {
|
||||
// Log and continue — don't let one failing ticket block the scheduler
|
||||
console.error(`[scheduler] Error processing ticket ${ticket.id}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return { checked: active.length, fired };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the background scheduler. Runs every `intervalMinutes` minutes.
|
||||
*/
|
||||
export function startScheduler(db: Db, intervalMinutes = 5) {
|
||||
console.log(`[scheduler] Starting scrip scheduler (every ${intervalMinutes}m)`);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const result = await runScheduledScrips(db);
|
||||
if (result.fired > 0) {
|
||||
console.log(`[scheduler] Checked ${result.checked} tickets, fired ${result.fired} scrip actions`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[scheduler] Error:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Run once at startup after a short delay
|
||||
setTimeout(run, 10000);
|
||||
|
||||
// Then run on interval
|
||||
setInterval(run, intervalMinutes * 60 * 1000);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "16.2.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
0
web/public/favicon.ico
Normal file
0
web/public/favicon.ico
Normal file
File diff suppressed because it is too large
Load Diff
598
web/src/app/dashboards/[id]/page.tsx
Normal file
598
web/src/app/dashboards/[id]/page.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, use, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
GripIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
RefreshCwIcon,
|
||||
LayoutGridIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getDashboard,
|
||||
createWidget,
|
||||
deleteWidget,
|
||||
updateWidget,
|
||||
getWidgetData,
|
||||
getViews,
|
||||
getTeams,
|
||||
updateDashboard,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
SavedView,
|
||||
Team,
|
||||
WidgetData,
|
||||
} from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { CountWidget } from "@/components/widgets/count-widget";
|
||||
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
|
||||
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
|
||||
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
|
||||
import { TrendChartWidget } from "@/components/widgets/trend-chart-widget";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
|
||||
return {
|
||||
gridColumn: `${position.x + 1} / span ${position.w}`,
|
||||
gridRow: `${position.y + 1} / span ${position.h}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
|
||||
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
||||
const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]);
|
||||
const [views, setViews] = useState<SavedView[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
|
||||
// Add widget dialog
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addViewId, setAddViewId] = useState("");
|
||||
const [addTitle, setAddTitle] = useState("");
|
||||
const [addType, setAddType] = useState("count");
|
||||
const [addGroupBy, setAddGroupBy] = useState("owner");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
const { data, error } = await getDashboard(id);
|
||||
if (error || !data) {
|
||||
setError(error ?? "Dashboard not found");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setDashboard(data);
|
||||
const widgetList = data.widgets ?? [];
|
||||
setWidgets(widgetList);
|
||||
|
||||
// Fetch data for each widget
|
||||
for (const widget of widgetList) {
|
||||
const { data: wData } = await getWidgetData(id, widget.id);
|
||||
if (wData) {
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard();
|
||||
getViews().then(({ data }) => { if (data) setViews(data); });
|
||||
getTeams().then(({ data }) => { if (data) setTeams(data); });
|
||||
}, [fetchDashboard]);
|
||||
|
||||
// Auto-refresh: only refresh widget data, not structure
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !dashboard) return;
|
||||
const interval = setInterval(() => {
|
||||
for (const widget of widgets) {
|
||||
getWidgetData(dashboard.id, widget.id).then(({ data: wData }) => {
|
||||
if (wData) {
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, dashboard?.id]);
|
||||
|
||||
const handleAddWidget = async () => {
|
||||
if (!addViewId || !addTitle.trim()) return;
|
||||
setAdding(true);
|
||||
// Smart positioning: fill a 3-column grid (4 units each in 12-col grid)
|
||||
const COLS = 3; const W = 4; const H = 2;
|
||||
const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`));
|
||||
let x = 0; let y = 0;
|
||||
while (occupied.has(`${x},${y}`)) {
|
||||
x += W;
|
||||
if (x >= COLS * W) { x = 0; y += H; }
|
||||
}
|
||||
const pos = { x, y, w: W, h: H };
|
||||
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
|
||||
const { data, error } = await createWidget(id, {
|
||||
view_id: addViewId,
|
||||
title: addTitle.trim(),
|
||||
widget_type: addType,
|
||||
position: pos,
|
||||
config,
|
||||
});
|
||||
if (!error && data) {
|
||||
setWidgets((prev) => [...prev, data]);
|
||||
const { data: wData } = await getWidgetData(id, data.id);
|
||||
if (wData) {
|
||||
setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w)));
|
||||
}
|
||||
setAddOpen(false);
|
||||
setAddViewId("");
|
||||
setAddTitle("");
|
||||
setAddType("count");
|
||||
}
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
const handleDeleteWidget = async (widgetId: string) => {
|
||||
await deleteWidget(id, widgetId);
|
||||
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||
};
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [resizingId, setResizingId] = useState<string | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Resize: track mousedown → mousemove → mouseup
|
||||
const handleResizeStart = (e: React.MouseEvent, widgetId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setResizingId(widgetId);
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const widget = widgets.find((w) => w.id === widgetId);
|
||||
if (!widget || !gridRef.current) return;
|
||||
|
||||
const startW = widget.position.w;
|
||||
const startH = widget.position.h;
|
||||
const gridWidth = gridRef.current.offsetWidth;
|
||||
const unitSize = gridWidth / 12;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = Math.round((ev.clientX - startX) / unitSize);
|
||||
const dy = Math.round((ev.clientY - startY) / unitSize);
|
||||
const newW = Math.max(1, Math.min(12, startW + dx));
|
||||
const newH = Math.max(1, startH + dy);
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === widgetId
|
||||
? { ...w, position: { ...w.position, w: newW, h: newH } }
|
||||
: w
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
setResizingId(null);
|
||||
|
||||
// Resolve overlaps using latest state via functional updater
|
||||
setWidgets((current) => {
|
||||
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
|
||||
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
let hasOverlap = false;
|
||||
for (let i = 0; i < resolved.length; i++) {
|
||||
for (let j = i + 1; j < resolved.length; j++) {
|
||||
const a = resolved[i].position;
|
||||
const b = resolved[j].position;
|
||||
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
|
||||
hasOverlap = true;
|
||||
const toMove = widgetId === resolved[i].id ? j : i;
|
||||
const fixedW = resolved[widgetId === resolved[i].id ? i : j];
|
||||
resolved[toMove] = {
|
||||
...resolved[toMove],
|
||||
position: { ...resolved[toMove].position, y: fixedW.position.y + fixedW.position.h },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasOverlap) break;
|
||||
}
|
||||
|
||||
// Persist changed positions
|
||||
for (const w of resolved) {
|
||||
const orig = current.find((o) => o.id === w.id);
|
||||
if (orig && (orig.position.y !== w.position.y || orig.position.h !== w.position.h)) {
|
||||
updateWidget(id, w.id, { position: w.position });
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
// Mouse-based drag: mousedown on grip → mousemove → mouseup
|
||||
const handleDragMouseDown = (e: React.MouseEvent, widgetId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!gridRef.current) return;
|
||||
setDraggingId(widgetId);
|
||||
|
||||
const widget = widgets.find((w) => w.id === widgetId);
|
||||
if (!widget) return;
|
||||
const unitSize = gridRef.current.offsetWidth / 12;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startGridX = widget.position.x;
|
||||
const startGridY = widget.position.y;
|
||||
|
||||
// Store offset from widget origin to mouse for visual tracking
|
||||
const widgetEl = (e.target as HTMLElement).closest('[data-widget-id]') as HTMLElement;
|
||||
if (widgetEl) {
|
||||
const rect = widgetEl.getBoundingClientRect();
|
||||
setDragOffset({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = Math.round((ev.clientX - startX) / unitSize);
|
||||
const dy = Math.round((ev.clientY - startY) / unitSize);
|
||||
const newX = Math.max(0, Math.min(12 - widget.position.w, startGridX + dx));
|
||||
const newY = Math.max(0, startGridY + dy);
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) =>
|
||||
w.id === widgetId
|
||||
? { ...w, position: { ...w.position, x: newX, y: newY } }
|
||||
: w
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
setDraggingId(null);
|
||||
|
||||
setWidgets((current) => {
|
||||
const updated = current.find((w) => w.id === widgetId);
|
||||
if (!updated) return current;
|
||||
|
||||
let resolved = current.map((w) => ({ ...w, position: { ...w.position } }));
|
||||
|
||||
// Push overlapping widgets down
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
let hasOverlap = false;
|
||||
for (let i = 0; i < resolved.length; i++) {
|
||||
for (let j = i + 1; j < resolved.length; j++) {
|
||||
const a = resolved[i].position;
|
||||
const b = resolved[j].position;
|
||||
if (a.x + a.w > b.x && a.x < b.x + b.w && a.y + a.h > b.y && a.y < b.y + b.h) {
|
||||
hasOverlap = true;
|
||||
const moveIdx = resolved[i].id === widgetId ? j : i;
|
||||
const fixedIdx = moveIdx === i ? j : i;
|
||||
resolved[moveIdx] = {
|
||||
...resolved[moveIdx],
|
||||
position: { ...resolved[moveIdx].position, y: resolved[fixedIdx].position.y + resolved[fixedIdx].position.h },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasOverlap) break;
|
||||
}
|
||||
|
||||
// Persist changed positions
|
||||
for (const w of resolved) {
|
||||
const orig = current.find((o) => o.id === w.id);
|
||||
if (orig && (orig.position.x !== w.position.x || orig.position.y !== w.position.y)) {
|
||||
updateWidget(id, w.id, { position: w.position });
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
|
||||
if (!widget.data) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (widget.data.type) {
|
||||
case "count":
|
||||
case "my_tickets":
|
||||
return <CountWidget data={widget.data} />;
|
||||
case "overdue":
|
||||
return <CountWidget data={{ ...widget.data, type: "count" }} />;
|
||||
case "ticket_list":
|
||||
return <TicketListWidget data={widget.data} />;
|
||||
case "status_chart":
|
||||
return <StatusChartWidget data={widget.data} />;
|
||||
case "grouped_counts":
|
||||
return <GroupedCountsWidget data={widget.data} />;
|
||||
case "trend_chart":
|
||||
return <TrendChartWidget data={widget.data} />;
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||
<p className="text-xs text-muted-foreground">Unknown type: {widget.data.type}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<p className="text-sm text-muted-foreground">{error ?? "Dashboard not found"}</p>
|
||||
<Link href="/" className="text-sm text-primary hover:underline">
|
||||
Go to ticket list
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background/80">
|
||||
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
|
||||
<div className="flex items-center justify-between px-5 py-3 lg:px-6">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
<LayoutGridIcon className="h-3.5 w-3.5" />
|
||||
Dashboard
|
||||
</div>
|
||||
<h1 className="mt-1 text-xl font-semibold text-foreground">{dashboard.name}</h1>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<select
|
||||
value={dashboard.team_id ?? ""}
|
||||
onChange={async (e) => {
|
||||
const teamId = e.target.value || null;
|
||||
await updateDashboard(dashboard.id, { team_id: teamId });
|
||||
setDashboard((prev) => prev ? { ...prev, team_id: teamId } : prev);
|
||||
}}
|
||||
className="h-7 rounded border border-border bg-card px-2 text-xs text-muted-foreground outline-none"
|
||||
>
|
||||
<option value="">No team</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{dashboard.description && (
|
||||
<p className="text-sm text-muted-foreground">{dashboard.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
className={cn("h-8 border-border/80", editMode ? "bg-primary/20 text-primary border-primary/40" : "bg-card/70")}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
{editMode ? "Done" : "Edit"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={cn("h-8 border-border/80", autoRefresh ? "bg-primary/20 text-primary" : "bg-card/70")}
|
||||
>
|
||||
<RefreshCwIcon className={cn("h-4 w-4", autoRefresh && "animate-spin")} />
|
||||
{autoRefresh ? "Live" : "Auto"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchDashboard}
|
||||
className="h-8 border-border/80 bg-card/70"
|
||||
>
|
||||
<RefreshCwIcon className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)} className="h-8 bg-primary shadow-sm">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add widget
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-auto p-5 lg:p-6">
|
||||
{widgets.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<LayoutGridIcon className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No widgets yet</p>
|
||||
{editMode ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add your first widget
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditMode(true)}>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
Enter edit mode
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid auto-rows-[minmax(100px,auto)] grid-cols-6 gap-3 md:grid-cols-12 md:gap-4"
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-widget-id={widget.id}
|
||||
className={cn(
|
||||
"group relative transition-none",
|
||||
draggingId === widget.id && "z-10",
|
||||
resizingId === widget.id && "select-none",
|
||||
)}
|
||||
style={widgetGridStyle(widget.position)}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{editMode && (
|
||||
<div
|
||||
className="absolute left-2 top-2 z-10 hidden h-6 w-6 cursor-grab items-center justify-center rounded bg-background/80 text-muted-foreground group-hover:flex active:cursor-grabbing"
|
||||
onMouseDown={(e) => handleDragMouseDown(e, widget.id)}
|
||||
>
|
||||
<GripIcon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
{renderWidget(widget)}
|
||||
{editMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWidget(widget.id)}
|
||||
className="absolute right-2 top-2 z-10 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
|
||||
title="Remove widget"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 z-10 hidden h-5 w-5 cursor-se-resize items-center justify-center group-hover:flex"
|
||||
onMouseDown={(e) => handleResizeStart(e, widget.id)}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" className="text-muted-foreground">
|
||||
<path d="M0 10 L10 0" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M5 10 L10 5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M0 10 L10 10" stroke="transparent" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add widget</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a saved view and widget type to add to this dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Widget title</label>
|
||||
<input
|
||||
value={addTitle}
|
||||
onChange={(e) => setAddTitle(e.target.value)}
|
||||
placeholder="e.g. Open tickets"
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Saved view</label>
|
||||
<select
|
||||
value={addViewId}
|
||||
onChange={(e) => {
|
||||
setAddViewId(e.target.value);
|
||||
const view = views.find((v) => v.id === e.target.value);
|
||||
if (view && !addTitle) setAddTitle(view.name);
|
||||
}}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="">Select a view...</option>
|
||||
{views.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Widget type</label>
|
||||
<select
|
||||
value={addType}
|
||||
onChange={(e) => setAddType(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="count">Count (big number)</option>
|
||||
<option value="ticket_list">Ticket list (mini table)</option>
|
||||
<option value="status_chart">Status chart (donut)</option>
|
||||
<option value="grouped_counts">Grouped counts (bar chart)</option>
|
||||
<option value="my_tickets">My tickets (auto-scoped)</option>
|
||||
<option value="overdue">Overdue / stale</option>
|
||||
<option value="trend_chart">Trend chart (bar)</option>
|
||||
</select>
|
||||
</div>
|
||||
{addType === "grouped_counts" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">Group by</label>
|
||||
<select
|
||||
value={addGroupBy}
|
||||
onChange={(e) => setAddGroupBy(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||
>
|
||||
<option value="owner">Owner</option>
|
||||
<option value="queue">Queue</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addViewId || !addTitle.trim() || adding}
|
||||
onClick={handleAddWidget}
|
||||
>
|
||||
{adding ? "Adding..." : "Add widget"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Suspense } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
|
||||
const ibmPlexSans = IBM_Plex_Sans({
|
||||
subsets: ["latin"],
|
||||
@@ -31,9 +32,11 @@ export default function RootLayout({
|
||||
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
|
||||
<AppShell>{children}</AppShell>
|
||||
</Suspense>
|
||||
<AuthProvider>
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
|
||||
<AppShell>{children}</AppShell>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
97
web/src/app/login/page.tsx
Normal file
97
web/src/app/login/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { LogInIcon } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, user } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Already logged in
|
||||
if (user) {
|
||||
router.replace("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await login(username.trim(), password);
|
||||
setLoading(false);
|
||||
|
||||
if (result) {
|
||||
setError(result);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background/80">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Tessera</h1>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="username" className="text-[10px] font-medium text-muted-foreground">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="password" className="text-[10px] font-medium text-muted-foreground">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !password}
|
||||
className="flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
) : (
|
||||
<LogInIcon className="h-4 w-4" />
|
||||
)}
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/60">
|
||||
Demo: admin / admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1299
web/src/app/page.tsx
1299
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
@@ -4,8 +4,11 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BellIcon,
|
||||
CircleIcon,
|
||||
LayoutGridIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
InboxIcon,
|
||||
ClockIcon,
|
||||
SettingsIcon,
|
||||
@@ -13,11 +16,12 @@ import {
|
||||
PanelLeftIcon,
|
||||
CommandIcon,
|
||||
} from "lucide-react";
|
||||
import { getTickets, getQueues } from "@/lib/api";
|
||||
import type { Queue } from "@/lib/types";
|
||||
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
|
||||
import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { cn, formatTicketId } from "@/lib/utils";
|
||||
|
||||
const SidebarCollapsedContext = createContext(false);
|
||||
|
||||
@@ -52,22 +56,19 @@ function SidebarNavItem({
|
||||
href={href}
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
"group flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
|
||||
"group flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
collapsed ? "justify-center w-full" : "justify-between",
|
||||
active
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground font-semibold shadow-[inset_3px_0_0_color-mix(in_oklch,var(--sidebar-primary-foreground)_55%,transparent)]"
|
||||
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
|
||||
? "bg-sidebar-accent text-sidebar-foreground font-medium"
|
||||
: "text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 font-normal"
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && label}
|
||||
<span className={cn("flex items-center min-w-0", collapsed ? "" : "gap-2.5")}>
|
||||
<Icon className={cn("w-4 h-4 flex-shrink-0", active ? "opacity-90" : "opacity-50 group-hover:opacity-70")} />
|
||||
{!collapsed && <span className="truncate">{label}</span>}
|
||||
</span>
|
||||
{!collapsed && count !== undefined && count > 0 && (
|
||||
<span className={cn(
|
||||
"min-w-5 rounded px-1 text-right text-[11px] tabular-nums",
|
||||
active ? "text-sidebar-primary-foreground/80" : "text-sidebar-foreground/45"
|
||||
)}>
|
||||
<span className="min-w-5 rounded px-1 text-right text-[11px] tabular-nums text-sidebar-foreground/35">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -78,6 +79,7 @@ function SidebarNavItem({
|
||||
function SidebarNav() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const [counts, setCounts] = useState<ViewCounts>({
|
||||
all: 0,
|
||||
@@ -86,54 +88,90 @@ function SidebarNav() {
|
||||
recent: 0,
|
||||
});
|
||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [myTeamId, setMyTeamId] = useState<string | null>(null);
|
||||
const [newDashboardName, setNewDashboardName] = useState("");
|
||||
const [addingDashboard, setAddingDashboard] = useState(false);
|
||||
|
||||
const currentUserId = authUser?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
getTickets().then(({ data }) => {
|
||||
async function load() {
|
||||
const myId = currentUserId;
|
||||
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
|
||||
const data = ticketRes.data;
|
||||
|
||||
if (data) {
|
||||
const now = Date.now();
|
||||
const week = 7 * 24 * 60 * 60 * 1000;
|
||||
setCounts({
|
||||
all: data.length,
|
||||
my: data.filter((t) => t.owner_id).length,
|
||||
my: myId ? data.filter((t) => t.owner_id === myId).length : 0,
|
||||
unassigned: data.filter((t) => !t.owner_id).length,
|
||||
recent: data.filter(
|
||||
(t) => new Date(t.updated_at).getTime() > now - week
|
||||
).length,
|
||||
recent: data.filter((t) => new Date(t.updated_at).getTime() > now - week).length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
getQueues().then(({ data }) => {
|
||||
if (data) {
|
||||
Promise.all(
|
||||
data.map((q) =>
|
||||
// Queues
|
||||
const queueRes = await getQueues();
|
||||
if (queueRes.data) {
|
||||
const qs = await Promise.all(
|
||||
queueRes.data.map((q) =>
|
||||
getTickets({ queue_id: q.id }).then(({ data: tickets }) => ({
|
||||
...q,
|
||||
count: tickets?.length ?? 0,
|
||||
}))
|
||||
)
|
||||
).then(setQueues);
|
||||
);
|
||||
setQueues(qs);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Views
|
||||
const viewRes = await getViews();
|
||||
if (viewRes.data) setSavedViews(viewRes.data);
|
||||
|
||||
// Dashboards scoped to user's teams
|
||||
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
|
||||
const allDashboards = dashRes.data ?? [];
|
||||
const allTeams = teamRes.data ?? [];
|
||||
const userTeams = myId
|
||||
? allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId))
|
||||
: [];
|
||||
setMyTeamId(userTeams[0]?.id ?? null);
|
||||
const teamIds = new Set(userTeams.map((t) => t.id));
|
||||
const visible = allDashboards.filter((d) =>
|
||||
!d.team_id || teamIds.has(d.team_id)
|
||||
);
|
||||
setDashboards(visible);
|
||||
}
|
||||
void load();
|
||||
}, [currentUserId]);
|
||||
|
||||
const collapsed = useSidebarCollapsed();
|
||||
|
||||
const views = [
|
||||
{
|
||||
label: "All tickets",
|
||||
href: "/",
|
||||
param: null,
|
||||
href: "/?view=all",
|
||||
param: "all",
|
||||
count: counts.all,
|
||||
icon: LayoutGridIcon,
|
||||
},
|
||||
{
|
||||
label: "My tickets",
|
||||
href: "/?view=my",
|
||||
href: currentUserId ? `/?view=my&owner=${currentUserId}` : "/?view=my",
|
||||
param: "my",
|
||||
count: counts.my,
|
||||
icon: UserIcon,
|
||||
},
|
||||
...(myTeamId ? [{
|
||||
label: "My team's tickets",
|
||||
href: `/?view=team&team_id=${myTeamId}`,
|
||||
param: "team",
|
||||
count: undefined as number | undefined,
|
||||
icon: UsersIcon,
|
||||
}] : []),
|
||||
{
|
||||
label: "Unassigned",
|
||||
href: "/?view=unassigned",
|
||||
@@ -172,24 +210,107 @@ function SidebarNav() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{queues.length > 0 && (
|
||||
<div>
|
||||
{dashboards.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!collapsed && (
|
||||
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
|
||||
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
||||
Dashboards
|
||||
</div>
|
||||
)}
|
||||
{dashboards.length > 0 && (
|
||||
<div className="px-2.5">
|
||||
<select
|
||||
value={dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
if (id === "_new") {
|
||||
setAddingDashboard(true);
|
||||
e.target.value = dashboards.find((d) => pathname.startsWith("/dashboards/") && pathname.endsWith(d.id))?.id ?? "";
|
||||
return;
|
||||
}
|
||||
if (id) window.location.href = `/dashboards/${id}`;
|
||||
}}
|
||||
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
|
||||
>
|
||||
{dashboards.map((dash) => (
|
||||
<option key={dash.id} value={dash.id}>{dash.name}</option>
|
||||
))}
|
||||
<option value="_new">+ New dashboard</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{addingDashboard && (
|
||||
<div className="mt-1 px-2">
|
||||
<input
|
||||
value={newDashboardName}
|
||||
onChange={(e) => setNewDashboardName(e.target.value)}
|
||||
placeholder="Dashboard name"
|
||||
className="h-7 w-full rounded border border-sidebar-border bg-sidebar-accent px-1.5 text-[12px] text-sidebar-foreground outline-none"
|
||||
autoFocus
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && newDashboardName.trim()) {
|
||||
const { data } = await createDashboard({ name: newDashboardName.trim(), is_default: false });
|
||||
if (data) {
|
||||
setDashboards((prev) => [...prev, data]);
|
||||
setNewDashboardName("");
|
||||
setAddingDashboard(false);
|
||||
window.location.href = `/dashboards/${data.id}`;
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setNewDashboardName("");
|
||||
setAddingDashboard(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!newDashboardName.trim()) {
|
||||
setNewDashboardName("");
|
||||
setAddingDashboard(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{savedViews.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
||||
Saved views
|
||||
</div>
|
||||
)}
|
||||
{savedViews.map((view) => {
|
||||
const active =
|
||||
pathname === "/" && searchParams.get("view_id") === view.id;
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={view.id}
|
||||
href={`/?view_id=${view.id}`}
|
||||
icon={ClockIcon}
|
||||
label={view.name}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queues.length > 0 && (
|
||||
<div className="mt-5">
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 mb-1 text-[10px] font-medium text-sidebar-foreground/30 uppercase tracking-wider">
|
||||
Queues
|
||||
</div>
|
||||
)}
|
||||
{queues.map((queue) => {
|
||||
const active =
|
||||
pathname === "/" && searchParams.get("queue") === queue.id;
|
||||
const QueueIcon = () => (
|
||||
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
|
||||
);
|
||||
return (
|
||||
<SidebarNavItem
|
||||
key={queue.id}
|
||||
href={`/?queue=${queue.id}`}
|
||||
icon={QueueIcon}
|
||||
icon={CircleIcon}
|
||||
label={queue.name}
|
||||
count={queue.count}
|
||||
active={active}
|
||||
@@ -205,40 +326,268 @@ function SidebarNav() {
|
||||
function SidebarBottom() {
|
||||
const pathname = usePathname();
|
||||
const collapsed = useSidebarCollapsed();
|
||||
const { user, logout, isAdmin } = useAuth();
|
||||
const [tokenOpen, setTokenOpen] = useState(false);
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [newTokenName, setNewTokenName] = useState("");
|
||||
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
const loadTokens = async () => {
|
||||
const { data } = await getApiTokens();
|
||||
if (data) setTokens(data);
|
||||
};
|
||||
|
||||
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
|
||||
|
||||
const handleCreateToken = async () => {
|
||||
if (!newTokenName.trim()) return;
|
||||
setTokenError(null);
|
||||
const { data, error } = await createApiToken(newTokenName.trim());
|
||||
if (error) { setTokenError(error); return; }
|
||||
if (data) {
|
||||
setNewTokenValue(data.token);
|
||||
setNewTokenName("");
|
||||
await loadTokens();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: string) => {
|
||||
await revokeApiToken(id);
|
||||
await loadTokens();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-sidebar-border p-2">
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center mt-0.5 px-2 py-1.5",
|
||||
collapsed ? "justify-center" : "gap-2"
|
||||
)}
|
||||
title={collapsed ? "User" : undefined}
|
||||
>
|
||||
<div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-sidebar-primary-foreground text-[10px] font-semibold">
|
||||
U
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="text-[13px] text-sidebar-foreground/65 truncate">
|
||||
User
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex", collapsed ? "justify-center mt-1" : "mt-1")}>
|
||||
<div className="border-t border-sidebar-border/50 p-2 space-y-1">
|
||||
{isAdmin && (
|
||||
<SidebarNavItem
|
||||
href="/admin"
|
||||
icon={SettingsIcon}
|
||||
label="Admin"
|
||||
active={pathname === "/admin"}
|
||||
/>
|
||||
)}
|
||||
{user ? (
|
||||
<>
|
||||
{!collapsed && (
|
||||
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
|
||||
{user.username}
|
||||
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTokenOpen(true)}
|
||||
className={cn(
|
||||
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
<span className="opacity-50">API tokens</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className={cn(
|
||||
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
|
||||
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
|
||||
collapsed ? "justify-center" : ""
|
||||
)}
|
||||
>
|
||||
<span className="opacity-50">Sign out</span>
|
||||
</button>
|
||||
|
||||
{/* Token dialog */}
|
||||
{tokenOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
|
||||
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||
<div className="border-b border-border/50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{newTokenValue ? (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-xs font-semibold text-foreground">Token created — copy it now:</p>
|
||||
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
placeholder="Token name..."
|
||||
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateToken}
|
||||
disabled={!newTokenName.trim()}
|
||||
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
|
||||
{tokens.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{tokens.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
|
||||
<div>
|
||||
<p className="text-xs font-medium">{t.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Created {new Date(t.created_at).toLocaleDateString()}
|
||||
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRevoke(t.id)}
|
||||
className="text-[10px] text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<SidebarNavItem
|
||||
href="/login"
|
||||
icon={UserIcon}
|
||||
label="Sign in"
|
||||
active={pathname === "/login"}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
|
||||
const [unread, setUnread] = useState(0);
|
||||
const [notifs, setNotifs] = useState<Notification[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const load = async () => {
|
||||
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
|
||||
if (countRes.data) setUnread(countRes.data.count);
|
||||
if (notifRes.data) setNotifs(notifRes.data);
|
||||
};
|
||||
void load();
|
||||
// Poll every 30s
|
||||
const interval = setInterval(() => { void load(); }, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await markNotificationRead(id);
|
||||
setUnread((c) => Math.max(0, c - 1));
|
||||
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
|
||||
};
|
||||
|
||||
const handleMarkAll = async () => {
|
||||
await markAllNotificationsRead();
|
||||
setUnread(0);
|
||||
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
|
||||
>
|
||||
<BellIcon className="h-4 w-4" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-foreground">Notifications</span>
|
||||
{unread > 0 && (
|
||||
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{notifs.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No notifications yet.
|
||||
</div>
|
||||
) : (
|
||||
notifs.slice(0, 20).map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => {
|
||||
handleMarkRead(n.id);
|
||||
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
|
||||
}}
|
||||
className={cn(
|
||||
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
|
||||
!n.read && "bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className={cn(
|
||||
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
|
||||
n.read ? "bg-border" : "bg-primary"
|
||||
)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">{n.title}</p>
|
||||
{n.body && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
|
||||
)}
|
||||
{n.ticket_id && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
|
||||
{formatTicketId(n.ticket_id)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3 w-3" />K
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
@@ -267,43 +616,25 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-150 shadow-[16px_0_42px_color-mix(in_oklch,var(--sidebar)_18%,transparent)]",
|
||||
sidebarCollapsed ? "w-[60px]" : "w-60"
|
||||
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border/50 transition-all duration-200",
|
||||
sidebarCollapsed ? "w-[56px]" : "w-[232px]"
|
||||
)}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="h-14 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-md bg-sidebar-primary flex items-center justify-center shadow-[0_0_0_1px_color-mix(in_oklch,var(--sidebar-primary)_55%,white_20%)]">
|
||||
<span className="text-sidebar-primary-foreground text-[12px] font-bold">
|
||||
T
|
||||
</span>
|
||||
<div className="h-12 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border/50">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="w-6 h-6 rounded-md bg-sidebar-primary flex items-center justify-center">
|
||||
<span className="text-sidebar-primary-foreground text-[11px] font-bold">T</span>
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="leading-tight">
|
||||
<span className="block font-semibold text-sidebar-foreground text-sm">
|
||||
Tessera
|
||||
</span>
|
||||
<span className="block text-[10px] text-sidebar-foreground/45">
|
||||
ScripFoundry
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setCommandOpen(true)}
|
||||
className="flex h-7 items-center gap-1 rounded-md border border-sidebar-border px-2 text-[11px] text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<CommandIcon className="h-3.5 w-3.5" />
|
||||
K
|
||||
</button>
|
||||
)}
|
||||
<NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2">
|
||||
<nav className="flex-1 overflow-y-auto py-2.5 px-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-1.5 px-2">
|
||||
|
||||
210
web/src/components/layout-builder.tsx
Normal file
210
web/src/components/layout-builder.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { GripVerticalIcon, XIcon, ArrowDownIcon } from "lucide-react";
|
||||
|
||||
export interface LayoutField {
|
||||
key: string;
|
||||
label: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface SubtitleEntry {
|
||||
key: string;
|
||||
under: string; // which row1 column this subtitle field sits under
|
||||
}
|
||||
|
||||
interface LayoutBuilderProps {
|
||||
fields: LayoutField[];
|
||||
row1: LayoutField[];
|
||||
row2: SubtitleEntry[];
|
||||
onChange: (row1: LayoutField[], row2: SubtitleEntry[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function LayoutBuilder({ fields: allFields, row1, row2, onChange, onClose }: LayoutBuilderProps) {
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, key: string) => {
|
||||
setDragKey(key);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", key);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragKey(null);
|
||||
}, []);
|
||||
|
||||
const makeRow1Drop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const key = e.dataTransfer.getData("text/plain");
|
||||
if (!key) return;
|
||||
// Remove from row2 if present
|
||||
const newRow2 = row2.filter((e) => e.key !== key);
|
||||
const field = allFields.find((f) => f.key === key);
|
||||
if (!field) return;
|
||||
// Insert into row1 via drop position
|
||||
const container = e.currentTarget;
|
||||
const children = Array.from(container.children).filter((c) => (c as HTMLElement).dataset?.chipkey);
|
||||
const mouseX = e.clientX;
|
||||
let idx = children.length;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const rect = (children[i] as HTMLElement).getBoundingClientRect();
|
||||
if (mouseX < rect.left + rect.width / 2) { idx = i; break; }
|
||||
}
|
||||
const newRow1 = [...row1.filter((f) => f.key !== key)];
|
||||
newRow1.splice(idx, 0, field);
|
||||
onChange(newRow1, newRow2);
|
||||
}, [allFields, row1, row2, onChange]);
|
||||
|
||||
const makeSubtitleDrop = useCallback((underCol: string) => {
|
||||
return (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const key = e.dataTransfer.getData("text/plain");
|
||||
if (!key || key === "subject") return;
|
||||
// Remove from row1 if present
|
||||
const newRow1 = row1.filter((f) => f.key !== key);
|
||||
// Remove from row2 if present
|
||||
const newRow2 = row2.filter((e) => e.key !== key);
|
||||
// Add to row2 under this column
|
||||
newRow2.push({ key, under: underCol });
|
||||
onChange(newRow1, newRow2);
|
||||
};
|
||||
}, [row1, row2, onChange]);
|
||||
|
||||
const makePaletteDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const key = e.dataTransfer.getData("text/plain");
|
||||
if (!key) return;
|
||||
onChange(
|
||||
row1.filter((f) => f.key !== key),
|
||||
row2.filter((e) => e.key !== key),
|
||||
);
|
||||
}, [row1, row2, onChange]);
|
||||
|
||||
const renderChip = (label: string, key: string) => (
|
||||
<div
|
||||
key={key}
|
||||
data-chipkey={key}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, key)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="flex cursor-grab items-center gap-1 rounded border border-border/50 bg-card px-2 py-1 text-xs text-foreground shadow-sm transition-colors hover:border-primary/30 active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="h-3 w-3 text-muted-foreground/50" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Fields not in row1 or row2
|
||||
const usedKeys = new Set([...row1.map((f) => f.key), ...row2.map((e) => e.key)]);
|
||||
const palette = allFields.filter((f) => !usedKeys.has(f.key) && f.key !== "subject");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
|
||||
<div className="fixed left-1/2 top-1/2 z-[9999] w-[560px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2.5">
|
||||
<h3 className="text-sm font-semibold text-foreground">Layout builder</h3>
|
||||
<button onClick={onClose} className="rounded text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
{/* Row 1 */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Main row</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={makeRow1Drop}
|
||||
className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-dashed border-border/50 p-2 transition-colors"
|
||||
>
|
||||
{row1.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">Drop fields here</span>
|
||||
) : (
|
||||
row1.map((f) => renderChip(f.label, f.key))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — subtitle fields under specific columns */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Subtitle (drop under a column)</div>
|
||||
<div className="space-y-2">
|
||||
{row1.map((col) => {
|
||||
const entries = row2.filter((e) => e.under === col.key);
|
||||
return (
|
||||
<div key={col.key} className="flex items-start gap-2">
|
||||
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
|
||||
{col.label}
|
||||
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={makeSubtitleDrop(col.key)}
|
||||
className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1 transition-colors"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground/40">drop here</span>
|
||||
) : (
|
||||
entries.map((e) => {
|
||||
const field = allFields.find((f) => f.key === e.key);
|
||||
return renderChip(field?.label ?? e.key, e.key);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Orphans: subtitle fields under columns not in row1 */}
|
||||
{(() => {
|
||||
const orphanEntries = row2.filter((e) => !row1.some((c) => c.key === e.under));
|
||||
if (orphanEntries.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
|
||||
subject
|
||||
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
|
||||
</div>
|
||||
<div className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1">
|
||||
{orphanEntries.map((e) => {
|
||||
const field = allFields.find((f) => f.key === e.key);
|
||||
return renderChip(field?.label ?? e.key, e.key);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palette */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Available</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={makePaletteDrop}
|
||||
className="flex min-h-9 flex-wrap gap-1.5 rounded-md border border-dashed border-border/30 p-2 transition-colors"
|
||||
>
|
||||
{palette.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">All fields are placed</span>
|
||||
) : (
|
||||
palette.map((f) => renderChip(f.label, f.key))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/50 px-4 py-2.5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-7 rounded-md bg-primary px-3 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
398
web/src/components/scrip-wizard.tsx
Normal file
398
web/src/components/scrip-wizard.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { XIcon, ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Queue, CustomField, Template } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
error?: string | null;
|
||||
onCreate: (data: {
|
||||
name: string; condition_type: string; condition_config: Record<string, unknown>;
|
||||
action_type: string; action_config: Record<string, unknown>;
|
||||
template_id: string | null; queue_id: string | null;
|
||||
stage: string; sort_order: number;
|
||||
}) => Promise<void>;
|
||||
queues: Queue[];
|
||||
customFields: CustomField[];
|
||||
templates: Template[];
|
||||
}
|
||||
|
||||
const CONDITIONS = [
|
||||
{ type: "OnCreate", icon: "➕", label: "Ticket created", desc: "When a new ticket is created" },
|
||||
{ type: "OnStatusChange", icon: "🔄", label: "Status changes", desc: "When ticket status changes" },
|
||||
{ type: "OnResolve", icon: "✅", label: "Ticket resolved", desc: "When a ticket is resolved" },
|
||||
{ type: "OnCustomFieldChange", icon: "📝", label: "Custom field changes", desc: "When a field value changes" },
|
||||
{ type: "OnLinkCreate", icon: "🔗", label: "Ticket linked", desc: "When linked to another ticket" },
|
||||
{ type: "OnOverdue", icon: "⏰", label: "Ticket overdue", desc: "When a date field passes due" },
|
||||
];
|
||||
|
||||
const ACTIONS = [
|
||||
{ type: "SendEmail", icon: "📧", label: "Send email", desc: "Send a templated email notification" },
|
||||
{ type: "SetCustomField", icon: "🏷️", label: "Set custom field", desc: "Update a field on the ticket" },
|
||||
{ type: "Webhook", icon: "🌐", label: "Call webhook", desc: "POST to an external URL" },
|
||||
{ type: "FetchMetadata", icon: "📡", label: "Fetch metadata", desc: "Pull data from an API" },
|
||||
{ type: "RunScript", icon: "⚡", label: "Run script", desc: "Execute custom JavaScript" },
|
||||
];
|
||||
|
||||
export function ScripWizard({ open, onClose, onCreate, queues, customFields, templates, error: externalError }: Props) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Trigger
|
||||
const [conditionType, setConditionType] = useState("OnCreate");
|
||||
const [fromStatus, setFromStatus] = useState("");
|
||||
const [toStatus, setToStatus] = useState("");
|
||||
const [conditionFieldKey, setConditionFieldKey] = useState("");
|
||||
|
||||
// Action
|
||||
const [actionType, setActionType] = useState("SendEmail");
|
||||
const [emailTo, setEmailTo] = useState("requestor");
|
||||
const [emailRecipients, setEmailRecipients] = useState("");
|
||||
const [emailSubject, setEmailSubject] = useState("");
|
||||
const [emailBody, setEmailBody] = useState("");
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
const [fieldKey, setFieldKey] = useState("");
|
||||
const [fieldValue, setFieldValue] = useState("");
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
|
||||
// Scope
|
||||
const [name, setName] = useState("");
|
||||
const [queueId, setQueueId] = useState("");
|
||||
const [stage, setStage] = useState("TransactionCreate");
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
|
||||
let conditionConfig: Record<string, unknown> = {};
|
||||
if (conditionType === "OnStatusChange" || conditionType === "OnResolve") {
|
||||
if (fromStatus) conditionConfig.from_status = fromStatus;
|
||||
if (toStatus) conditionConfig.to_status = toStatus;
|
||||
} else if (conditionType === "OnOverdue") {
|
||||
if (conditionFieldKey) conditionConfig.field_key = conditionFieldKey;
|
||||
}
|
||||
|
||||
let actionConfig: Record<string, unknown> = {};
|
||||
if (actionType === "SendEmail") {
|
||||
const sources = emailTo === "requestor" ? ["requestor"] : emailTo === "owner" ? ["owner"] : [];
|
||||
actionConfig = {
|
||||
recipients: emailRecipients ? emailRecipients.split(",").map((s) => s.trim()).filter(Boolean) : [],
|
||||
recipient_sources: sources,
|
||||
subject: emailSubject || "",
|
||||
body: emailBody || "",
|
||||
};
|
||||
} else if (actionType === "SetCustomField") {
|
||||
actionConfig = { field_key: fieldKey || "", value: fieldValue || "" };
|
||||
} else if (actionType === "Webhook") {
|
||||
actionConfig = { url: webhookUrl || "", method: "POST" };
|
||||
}
|
||||
|
||||
await onCreate({
|
||||
name: name || `Scrip: ${conditionType} → ${actionType}`,
|
||||
condition_type: conditionType,
|
||||
condition_config: conditionConfig,
|
||||
action_type: actionType,
|
||||
action_config: actionConfig,
|
||||
template_id: templateId || null,
|
||||
queue_id: queueId || null,
|
||||
stage,
|
||||
sort_order: 0,
|
||||
});
|
||||
setSaving(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setStep(1); setConditionType("OnCreate"); setFromStatus(""); setToStatus(""); setConditionFieldKey("");
|
||||
setActionType("SendEmail"); setEmailTo("requestor"); setEmailRecipients(""); setEmailSubject("");
|
||||
setEmailBody(""); setTemplateId(""); setFieldKey(""); setFieldValue(""); setWebhookUrl("");
|
||||
setName(""); setQueueId(""); setStage("TransactionCreate");
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
|
||||
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
|
||||
const dateFields = customFields.filter((cf) => {
|
||||
const ft = cf.field_type.toLowerCase();
|
||||
return ft === "date" || ft === "datetime";
|
||||
});
|
||||
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { onClose(); reset(); }} />
|
||||
<div className="fixed left-1/2 top-1/2 z-50 w-[600px] max-h-[80vh] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-lg border border-border bg-popover shadow-xl">
|
||||
{/* Header with steps */}
|
||||
<div className="border-b border-border/50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">New automation</h2>
|
||||
<button onClick={() => { onClose(); reset(); }} className="text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{stepLabels.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold",
|
||||
step > i + 1 ? "bg-primary text-primary-foreground" :
|
||||
step === i + 1 ? "bg-primary text-primary-foreground ring-2 ring-primary/30" :
|
||||
"bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{step > i + 1 ? <CheckIcon className="h-3 w-3" /> : i + 1}
|
||||
</div>
|
||||
<span className={cn("text-[11px] font-medium", step === i + 1 ? "text-foreground" : "text-muted-foreground")}>{label}</span>
|
||||
{i < 3 && <div className="h-px w-6 bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Step 1: Trigger */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">When should this automation run?</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{CONDITIONS.map((c) => (
|
||||
<button
|
||||
key={c.type}
|
||||
type="button"
|
||||
onClick={() => { setConditionType(c.type); setFromStatus(""); setToStatus(""); setConditionFieldKey(""); }}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
conditionType === c.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{c.icon}</span>
|
||||
<div className="mt-1 text-sm font-semibold">{c.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{c.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(conditionType === "OnStatusChange" || conditionType === "OnResolve") && (
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px]">From status</Label>
|
||||
<Select value={fromStatus || "_any"} onValueChange={(v) => setFromStatus((v === "_any" || !v) ? "" : v)}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Any status" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_any">Any status</SelectItem>
|
||||
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px]">To status</Label>
|
||||
<Select value={toStatus || "_any"} onValueChange={(v) => setToStatus((v === "_any" || !v) ? "" : v)}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder={conditionType === "OnResolve" ? "Any resolved" : "Any status"} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_any">{conditionType === "OnResolve" ? "Any resolved" : "Any status"}</SelectItem>
|
||||
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conditionType === "OnOverdue" && dateFields.length > 0 && (
|
||||
<div>
|
||||
<Label className="text-[10px]">Date field to check</Label>
|
||||
<Select value={conditionFieldKey} onValueChange={(v) => setConditionFieldKey(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a date field..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{dateFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Action */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">What should happen when triggered?</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ACTIONS.map((a) => (
|
||||
<button
|
||||
key={a.type}
|
||||
type="button"
|
||||
onClick={() => setActionType(a.type)}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 text-left transition-colors",
|
||||
actionType === a.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{a.icon}</span>
|
||||
<div className="mt-1 text-sm font-semibold">{a.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{a.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Configure */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Configure the details.</p>
|
||||
|
||||
{actionType === "SendEmail" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Recipients</Label>
|
||||
<Select value={emailTo} onValueChange={(v) => setEmailTo(v ?? "requestor")}>
|
||||
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="requestor">Ticket requestor (creator)</SelectItem>
|
||||
<SelectItem value="owner">Ticket owner</SelectItem>
|
||||
<SelectItem value="manual">Custom recipients</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{emailTo === "manual" && (
|
||||
<div>
|
||||
<Label>Email addresses (comma-separated)</Label>
|
||||
<Input placeholder="user@example.com, other@example.com" value={emailRecipients} onChange={(e) => setEmailRecipients(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Subject</Label>
|
||||
<Input placeholder="Ticket #{{ticket.id}}: {{ticket.subject}}" value={emailSubject} onChange={(e) => setEmailSubject(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Body</Label>
|
||||
<Textarea rows={3} placeholder="The ticket has been updated..." value={emailBody} onChange={(e) => setEmailBody(e.target.value)} />
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">Variables: {"{{ticket.id}} {{ticket.subject}} {{ticket.status}} {{queue.name}} {{transaction.old_value}} {{transaction.new_value}}"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Template (optional)</Label>
|
||||
<Select value={templateId} onValueChange={(v) => setTemplateId(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="No template — use subject/body above" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">No template</SelectItem>
|
||||
{emailTemplates.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === "SetCustomField" && (() => {
|
||||
const selectedField = customFields.find((cf) => cf.key === fieldKey);
|
||||
const fieldOptions: string[] = Array.isArray(selectedField?.values) ? selectedField.values.map((v: any) => String(v)) : [];
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>Field</Label>
|
||||
<Select value={fieldKey} onValueChange={(v) => { setFieldKey((v && v !== "_any") ? v : ""); setFieldValue(""); }}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a field" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label>Value</Label>
|
||||
{fieldOptions.length > 0 ? (
|
||||
<Select value={fieldValue} onValueChange={(v) => setFieldValue(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a value" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input placeholder="Value to set" value={fieldValue} onChange={(e) => setFieldValue(e.target.value)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{actionType === "Webhook" && (
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Input placeholder="https://hooks.slack.com/..." value={webhookUrl} onChange={(e) => setWebhookUrl(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/30 pt-3 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label>Name (optional)</Label>
|
||||
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label>Queue scope</Label>
|
||||
<Select value={queueId} onValueChange={(v) => setQueueId(v || "")}>
|
||||
<SelectTrigger className="h-8"><SelectValue placeholder="All queues" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All queues (global)</SelectItem>
|
||||
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Review */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm space-y-2">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground/60">Summary</div>
|
||||
<p><strong>When:</strong> {CONDITIONS.find((c) => c.type === conditionType)?.label}</p>
|
||||
<p><strong>Then:</strong> {ACTIONS.find((a) => a.type === actionType)?.label}</p>
|
||||
{queueId && <p><strong>Queue:</strong> {selectedQueue?.name}</p>}
|
||||
<p><strong>Stage:</strong> {stage === "TransactionCreate" ? "Per transaction" : "After batch"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{externalError && (
|
||||
<div className="border-t border-destructive/20 bg-destructive/10 px-6 py-2 text-sm text-destructive">{externalError}</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between border-t border-border/50 px-6 py-3">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setStep(step - 1)}>
|
||||
<ArrowLeftIcon className="h-3.5 w-3.5 mr-1" /> Back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{step < 4 ? (
|
||||
<Button size="sm" onClick={() => setStep(step + 1)}>
|
||||
Next <ArrowRightIcon className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="bg-primary" disabled={saving} onClick={handleCreate}>
|
||||
<CheckIcon className="h-3.5 w-3.5 mr-1" />
|
||||
{saving ? "Creating..." : "Create automation"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
web/src/components/searchable-select.tsx
Normal file
163
web/src/components/searchable-select.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
options: SelectOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
allowClear?: boolean;
|
||||
clearLabel?: string;
|
||||
}
|
||||
|
||||
export function SearchableSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select...",
|
||||
searchPlaceholder = "Search...",
|
||||
disabled = false,
|
||||
className,
|
||||
allowClear = true,
|
||||
clearLabel = "None",
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [highlightIdx, setHighlightIdx] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const filtered = search.trim()
|
||||
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
: options;
|
||||
|
||||
// Reset highlight when search changes
|
||||
useEffect(() => {
|
||||
setHighlightIdx(0);
|
||||
}, [search]);
|
||||
|
||||
// Focus search input when opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
setSearch("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const select = useCallback((optValue: string) => {
|
||||
onChange(optValue);
|
||||
setOpen(false);
|
||||
}, [onChange]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightIdx((prev) => Math.min(prev + 1, filtered.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightIdx((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered[highlightIdx]) {
|
||||
select(filtered[highlightIdx].value);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setOpen(!open)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2.5 text-sm outline-none transition-colors",
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "hover:border-ring/50 focus:border-ring",
|
||||
!selected && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||
<ChevronDownIcon className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-lg">
|
||||
<div className="flex items-center gap-1.5 border-b border-border/50 px-2">
|
||||
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")} className="shrink-0 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
{allowClear && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select("")}
|
||||
className="flex w-full items-center gap-2 border-b border-border/30 px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2.5 py-3 text-center text-xs text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((opt, idx) => (
|
||||
<button
|
||||
key={opt.value || "__empty__"}
|
||||
type="button"
|
||||
onClick={() => select(opt.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center px-2.5 py-1.5 text-xs transition-colors",
|
||||
idx === highlightIdx ? "bg-accent text-foreground" : "text-foreground hover:bg-accent/50",
|
||||
opt.value === value && "font-semibold"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
43
web/src/components/widgets/trend-chart-widget.tsx
Normal file
43
web/src/components/widgets/trend-chart-widget.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type { WidgetData } from "@/lib/types";
|
||||
|
||||
export function TrendChartWidget({ data }: { data: WidgetData }) {
|
||||
const points = data.counts ?? {};
|
||||
const entries = Object.entries(points).sort(([a], [b]) => a.localeCompare(b));
|
||||
const maxVal = Math.max(1, ...Object.values(points));
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col rounded-lg border border-border/50 bg-card p-3">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase text-muted-foreground/60">
|
||||
{data.title}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-foreground tabular-nums">{data.total}</div>
|
||||
<div className="mt-2 flex flex-1 items-end gap-px">
|
||||
{entries.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">No data</span>
|
||||
) : (
|
||||
entries.map(([label, count]) => {
|
||||
const h = Math.max(4, (count / maxVal) * 100);
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-1 flex-col items-center justify-end"
|
||||
title={`${label}: ${count}`}
|
||||
>
|
||||
<div className="text-[9px] tabular-nums text-muted-foreground">{count}</div>
|
||||
<div
|
||||
className="w-full min-w-[3px] rounded-t bg-primary/60"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] text-muted-foreground/50 text-right">
|
||||
{entries.length > 0 && `${entries[0]?.[0]} — ${entries[entries.length - 1]?.[0]}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type {
|
||||
Ticket,
|
||||
Queue,
|
||||
Dashboard,
|
||||
DashboardWidget,
|
||||
WidgetData,
|
||||
Team,
|
||||
User,
|
||||
Transaction,
|
||||
SavedView,
|
||||
Scrip,
|
||||
Template,
|
||||
TemplatePreview,
|
||||
@@ -12,15 +17,32 @@ import type {
|
||||
QueueCustomField,
|
||||
PreviewResult,
|
||||
UpdateResult,
|
||||
Attachment,
|
||||
AttachmentUploadResult,
|
||||
TicketLink,
|
||||
LoginResult,
|
||||
Watcher,
|
||||
SlaPolicy,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = "/api";
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
// Merge with options headers if any
|
||||
const opts = { ...options };
|
||||
if (opts.headers) {
|
||||
Object.assign(headers, opts.headers as Record<string, string>);
|
||||
delete opts.headers;
|
||||
}
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
headers,
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
@@ -38,13 +60,21 @@ export async function getTickets(params?: {
|
||||
status?: string;
|
||||
q?: string;
|
||||
owner_id?: string;
|
||||
team_id?: string;
|
||||
custom_fields?: Record<string, string>;
|
||||
subject?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}): Promise<{ data: Ticket[] | null; error: string | null }> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.q) sp.set("q", params.q);
|
||||
if (params?.owner_id) sp.set("owner_id", params.owner_id);
|
||||
if (params?.team_id) sp.set("team_id", params.team_id);
|
||||
if (params?.subject) sp.set("subject", params.subject);
|
||||
if (params?.created) sp.set("created", params.created);
|
||||
if (params?.updated) sp.set("updated", params.updated);
|
||||
if (params?.custom_fields) {
|
||||
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
|
||||
if (value) sp.set(`cf.${fieldId}`, value);
|
||||
@@ -67,7 +97,7 @@ export async function createTicket(data: {
|
||||
return request<UpdateResult>("/tickets", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
|
||||
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null; team_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
|
||||
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
@@ -79,10 +109,126 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
|
||||
return request<Transaction[]>(`/tickets/${id}/transactions`);
|
||||
}
|
||||
|
||||
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||
export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
|
||||
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function batchUpdateTickets(data: {
|
||||
ticket_ids: number[];
|
||||
status?: string;
|
||||
owner_id?: string | null;
|
||||
team_id?: string | null;
|
||||
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
|
||||
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
|
||||
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
|
||||
}
|
||||
|
||||
// Notifications
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: number | null;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
|
||||
return request<Notification[]>("/notifications");
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
|
||||
return request<{ count: number }>("/notifications/unread-count");
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
|
||||
}
|
||||
|
||||
// API Tokens
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
|
||||
return request<ApiToken[]>("/auth/tokens");
|
||||
}
|
||||
|
||||
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
|
||||
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Watchers
|
||||
|
||||
export async function getWatchers(ticketId: number): Promise<{ data: Watcher[] | null; error: string | null }> {
|
||||
return request<Watcher[]>(`/tickets/${ticketId}/watchers`);
|
||||
}
|
||||
|
||||
export async function addWatcher(ticketId: number, userId?: string): Promise<{ data: Watcher | null; error: string | null }> {
|
||||
return request<Watcher>(`/tickets/${ticketId}/watchers`, { method: "POST", body: JSON.stringify(userId ? { user_id: userId } : {}) });
|
||||
}
|
||||
|
||||
export async function removeWatcher(ticketId: number, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/tickets/${ticketId}/watchers/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// SLA Policies
|
||||
|
||||
export async function getSlaPolicies(): Promise<{ data: SlaPolicy[] | null; error: string | null }> {
|
||||
return request<SlaPolicy[]>("/sla-policies");
|
||||
}
|
||||
|
||||
export async function createSlaPolicy(data: {
|
||||
name: string;
|
||||
queue_id?: string;
|
||||
description?: string;
|
||||
response_time_minutes?: number;
|
||||
resolution_time_minutes?: number;
|
||||
disabled?: boolean;
|
||||
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
|
||||
return request<SlaPolicy>("/sla-policies", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateSlaPolicy(id: string, data: {
|
||||
name?: string;
|
||||
queue_id?: string | null;
|
||||
description?: string;
|
||||
response_time_minutes?: number | null;
|
||||
resolution_time_minutes?: number | null;
|
||||
disabled?: boolean;
|
||||
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
|
||||
return request<SlaPolicy>(`/sla-policies/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteSlaPolicy(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/sla-policies/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
|
||||
return request<Queue[]>("/queues");
|
||||
}
|
||||
@@ -91,11 +237,33 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
|
||||
return request<User[]>("/users");
|
||||
}
|
||||
|
||||
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
export async function createUser(data: {
|
||||
username: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
password?: string | null;
|
||||
}): Promise<{ data: User | null; error: string | null }> {
|
||||
return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, data: {
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
role?: string;
|
||||
password?: string | null;
|
||||
}): Promise<{ data: User | null; error: string | null }> {
|
||||
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null; team_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
|
||||
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
@@ -165,6 +333,10 @@ export async function previewTemplate(data: {
|
||||
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/templates/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
|
||||
return request<Lifecycle[]>("/lifecycles");
|
||||
}
|
||||
@@ -216,6 +388,8 @@ export async function createCustomField(data: {
|
||||
values?: unknown | null;
|
||||
max_values?: number;
|
||||
pattern?: string | null;
|
||||
validation_config?: Record<string, unknown> | null;
|
||||
default_value?: string | null;
|
||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
@@ -227,6 +401,326 @@ export async function updateCustomField(id: string, data: {
|
||||
values?: unknown | null;
|
||||
max_values?: number;
|
||||
pattern?: string | null;
|
||||
validation_config?: Record<string, unknown> | null;
|
||||
default_value?: string | null;
|
||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> {
|
||||
return request<SavedView[]>("/views");
|
||||
}
|
||||
|
||||
export async function createView(data: {
|
||||
name: string;
|
||||
filters: { field: string; operator: string; value: string }[];
|
||||
sort_key?: string;
|
||||
columns?: { key: string; label: string; width: number; display: string }[];
|
||||
is_public?: boolean;
|
||||
}): Promise<{ data: SavedView | null; error: string | null }> {
|
||||
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateView(id: string, data: {
|
||||
name?: string;
|
||||
filters?: { field: string; operator: string; value: string }[];
|
||||
sort_key?: string;
|
||||
columns?: unknown[];
|
||||
is_public?: boolean;
|
||||
}): Promise<{ data: SavedView | null; error: string | null }> {
|
||||
return request<SavedView>(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
|
||||
return request<Dashboard[]>("/dashboards");
|
||||
}
|
||||
|
||||
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||
return request<Dashboard>(`/dashboards/${id}`);
|
||||
}
|
||||
|
||||
export async function createDashboard(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
team_id?: string | null;
|
||||
is_default?: boolean;
|
||||
}): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateDashboard(id: string, data: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
team_id?: string | null;
|
||||
is_default?: boolean;
|
||||
layout?: unknown[];
|
||||
}): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
|
||||
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
|
||||
}
|
||||
|
||||
export async function createWidget(dashboardId: string, data: {
|
||||
view_id: string;
|
||||
title: string;
|
||||
widget_type: string;
|
||||
position?: { x: number; y: number; w: number; h: number };
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
|
||||
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateWidget(dashboardId: string, widgetId: string, data: {
|
||||
title?: string;
|
||||
widget_type?: string;
|
||||
position?: { x: number; y: number; w: number; h: number };
|
||||
config?: Record<string, unknown>;
|
||||
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
|
||||
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
|
||||
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
|
||||
}
|
||||
|
||||
export async function getTeams(): Promise<{ data: Team[] | null; error: string | null }> {
|
||||
return request<Team[]>("/teams");
|
||||
}
|
||||
|
||||
export async function createTeam(data: { name: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
|
||||
return request<Team>("/teams", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function updateTeam(id: string, data: { name?: string; description?: string | null }): Promise<{ data: Team | null; error: string | null }> {
|
||||
return request<Team>(`/teams/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteTeam(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/teams/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function addTeamMember(teamId: string, userId: string): Promise<{ data: unknown | null; error: string | null }> {
|
||||
return request<unknown>(`/teams/${teamId}/members`, { method: "POST", body: JSON.stringify({ user_id: userId }) });
|
||||
}
|
||||
|
||||
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function uploadAttachments(
|
||||
ticketId: number,
|
||||
files: File[],
|
||||
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
export function getAttachmentUrl(attachmentId: string): string {
|
||||
return `/api/attachments/${attachmentId}`;
|
||||
}
|
||||
|
||||
export async function getTicketAttachments(
|
||||
ticketId: number,
|
||||
): Promise<{ data: Attachment[] | null; error: string | null }> {
|
||||
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
|
||||
}
|
||||
|
||||
export async function getTicketLinks(
|
||||
ticketId: number,
|
||||
): Promise<{ data: TicketLink[] | null; error: string | null }> {
|
||||
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
|
||||
}
|
||||
|
||||
export async function createTicketLink(
|
||||
ticketId: number,
|
||||
data: { target_ticket_id: number; link_type: string },
|
||||
): Promise<{ data: TicketLink | null; error: string | null }> {
|
||||
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
export async function deleteTicketLink(
|
||||
ticketId: number,
|
||||
linkId: string,
|
||||
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Queue Permissions (admin)
|
||||
|
||||
export interface QueuePermission {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
team_id: string;
|
||||
right_name: string;
|
||||
team_name?: string;
|
||||
queue_name?: string;
|
||||
}
|
||||
|
||||
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
|
||||
return request<QueuePermission[]>("/queue-permissions");
|
||||
}
|
||||
|
||||
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
|
||||
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
|
||||
}
|
||||
|
||||
export async function grantQueuePermission(
|
||||
queue_id: string,
|
||||
team_id: string,
|
||||
right_name: string,
|
||||
): Promise<{ data: QueuePermission | null; error: string | null }> {
|
||||
return request<QueuePermission>("/queue-permissions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queue_id, team_id, right_name }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// User Permissions (admin)
|
||||
|
||||
export interface UserPermission {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
user_id: string;
|
||||
right_name: string;
|
||||
username?: string;
|
||||
queue_name?: string;
|
||||
}
|
||||
|
||||
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
|
||||
return request<UserPermission[]>("/user-permissions");
|
||||
}
|
||||
|
||||
export async function grantUserPermission(
|
||||
queue_id: string,
|
||||
user_id: string,
|
||||
right_name: string,
|
||||
): Promise<{ data: UserPermission | null; error: string | null }> {
|
||||
return request<UserPermission>("/user-permissions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queue_id, user_id, right_name }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Auth
|
||||
|
||||
function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem("tessera_token");
|
||||
}
|
||||
|
||||
export function setStoredToken(token: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (token) {
|
||||
localStorage.setItem("tessera_token", token);
|
||||
} else {
|
||||
localStorage.removeItem("tessera_token");
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ data: LoginResult | null; error: string | null }> {
|
||||
const result = await request<LoginResult>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (result.data?.token) {
|
||||
setStoredToken(result.data.token);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
setStoredToken(null);
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
|
||||
const token = getStoredToken();
|
||||
if (!token) return { data: null, error: "Not authenticated" };
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setStoredToken(null);
|
||||
return { data: null, error: "Session expired" };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that includes the auth token.
|
||||
*/
|
||||
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
setStoredToken(null);
|
||||
}
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
61
web/src/lib/auth-context.tsx
Normal file
61
web/src/lib/auth-context.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { login as apiLogin, logout as apiLogout, getMe } from "./api";
|
||||
import type { User, LoginResult } from "./types";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<string | null>;
|
||||
logout: () => void;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: async () => null,
|
||||
logout: () => {},
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Check existing session on mount
|
||||
useEffect(() => {
|
||||
void Promise.resolve().then(async () => {
|
||||
const { data } = await getMe();
|
||||
if (data) {
|
||||
setUser(data);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string): Promise<string | null> => {
|
||||
const { data, error } = await apiLogin(username, password);
|
||||
if (error || !data) {
|
||||
return error || "Login failed";
|
||||
}
|
||||
setUser(data.user);
|
||||
return null; // null = success
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
apiLogout();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin: user?.role === "admin" }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
30
web/src/lib/markdown.ts
Normal file
30
web/src/lib/markdown.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
// Configure marked for safe rendering
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Render markdown string to sanitized HTML.
|
||||
* Strips raw HTML tags for XSS safety.
|
||||
*/
|
||||
export function renderMarkdown(markdown: string): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
// Strip raw HTML tags for XSS safety
|
||||
const sanitized = markdown.replace(/<[^>]*>/g, '');
|
||||
|
||||
try {
|
||||
const html = marked.parse(sanitized) as string;
|
||||
return html;
|
||||
} catch {
|
||||
// Fallback: escape and wrap in <p>
|
||||
const escaped = sanitized
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<p>${escaped}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,17 @@ export interface Ticket {
|
||||
queue_id: string;
|
||||
status: string;
|
||||
owner_id: string | null;
|
||||
team_id: string | null;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
started_at: string | null;
|
||||
resolved_at: string | null;
|
||||
sla_response_deadline?: string | null;
|
||||
sla_resolution_deadline?: string | null;
|
||||
sla_breached?: string | null;
|
||||
custom_fields?: CustomFieldValue[];
|
||||
blocked_by?: Array<{ id: number; subject: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface Queue {
|
||||
@@ -17,15 +22,22 @@ export interface Queue {
|
||||
name: string;
|
||||
description: string | null;
|
||||
lifecycle_id: string | null;
|
||||
team_id: string | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
@@ -34,8 +46,10 @@ export interface Transaction {
|
||||
old_value: string | null;
|
||||
new_value: string | null;
|
||||
data: unknown;
|
||||
time_worked_minutes: number;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface Scrip {
|
||||
@@ -51,6 +65,7 @@ export interface Scrip {
|
||||
stage: string;
|
||||
sort_order: number;
|
||||
disabled: boolean;
|
||||
applicable_trans_types: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -88,8 +103,22 @@ export interface CustomField {
|
||||
values: unknown | null;
|
||||
max_values: number;
|
||||
pattern: string | null;
|
||||
validation_config: Record<string, unknown> | null;
|
||||
default_value: string | null;
|
||||
}
|
||||
|
||||
export const CUSTOM_FIELD_TYPES = [
|
||||
'Text',
|
||||
'Textarea',
|
||||
'SelectOne',
|
||||
'SelectMultiple',
|
||||
'Date',
|
||||
'DateTime',
|
||||
'Number',
|
||||
] as const;
|
||||
|
||||
export type CustomFieldType = (typeof CUSTOM_FIELD_TYPES)[number];
|
||||
|
||||
export interface QueueCustomField {
|
||||
id: string;
|
||||
queue_id: string;
|
||||
@@ -128,3 +157,136 @@ export interface ScripResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SavedFilter {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
name: string;
|
||||
filters: SavedFilter[];
|
||||
sort_key: string;
|
||||
columns: unknown[];
|
||||
is_public: boolean;
|
||||
creator_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
members?: User[];
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
team_id: string | null;
|
||||
layout: unknown[];
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
widgets?: DashboardWidget[];
|
||||
}
|
||||
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
dashboard_id: string;
|
||||
view_id: string;
|
||||
title: string;
|
||||
widget_type: string;
|
||||
position: { x: number; y: number; w: number; h: number };
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WidgetTicket {
|
||||
id: number;
|
||||
subject: string;
|
||||
status: string;
|
||||
owner_id: string | null;
|
||||
owner_name: string | null;
|
||||
queue_name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WidgetData {
|
||||
type: string;
|
||||
title: string;
|
||||
total: number;
|
||||
view_id: string;
|
||||
tickets?: WidgetTicket[];
|
||||
counts?: Record<string, number>;
|
||||
groups?: Record<string, number>;
|
||||
group_by?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
transaction_id: string | null;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_path: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AttachmentUploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
export interface TicketLink {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
target_ticket_id: number;
|
||||
link_type: string;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
target_ticket?: { id: number; subject: string; status: string } | null;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
ticket_id: number | null;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Watcher {
|
||||
id: string;
|
||||
ticket_id: number;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
user?: { id: string; username: string; email: string | null } | null;
|
||||
}
|
||||
|
||||
export interface SlaPolicy {
|
||||
id: string;
|
||||
queue_id: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
response_time_minutes: number | null;
|
||||
resolution_time_minutes: number | null;
|
||||
disabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
id: string;
|
||||
name: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user