TypeScript/Bun project scaffold
- Stack: Bun, Hono, Drizzle ORM, Zod, Handlebars, Pino - Models: ticket, queue, transaction, scrip, template, custom_field, user, lifecycle - Scrip engine: prepare/commit two-phase dispatch, template rendering, mock actions - Lifecycle validator: state machine transition validation with wildcard support - Routes: health, tickets (full CRUD + preview + transactions), queues, scrips, custom-fields, lifecycles - Middleware: Pino logging, error handler - Database: Drizzle ORM schema + initial migration (10 tables) - Type-check: passes (tsc --noEmit, zero errors)
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera
|
||||
SERVER_HOST=127.0.0.1
|
||||
SERVER_PORT=8080
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
bun.lock
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
106
CLAUDE.md
Normal file
106
CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||
202
docs/scaffold-spec.md
Normal file
202
docs/scaffold-spec.md
Normal file
@@ -0,0 +1,202 @@
|
||||
Scaffold the Tessera Rust project workspace at /home/gjermund/projects/tessera.
|
||||
|
||||
## What to Create
|
||||
|
||||
### 1. Workspace Cargo.toml
|
||||
At /home/gjermund/projects/tessera/Cargo.toml:
|
||||
- [workspace] with members: crates/tessera-core, crates/tessera-api
|
||||
- [workspace.dependencies] with versions for:
|
||||
- axum 0.8.x
|
||||
- tokio 1.x (full features)
|
||||
- serde 1.x (derive feature)
|
||||
- serde_json 1.x
|
||||
- sqlx 0.8.x (runtime-tokio, tls-rustls, postgres features)
|
||||
- uuid 1.x (v4, serde features)
|
||||
- chrono 0.4.x (serde feature)
|
||||
- tracing 0.1.x, tracing-subscriber 0.3.x
|
||||
- tower 0.5.x, tower-http 0.6.x (cors feature)
|
||||
- tonic 0.12.x
|
||||
- prost 0.13.x
|
||||
- anyhow 1.x
|
||||
- thiserror 2.x
|
||||
- lettre 0.11.x (rustls-tls feature)
|
||||
- tera 1.x
|
||||
- figment 0.10.x (toml, env features)
|
||||
- dotenvy 0.15.x
|
||||
- clap 4.x (derive feature)
|
||||
|
||||
### 2. tessera-core crate
|
||||
At /home/gjermund/projects/tessera/crates/tessera-core/Cargo.toml:
|
||||
- name = "tessera-core"
|
||||
- Dependencies: serde, serde_json, sqlx, uuid, chrono, anyhow, thiserror, tracing, tera, lettre
|
||||
- [lib] crate-type = ["lib"]
|
||||
|
||||
At /home/gjermund/projects/tessera/crates/tessera-core/src/lib.rs:
|
||||
- Module declarations: pub mod models; pub mod scrip; pub mod lifecycle; pub mod query; pub mod db;
|
||||
- Re-export key types
|
||||
|
||||
Create these source files (with proper module structure):
|
||||
|
||||
#### crates/tessera-core/src/models/mod.rs
|
||||
- Module declarations: mod ticket; mod queue; mod transaction; mod scrip; mod template; mod custom_field; mod user;
|
||||
- Re-exports
|
||||
|
||||
#### crates/tessera-core/src/models/ticket.rs
|
||||
- Ticket struct with fields: id (Uuid), subject (String), queue_id (Uuid), status (String), owner_id (Option<Uuid>), creator_id (Uuid), created_at (DateTime<Utc>), updated_at (DateTime<Utc>), started_at (Option<DateTime<Utc>>), resolved_at (Option<DateTime<Utc>>)
|
||||
|
||||
#### crates/tessera-core/src/models/queue.rs
|
||||
- Queue struct: id (Uuid), name (String), description (Option<String>), lifecycle_id (Option<Uuid>), created_at (DateTime<Utc>)
|
||||
|
||||
#### crates/tessera-core/src/models/transaction.rs
|
||||
- Transaction struct: id (Uuid), ticket_id (Uuid), transaction_type (String), field (Option<String>), old_value (Option<String>), new_value (Option<String>), data (Option<serde_json::Value>), creator_id (Uuid), created_at (DateTime<Utc>)
|
||||
|
||||
#### crates/tessera-core/src/models/scrip.rs
|
||||
- Scrip struct: id (Uuid), queue_id (Option<Uuid>), name (String), description (Option<String>), condition_type (String), condition_config (serde_json::Value), action_type (String), action_config (serde_json::Value), template_id (Option<Uuid>), stage (String), sort_order (i32), disabled (bool), created_at (DateTime<Utc>)
|
||||
- ScripStage enum: TransactionCreate, TransactionBatch
|
||||
|
||||
#### crates/tessera-core/src/models/template.rs
|
||||
- Template struct: id (Uuid), name (String), queue_id (Option<Uuid>), subject_template (String), body_template (String), created_at (DateTime<Utc>)
|
||||
|
||||
#### crates/tessera-core/src/models/custom_field.rs
|
||||
- CustomField struct: id (Uuid), name (String), field_type (String), values (Option<serde_json::Value>), max_values (i32), pattern (Option<String>), created_at (DateTime<Utc>)
|
||||
- QueueCustomField struct: id (Uuid), queue_id (Uuid), custom_field_id (Uuid), sort_order (i32)
|
||||
- CustomFieldValue struct: id (Uuid), custom_field_id (Uuid), ticket_id (Uuid), value (String), created_at (DateTime<Utc>)
|
||||
|
||||
#### crates/tessera-core/src/models/user.rs
|
||||
- User struct: id (Uuid), username (String), email (Option<String>), created_at (DateTime<Utc>)
|
||||
|
||||
#### crates/tessera-core/src/scrip/mod.rs
|
||||
- Module declarations: mod engine; mod conditions; mod actions; mod templates;
|
||||
- Re-export ScripEngine, PreparedScrip
|
||||
|
||||
#### crates/tessera-core/src/scrip/engine.rs
|
||||
- ScripEngine struct with methods:
|
||||
- new(pool: PgPool)
|
||||
- async fn prepare(&self, ticket_id: Uuid, transactions: &[Transaction]) -> Result<Vec<PreparedScrip>>
|
||||
- Load matching scrips from DB (global + queue-specific, filtered by transaction types matching condition_type, sorted by sort_order)
|
||||
- For each: evaluate condition → if matches, build PreparedScrip with template substitution
|
||||
- async fn commit(&self, prepared: Vec<PreparedScrip>) -> Result<Vec<ScripResult>>
|
||||
- Execute each action, record results
|
||||
- PreparedScrip struct: scrip_id, scrip_name, action_type, action_payload (serde_json::Value), dry_run bool
|
||||
- ScripResult struct: scrip_id, success bool, message String
|
||||
|
||||
#### crates/tessera-core/src/scrip/conditions.rs
|
||||
- ConditionEvaluator trait with fn evaluate(&self, ticket: &Ticket, transactions: &[Transaction]) -> bool
|
||||
- OnCreate condition (true if any transaction is type "Create")
|
||||
- OnStatusChange condition (true if any transaction is type "StatusChange")
|
||||
- OnResolve condition (true if any transaction changes status to a "resolved" lifecycle state)
|
||||
|
||||
#### crates/tessera-core/src/scrip/actions.rs
|
||||
- ActionExecutor trait with async fn execute(&self, payload: &serde_json::Value) -> Result<String>
|
||||
- SendEmail action (placeholder — logs the email it would send)
|
||||
- Webhook action (placeholder — logs the webhook it would fire)
|
||||
- SetCustomField action (placeholder — logs the CF it would set)
|
||||
|
||||
#### crates/tessera-core/src/scrip/templates.rs
|
||||
- TemplateRenderer struct that uses Tera
|
||||
- async fn render(&self, template: &Template, context: &serde_json::Value) -> Result<(String, String)> — returns (subject, body)
|
||||
|
||||
#### crates/tessera-core/src/lifecycle/mod.rs
|
||||
- Lifecycle struct: id (Uuid), name (String), definition (serde_json::Value)
|
||||
- LifecycleValidator struct with methods:
|
||||
- fn validate_transition(&self, lifecycle_def: &serde_json::Value, from: &str, to: &str) -> Result<()>
|
||||
- fn is_valid_status(&self, lifecycle_def: &serde_json::Value, status: &str) -> bool
|
||||
|
||||
#### crates/tessera-core/src/query/mod.rs
|
||||
- Placeholder module with comment: "TicketSQL query builder — post-MVP"
|
||||
|
||||
#### crates/tessera-core/src/db/mod.rs
|
||||
- pub mod migrations;
|
||||
- Pub async fn run_migrations(pool: &PgPool) -> Result<()>
|
||||
|
||||
#### crates/tessera-core/src/db/migrations.rs
|
||||
- Function signatures for migration runner (placeholder — actual migrations in sql files)
|
||||
|
||||
### 3. tessera-api crate
|
||||
At /home/gjermund/projects/tessera/crates/tessera-api/Cargo.toml:
|
||||
- name = "tessera-api"
|
||||
- Dependencies: tessera-core (path), axum, tokio, serde, serde_json, uuid, anyhow, tracing, tracing-subscriber, tower-http, clap, figment, dotenvy
|
||||
- [[bin]] name = "tessera-api"
|
||||
|
||||
At /home/gjermund/projects/tessera/crates/tessera-api/src/main.rs:
|
||||
- Clap CLI with subcommands: serve, migrate
|
||||
- serve: start axum HTTP server
|
||||
- migrate: run database migrations
|
||||
- tracing_subscriber init with JSON formatting
|
||||
- Load config from TESSERA_CONFIG env or tessera.toml
|
||||
|
||||
Create source files:
|
||||
|
||||
#### crates/tessera-api/src/routes/mod.rs
|
||||
- Module declarations: mod health; mod tickets; mod queues; mod scrips; mod custom_fields; mod lifecycles;
|
||||
|
||||
#### crates/tessera-api/src/routes/health.rs
|
||||
- GET /health → 200 with {"status": "ok", "version": "0.1.0"}
|
||||
|
||||
#### crates/tessera-api/src/routes/tickets.rs
|
||||
- Router function returning axum Router with placeholder handlers:
|
||||
- GET /api/tickets
|
||||
- POST /api/tickets
|
||||
- GET /api/tickets/:id
|
||||
- PATCH /api/tickets/:id
|
||||
- POST /api/tickets/:id/preview
|
||||
- GET /api/tickets/:id/transactions
|
||||
- POST /api/tickets/:id/comment
|
||||
- Each handler returns 501 NotImplemented with a descriptive message
|
||||
|
||||
#### crates/tessera-api/src/routes/queues.rs
|
||||
- GET /api/queues → 501
|
||||
- POST /api/queues → 501
|
||||
|
||||
#### crates/tessera-api/src/routes/scrips.rs
|
||||
- GET /api/scrips → 501
|
||||
- POST /api/scrips → 501
|
||||
- GET /api/scrips/:id → 501
|
||||
- PATCH /api/scrips/:id → 501
|
||||
|
||||
#### crates/tessera-api/src/routes/custom_fields.rs
|
||||
- GET /api/custom-fields → 501
|
||||
- POST /api/custom-fields → 501
|
||||
|
||||
#### crates/tessera-api/src/routes/lifecycles.rs
|
||||
- GET /api/lifecycles → 501
|
||||
- POST /api/lifecycles → 501
|
||||
|
||||
#### crates/tessera-api/src/config.rs
|
||||
- AppConfig struct: database_url (String), server_host (String), server_port (u16)
|
||||
- impl from figment
|
||||
|
||||
### 4. SQL Migrations
|
||||
At /home/gjermund/projects/tessera/migrations/0001_initial_schema.sql:
|
||||
- All CREATE TABLE statements from the architecture document (tickets, queues, lifecycles, transactions, scrips, templates, custom_fields, queue_custom_fields, custom_field_values, users)
|
||||
- CREATE INDEX statements on: tickets(queue_id), tickets(status), transactions(ticket_id), transactions(created_at), scrips(queue_id), custom_field_values(ticket_id), custom_field_values(custom_field_id)
|
||||
|
||||
### 5. Configuration
|
||||
At /home/gjermund/projects/tessera/tessera.example.toml:
|
||||
```toml
|
||||
[database]
|
||||
url = "postgres://tessera:tessera@localhost:5432/tessera"
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
```
|
||||
|
||||
### 6. .gitignore
|
||||
Standard Rust .gitignore: target/, .env, *.log
|
||||
|
||||
## Rules
|
||||
- Use `cargo init` where appropriate, or create files manually
|
||||
- All code must compile: run `cargo check --workspace` after creating everything
|
||||
- Fix any compilation errors
|
||||
- Use `cargo fmt` and `cargo clippy` on the workspace
|
||||
- Do NOT create a separate Cargo.lock — it will be generated
|
||||
- The workspace Cargo.toml must NOT have a [package] section
|
||||
|
||||
## Verification
|
||||
After creating all files, run:
|
||||
1. `cargo check --workspace` — must succeed
|
||||
2. `cargo fmt --check` — must pass
|
||||
3. `cargo clippy --workspace` — should pass or have only minor warnings
|
||||
|
||||
If any verification fails, fix the issues and re-run until clean.
|
||||
10
drizzle.config.ts
Normal file
10
drizzle.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
122
drizzle/migrations/0000_acoustic_wendell_vaughn.sql
Normal file
122
drizzle/migrations/0000_acoustic_wendell_vaughn.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
CREATE TABLE "custom_field_values" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"custom_field_id" uuid NOT NULL,
|
||||
"ticket_id" uuid NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "custom_field_values_cf_id_ticket_id_value_unique" UNIQUE("custom_field_id","ticket_id","value")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "custom_fields" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"field_type" text NOT NULL,
|
||||
"values" jsonb,
|
||||
"max_values" integer DEFAULT 1 NOT NULL,
|
||||
"pattern" text,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lifecycles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"definition" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "lifecycles_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "queue_custom_fields" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"queue_id" uuid NOT NULL,
|
||||
"custom_field_id" uuid NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT "queue_custom_fields_queue_id_custom_field_id_unique" UNIQUE("queue_id","custom_field_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "queues" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"lifecycle_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "queues_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "scrips" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"queue_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"condition_type" text NOT NULL,
|
||||
"condition_config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"action_type" text NOT NULL,
|
||||
"action_config" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"template_id" uuid,
|
||||
"stage" text DEFAULT 'TransactionCreate' NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"disabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "templates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"queue_id" uuid,
|
||||
"subject_template" text NOT NULL,
|
||||
"body_template" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tickets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"queue_id" uuid NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"owner_id" uuid,
|
||||
"creator_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
"updated_at" timestamp with time zone DEFAULT now(),
|
||||
"started_at" timestamp with time zone,
|
||||
"resolved_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "transactions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ticket_id" uuid NOT NULL,
|
||||
"transaction_type" text NOT NULL,
|
||||
"field" text,
|
||||
"old_value" text,
|
||||
"new_value" text,
|
||||
"data" jsonb,
|
||||
"creator_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" text NOT NULL,
|
||||
"email" text,
|
||||
"created_at" timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT "users_username_unique" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_custom_field_id_custom_fields_id_fk" FOREIGN KEY ("custom_field_id") REFERENCES "public"."custom_fields"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "custom_field_values" ADD CONSTRAINT "custom_field_values_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_custom_fields" ADD CONSTRAINT "queue_custom_fields_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_custom_fields" ADD CONSTRAINT "queue_custom_fields_custom_field_id_custom_fields_id_fk" FOREIGN KEY ("custom_field_id") REFERENCES "public"."custom_fields"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queues" ADD CONSTRAINT "queues_lifecycle_id_lifecycles_id_fk" FOREIGN KEY ("lifecycle_id") REFERENCES "public"."lifecycles"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scrips" ADD CONSTRAINT "scrips_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "scrips" ADD CONSTRAINT "scrips_template_id_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."templates"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "templates" ADD CONSTRAINT "templates_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_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 "custom_field_values_ticket_id_idx" ON "custom_field_values" USING btree ("ticket_id");--> statement-breakpoint
|
||||
CREATE INDEX "custom_field_values_custom_field_id_idx" ON "custom_field_values" USING btree ("custom_field_id");--> statement-breakpoint
|
||||
CREATE INDEX "scrips_queue_id_idx" ON "scrips" USING btree ("queue_id");--> statement-breakpoint
|
||||
CREATE INDEX "tickets_queue_id_idx" ON "tickets" USING btree ("queue_id");--> statement-breakpoint
|
||||
CREATE INDEX "tickets_status_idx" ON "tickets" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_ticket_id_idx" ON "transactions" USING btree ("ticket_id");--> statement-breakpoint
|
||||
CREATE INDEX "transactions_created_at_idx" ON "transactions" USING btree ("created_at");
|
||||
906
drizzle/migrations/meta/0000_snapshot.json
Normal file
906
drizzle/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,906 @@
|
||||
{
|
||||
"id": "981c2ca0-1a37-4fbd-8624-2e7f43cd8361",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.custom_field_values": {
|
||||
"name": "custom_field_values",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"custom_field_id": {
|
||||
"name": "custom_field_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"custom_field_values_ticket_id_idx": {
|
||||
"name": "custom_field_values_ticket_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "ticket_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"custom_field_values_custom_field_id_idx": {
|
||||
"name": "custom_field_values_custom_field_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "custom_field_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"custom_field_values_custom_field_id_custom_fields_id_fk": {
|
||||
"name": "custom_field_values_custom_field_id_custom_fields_id_fk",
|
||||
"tableFrom": "custom_field_values",
|
||||
"tableTo": "custom_fields",
|
||||
"columnsFrom": [
|
||||
"custom_field_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"custom_field_values_ticket_id_tickets_id_fk": {
|
||||
"name": "custom_field_values_ticket_id_tickets_id_fk",
|
||||
"tableFrom": "custom_field_values",
|
||||
"tableTo": "tickets",
|
||||
"columnsFrom": [
|
||||
"ticket_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"custom_field_values_cf_id_ticket_id_value_unique": {
|
||||
"name": "custom_field_values_cf_id_ticket_id_value_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"custom_field_id",
|
||||
"ticket_id",
|
||||
"value"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.custom_fields": {
|
||||
"name": "custom_fields",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"field_type": {
|
||||
"name": "field_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"values": {
|
||||
"name": "values",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"max_values": {
|
||||
"name": "max_values",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"pattern": {
|
||||
"name": "pattern",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lifecycles": {
|
||||
"name": "lifecycles",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"definition": {
|
||||
"name": "definition",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"lifecycles_name_unique": {
|
||||
"name": "lifecycles_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.queue_custom_fields": {
|
||||
"name": "queue_custom_fields",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"custom_field_id": {
|
||||
"name": "custom_field_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"queue_custom_fields_queue_id_queues_id_fk": {
|
||||
"name": "queue_custom_fields_queue_id_queues_id_fk",
|
||||
"tableFrom": "queue_custom_fields",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"queue_custom_fields_custom_field_id_custom_fields_id_fk": {
|
||||
"name": "queue_custom_fields_custom_field_id_custom_fields_id_fk",
|
||||
"tableFrom": "queue_custom_fields",
|
||||
"tableTo": "custom_fields",
|
||||
"columnsFrom": [
|
||||
"custom_field_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"queue_custom_fields_queue_id_custom_field_id_unique": {
|
||||
"name": "queue_custom_fields_queue_id_custom_field_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"queue_id",
|
||||
"custom_field_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.queues": {
|
||||
"name": "queues",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"lifecycle_id": {
|
||||
"name": "lifecycle_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"queues_lifecycle_id_lifecycles_id_fk": {
|
||||
"name": "queues_lifecycle_id_lifecycles_id_fk",
|
||||
"tableFrom": "queues",
|
||||
"tableTo": "lifecycles",
|
||||
"columnsFrom": [
|
||||
"lifecycle_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"queues_name_unique": {
|
||||
"name": "queues_name_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.scrips": {
|
||||
"name": "scrips",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"condition_type": {
|
||||
"name": "condition_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"condition_config": {
|
||||
"name": "condition_config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"action_type": {
|
||||
"name": "action_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"action_config": {
|
||||
"name": "action_config",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"template_id": {
|
||||
"name": "template_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"stage": {
|
||||
"name": "stage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'TransactionCreate'"
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"disabled": {
|
||||
"name": "disabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"scrips_queue_id_idx": {
|
||||
"name": "scrips_queue_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "queue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"scrips_queue_id_queues_id_fk": {
|
||||
"name": "scrips_queue_id_queues_id_fk",
|
||||
"tableFrom": "scrips",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"scrips_template_id_templates_id_fk": {
|
||||
"name": "scrips_template_id_templates_id_fk",
|
||||
"tableFrom": "scrips",
|
||||
"tableTo": "templates",
|
||||
"columnsFrom": [
|
||||
"template_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.templates": {
|
||||
"name": "templates",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_template": {
|
||||
"name": "subject_template",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body_template": {
|
||||
"name": "body_template",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"templates_queue_id_queues_id_fk": {
|
||||
"name": "templates_queue_id_queues_id_fk",
|
||||
"tableFrom": "templates",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.tickets": {
|
||||
"name": "tickets",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"subject": {
|
||||
"name": "subject",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"queue_id": {
|
||||
"name": "queue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tickets_queue_id_idx": {
|
||||
"name": "tickets_queue_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "queue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"tickets_status_idx": {
|
||||
"name": "tickets_status_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tickets_queue_id_queues_id_fk": {
|
||||
"name": "tickets_queue_id_queues_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "queues",
|
||||
"columnsFrom": [
|
||||
"queue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tickets_owner_id_users_id_fk": {
|
||||
"name": "tickets_owner_id_users_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"tickets_creator_id_users_id_fk": {
|
||||
"name": "tickets_creator_id_users_id_fk",
|
||||
"tableFrom": "tickets",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"creator_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.transactions": {
|
||||
"name": "transactions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"ticket_id": {
|
||||
"name": "ticket_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"transaction_type": {
|
||||
"name": "transaction_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"field": {
|
||||
"name": "field",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"old_value": {
|
||||
"name": "old_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"new_value": {
|
||||
"name": "new_value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"transactions_ticket_id_idx": {
|
||||
"name": "transactions_ticket_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "ticket_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"transactions_created_at_idx": {
|
||||
"name": "transactions_created_at_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"transactions_ticket_id_tickets_id_fk": {
|
||||
"name": "transactions_ticket_id_tickets_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "tickets",
|
||||
"columnsFrom": [
|
||||
"ticket_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"transactions_creator_id_users_id_fk": {
|
||||
"name": "transactions_creator_id_users_id_fk",
|
||||
"tableFrom": "transactions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"creator_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_username_unique": {
|
||||
"name": "users_username_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/migrations/meta/_journal.json
Normal file
13
drizzle/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1780859982396,
|
||||
"tag": "0000_acoustic_wendell_vaughn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "tessera",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"bun-types": "^1.3.14",
|
||||
"drizzle-kit": "^0.31.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
9
src/config.ts
Normal file
9
src/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
const configSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
SERVER_HOST: z.string().default('127.0.0.1'),
|
||||
SERVER_PORT: z.coerce.number().int().positive().default(8080),
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
10
src/db/index.ts
Normal file
10
src/db/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import * as schema from './schema.ts';
|
||||
|
||||
export function createDb(databaseUrl: string) {
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
return drizzle(pool, { schema });
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof createDb>;
|
||||
24
src/db/migrate.ts
Normal file
24
src/db/migrate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error('DATABASE_URL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = new Pool({ connectionString: databaseUrl });
|
||||
const db = drizzle(pool);
|
||||
|
||||
await migrate(db, { migrationsFolder: './drizzle/migrations' });
|
||||
|
||||
console.log('Migrations complete');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
113
src/db/schema.ts
Normal file
113
src/db/schema.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { pgTable, uuid, text, jsonb, integer, boolean, timestamp, unique, index } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
username: text('username').notNull().unique(),
|
||||
email: text('email'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const queues = pgTable('queues', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull().unique(),
|
||||
description: text('description'),
|
||||
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const lifecycles = pgTable('lifecycles', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull().unique(),
|
||||
definition: jsonb('definition').notNull(),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const tickets = pgTable('tickets', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
subject: text('subject').notNull(),
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id),
|
||||
status: text('status').notNull(),
|
||||
owner_id: uuid('owner_id').references(() => users.id),
|
||||
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 }),
|
||||
}, (table) => ({
|
||||
queueIdIdx: index('tickets_queue_id_idx').on(table.queue_id),
|
||||
statusIdx: index('tickets_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const transactions = pgTable('transactions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
transaction_type: text('transaction_type').notNull(),
|
||||
field: text('field'),
|
||||
old_value: text('old_value'),
|
||||
new_value: text('new_value'),
|
||||
data: jsonb('data'),
|
||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
ticketIdIdx: index('transactions_ticket_id_idx').on(table.ticket_id),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.created_at),
|
||||
}));
|
||||
|
||||
export const templates = pgTable('templates', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
queue_id: uuid('queue_id').references(() => queues.id),
|
||||
subject_template: text('subject_template').notNull(),
|
||||
body_template: text('body_template').notNull(),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const scrips = pgTable('scrips', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').references(() => queues.id),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
condition_type: text('condition_type').notNull(),
|
||||
condition_config: jsonb('condition_config').notNull().default(sql`'{}'::jsonb`),
|
||||
action_type: text('action_type').notNull(),
|
||||
action_config: jsonb('action_config').notNull().default(sql`'{}'::jsonb`),
|
||||
template_id: uuid('template_id').references(() => templates.id),
|
||||
stage: text('stage').notNull().default('TransactionCreate'),
|
||||
sort_order: integer('sort_order').notNull().default(0),
|
||||
disabled: boolean('disabled').notNull().default(false),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
|
||||
}));
|
||||
|
||||
export const customFields = pgTable('custom_fields', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
field_type: text('field_type').notNull(),
|
||||
values: jsonb('values'),
|
||||
max_values: integer('max_values').notNull().default(1),
|
||||
pattern: text('pattern'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const queueCustomFields = pgTable('queue_custom_fields', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id),
|
||||
custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }),
|
||||
sort_order: integer('sort_order').notNull().default(0),
|
||||
}, (table) => ({
|
||||
uniqueQueueCf: unique('queue_custom_fields_queue_id_custom_field_id_unique').on(table.queue_id, table.custom_field_id),
|
||||
}));
|
||||
|
||||
export const customFieldValues = pgTable('custom_field_values', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
custom_field_id: uuid('custom_field_id').notNull().references(() => customFields.id, { onDelete: 'cascade' }),
|
||||
ticket_id: uuid('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
value: text('value').notNull(),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
uniqueCfTicketValue: unique('custom_field_values_cf_id_ticket_id_value_unique').on(table.custom_field_id, table.ticket_id, table.value),
|
||||
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),
|
||||
}));
|
||||
46
src/index.ts
Normal file
46
src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Hono } from 'hono';
|
||||
import { config } from './config.ts';
|
||||
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 healthRouter from './routes/health.ts';
|
||||
import { createTicketsRouter } from './routes/tickets.ts';
|
||||
import { createQueuesRouter } from './routes/queues.ts';
|
||||
import { createScripsRouter } from './routes/scrips.ts';
|
||||
import { createCustomFieldsRouter } from './routes/custom-fields.ts';
|
||||
import { createLifecyclesRouter } from './routes/lifecycles.ts';
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
function getDb(): Db {
|
||||
if (!db) {
|
||||
db = createDb(config.DATABASE_URL);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use('*', requestLogger);
|
||||
app.onError(errorHandler);
|
||||
|
||||
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()));
|
||||
|
||||
export default app;
|
||||
export { app };
|
||||
|
||||
// Start server when run directly
|
||||
if (Bun.main === import.meta.path) {
|
||||
Bun.serve({
|
||||
fetch: app.fetch,
|
||||
port: config.SERVER_PORT,
|
||||
hostname: config.SERVER_HOST,
|
||||
});
|
||||
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
||||
}
|
||||
73
src/lifecycle/validator.ts
Normal file
73
src/lifecycle/validator.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface LifecycleDefinition {
|
||||
statuses: {
|
||||
initial: string[];
|
||||
active: string[];
|
||||
inactive: string[];
|
||||
};
|
||||
transitions: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class LifecycleValidator {
|
||||
validateTransition(
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
fromStatus: string,
|
||||
toStatus: string,
|
||||
): ValidationResult {
|
||||
const allStatuses = [
|
||||
...lifecycleDef.statuses.initial,
|
||||
...lifecycleDef.statuses.active,
|
||||
...lifecycleDef.statuses.inactive,
|
||||
];
|
||||
|
||||
if (!allStatuses.includes(toStatus)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Status "${toStatus}" is not defined in the lifecycle`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for allowed transitions
|
||||
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
|
||||
|
||||
if (allowedTransitions.includes(toStatus)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Also handle wildcard "*" -> any transition
|
||||
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
|
||||
if (wildcardTransitions.includes(toStatus)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Transition from "${fromStatus}" to "${toStatus}" is not allowed`,
|
||||
};
|
||||
}
|
||||
|
||||
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
|
||||
return lifecycleDef.statuses.inactive.includes(status);
|
||||
}
|
||||
|
||||
private getAllowedTransitions(
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
fromStatus: string,
|
||||
): string[] {
|
||||
// Direct transition
|
||||
if (lifecycleDef.transitions[fromStatus]) {
|
||||
return lifecycleDef.transitions[fromStatus]!;
|
||||
}
|
||||
|
||||
// Wildcard transitions
|
||||
if (lifecycleDef.transitions['*']) {
|
||||
return lifecycleDef.transitions['*']!;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
15
src/middleware/error.ts
Normal file
15
src/middleware/error.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Context } from 'hono';
|
||||
import type { ErrorHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'tessera' });
|
||||
|
||||
export const errorHandler: ErrorHandler = (err: Error, c: Context): Response => {
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json({ error: err.message }, err.status as any);
|
||||
}
|
||||
|
||||
logger.error({ err }, 'Unhandled error');
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
};
|
||||
16
src/middleware/logging.ts
Normal file
16
src/middleware/logging.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'tessera-http' });
|
||||
|
||||
export const requestLogger: MiddlewareHandler = async (c, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
const ms = Date.now() - start;
|
||||
logger.info({
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
status: c.res.status,
|
||||
ms,
|
||||
});
|
||||
};
|
||||
13
src/models/custom-field.ts
Normal file
13
src/models/custom-field.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { customFields } from '../db/schema.ts';
|
||||
|
||||
export type CustomField = InferSelectModel<typeof customFields>;
|
||||
|
||||
export const CustomFieldType = {
|
||||
SelectOne: 'SelectOne',
|
||||
SelectMultiple: 'SelectMultiple',
|
||||
Text: 'Text',
|
||||
Date: 'Date',
|
||||
} as const;
|
||||
|
||||
export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType];
|
||||
4
src/models/lifecycle.ts
Normal file
4
src/models/lifecycle.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { lifecycles } from '../db/schema.ts';
|
||||
|
||||
export type Lifecycle = InferSelectModel<typeof lifecycles>;
|
||||
11
src/models/queue.ts
Normal file
11
src/models/queue.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { z } from 'zod/v4';
|
||||
import { queues } from '../db/schema.ts';
|
||||
|
||||
export type Queue = InferSelectModel<typeof queues>;
|
||||
|
||||
export const CreateQueueSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
lifecycle_id: z.string().uuid().optional(),
|
||||
});
|
||||
26
src/models/scrip.ts
Normal file
26
src/models/scrip.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { z } from 'zod/v4';
|
||||
import { scrips } from '../db/schema.ts';
|
||||
|
||||
export type Scrip = InferSelectModel<typeof scrips>;
|
||||
|
||||
export const ScripStage = {
|
||||
TransactionCreate: 'TransactionCreate',
|
||||
TransactionBatch: 'TransactionBatch',
|
||||
} as const;
|
||||
|
||||
export type ScripStage = (typeof ScripStage)[keyof typeof ScripStage];
|
||||
|
||||
export const CreateScripSchema = z.object({
|
||||
queue_id: z.string().uuid().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
condition_type: z.string().min(1),
|
||||
condition_config: z.record(z.string(), z.unknown()).default({}),
|
||||
action_type: z.string().min(1),
|
||||
action_config: z.record(z.string(), z.unknown()).default({}),
|
||||
template_id: z.string().uuid().optional(),
|
||||
stage: z.enum(['TransactionCreate', 'TransactionBatch']).default('TransactionCreate'),
|
||||
sort_order: z.number().int().default(0),
|
||||
disabled: z.boolean().default(false),
|
||||
});
|
||||
16
src/models/ticket.ts
Normal file
16
src/models/ticket.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { z } from 'zod/v4';
|
||||
import { tickets } from '../db/schema.ts';
|
||||
|
||||
export type Ticket = InferSelectModel<typeof tickets>;
|
||||
|
||||
export const CreateTicketSchema = z.object({
|
||||
subject: z.string().min(1),
|
||||
queue_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const UpdateTicketSchema = z.object({
|
||||
subject: z.string().min(1).optional(),
|
||||
status: z.string().min(1).optional(),
|
||||
owner_id: z.string().uuid().optional(),
|
||||
});
|
||||
16
src/models/transaction.ts
Normal file
16
src/models/transaction.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { transactions } from '../db/schema.ts';
|
||||
|
||||
export type Transaction = InferSelectModel<typeof transactions>;
|
||||
|
||||
export const TransactionType = {
|
||||
Create: 'Create',
|
||||
StatusChange: 'StatusChange',
|
||||
SetOwner: 'SetOwner',
|
||||
AddWatcher: 'AddWatcher',
|
||||
Comment: 'Comment',
|
||||
CustomField: 'CustomField',
|
||||
Correspond: 'Correspond',
|
||||
} as const;
|
||||
|
||||
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];
|
||||
4
src/models/user.ts
Normal file
4
src/models/user.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
import { users } from '../db/schema.ts';
|
||||
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
41
src/routes/custom-fields.ts
Normal file
41
src/routes/custom-fields.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { customFields } from '../db/schema.ts';
|
||||
import { asc } from 'drizzle-orm';
|
||||
|
||||
export function createCustomFieldsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.customFields.findMany({
|
||||
orderBy: asc(customFields.name),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, field_type, values, max_values, pattern } = body;
|
||||
|
||||
if (!name || !field_type) {
|
||||
throw new HTTPException(400, { message: 'name and field_type are required' });
|
||||
}
|
||||
|
||||
const [cf] = await db.insert(customFields).values({
|
||||
name,
|
||||
field_type,
|
||||
values: values ?? null,
|
||||
max_values: max_values ?? 1,
|
||||
pattern: pattern ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!cf) {
|
||||
throw new HTTPException(500, { message: 'Failed to create custom field' });
|
||||
}
|
||||
|
||||
return c.json(cf, 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
9
src/routes/health.ts
Normal file
9
src/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({ status: 'ok', version: '0.1.0' });
|
||||
});
|
||||
|
||||
export default app;
|
||||
38
src/routes/lifecycles.ts
Normal file
38
src/routes/lifecycles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { lifecycles } from '../db/schema.ts';
|
||||
import { asc } from 'drizzle-orm';
|
||||
|
||||
export function createLifecyclesRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.lifecycles.findMany({
|
||||
orderBy: asc(lifecycles.name),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { name, definition } = body;
|
||||
|
||||
if (!name || !definition) {
|
||||
throw new HTTPException(400, { message: 'name and definition are required' });
|
||||
}
|
||||
|
||||
const [lifecycle] = await db.insert(lifecycles).values({
|
||||
name,
|
||||
definition,
|
||||
}).returning();
|
||||
|
||||
if (!lifecycle) {
|
||||
throw new HTTPException(500, { message: 'Failed to create lifecycle' });
|
||||
}
|
||||
|
||||
return c.json(lifecycle, 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
36
src/routes/queues.ts
Normal file
36
src/routes/queues.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { queues } from '../db/schema.ts';
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { CreateQueueSchema } from '../models/queue.ts';
|
||||
|
||||
export function createQueuesRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.queues.findMany({
|
||||
orderBy: asc(queues.name),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateQueueSchema.parse(body);
|
||||
|
||||
const [queue] = await db.insert(queues).values({
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
lifecycle_id: parsed.lifecycle_id ?? null,
|
||||
}).returning();
|
||||
|
||||
if (!queue) {
|
||||
throw new HTTPException(500, { message: 'Failed to create queue' });
|
||||
}
|
||||
|
||||
return c.json(queue, 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
89
src/routes/scrips.ts
Normal file
89
src/routes/scrips.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { scrips } from '../db/schema.ts';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { CreateScripSchema } from '../models/scrip.ts';
|
||||
|
||||
export function createScripsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.get('/', async (c) => {
|
||||
const result = await db.query.scrips.findMany({
|
||||
orderBy: asc(scrips.sort_order),
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateScripSchema.parse(body);
|
||||
|
||||
const [scrip] = await db.insert(scrips).values({
|
||||
queue_id: parsed.queue_id ?? null,
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
condition_type: parsed.condition_type,
|
||||
condition_config: parsed.condition_config,
|
||||
action_type: parsed.action_type,
|
||||
action_config: parsed.action_config,
|
||||
template_id: parsed.template_id ?? null,
|
||||
stage: parsed.stage,
|
||||
sort_order: parsed.sort_order,
|
||||
disabled: parsed.disabled,
|
||||
}).returning();
|
||||
|
||||
if (!scrip) {
|
||||
throw new HTTPException(500, { message: 'Failed to create scrip' });
|
||||
}
|
||||
|
||||
return c.json(scrip, 201);
|
||||
});
|
||||
|
||||
router.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const scrip = await db.query.scrips.findFirst({
|
||||
where: eq(scrips.id, id),
|
||||
});
|
||||
|
||||
if (!scrip) {
|
||||
throw new HTTPException(404, { message: 'Scrip not found' });
|
||||
}
|
||||
|
||||
return c.json(scrip);
|
||||
});
|
||||
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
|
||||
const existing = await db.query.scrips.findFirst({
|
||||
where: eq(scrips.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Scrip not found' });
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) updateData.name = body.name;
|
||||
if (body.description !== undefined) updateData.description = body.description;
|
||||
if (body.condition_type !== undefined) updateData.condition_type = body.condition_type;
|
||||
if (body.condition_config !== undefined) updateData.condition_config = body.condition_config;
|
||||
if (body.action_type !== undefined) updateData.action_type = body.action_type;
|
||||
if (body.action_config !== undefined) updateData.action_config = body.action_config;
|
||||
if (body.template_id !== undefined) updateData.template_id = body.template_id;
|
||||
if (body.stage !== undefined) updateData.stage = body.stage;
|
||||
if (body.sort_order !== undefined) updateData.sort_order = body.sort_order;
|
||||
if (body.disabled !== undefined) updateData.disabled = body.disabled;
|
||||
|
||||
const [updated] = await db.update(scrips)
|
||||
.set(updateData as any)
|
||||
.where(eq(scrips.id, id))
|
||||
.returning();
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
226
src/routes/tickets.ts
Normal file
226
src/routes/tickets.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema } from '../models/ticket.ts';
|
||||
import { ScripEngine } from '../scrip/engine.ts';
|
||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||
|
||||
export function createTicketsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
const scripEngine = new ScripEngine(db);
|
||||
const lifecycleValidator = new LifecycleValidator();
|
||||
|
||||
// GET / — list tickets
|
||||
router.get('/', async (c) => {
|
||||
const queueId = c.req.query('queue_id');
|
||||
const status = c.req.query('status');
|
||||
|
||||
const result = await db.query.tickets.findMany({
|
||||
where: (t, { and, eq }) => {
|
||||
const conditions = [];
|
||||
if (queueId) conditions.push(eq(t.queue_id, queueId));
|
||||
if (status) conditions.push(eq(t.status, status));
|
||||
return conditions.length > 0 ? and(...conditions) : undefined;
|
||||
},
|
||||
orderBy: asc(tickets.created_at),
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST / — create ticket
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateTicketSchema.parse(body);
|
||||
|
||||
const [ticket] = await db.insert(tickets).values({
|
||||
subject: parsed.subject,
|
||||
queue_id: parsed.queue_id,
|
||||
status: 'new',
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
}).returning();
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(500, { message: 'Failed to create ticket' });
|
||||
}
|
||||
|
||||
// Record transaction
|
||||
await db.insert(transactions).values({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: 'new',
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
|
||||
return c.json(ticket, 201);
|
||||
});
|
||||
|
||||
// GET /:id — get ticket with custom field values
|
||||
router.get('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const cfValues = await db.query.customFieldValues.findMany({
|
||||
where: eq(customFieldValues.ticket_id, id),
|
||||
with: {
|
||||
customField: true,
|
||||
},
|
||||
});
|
||||
|
||||
return c.json({ ...ticket, custom_fields: cfValues });
|
||||
});
|
||||
|
||||
// PATCH /:id — update ticket
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const parsed = UpdateTicketSchema.parse(body);
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
// Validate lifecycle transition if status is changing
|
||||
if (parsed.status) {
|
||||
const queue = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, ticket.queue_id),
|
||||
});
|
||||
|
||||
if (queue?.lifecycle_id) {
|
||||
const lifecycle = await db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.id, queue.lifecycle_id!),
|
||||
});
|
||||
|
||||
if (lifecycle) {
|
||||
const def = lifecycle.definition as LifecycleDefinition;
|
||||
const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status);
|
||||
if (!result.valid) {
|
||||
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const txList = [];
|
||||
|
||||
if (parsed.subject && parsed.subject !== ticket.subject) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'StatusChange' as const,
|
||||
field: 'subject',
|
||||
old_value: ticket.subject,
|
||||
new_value: parsed.subject,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.status && parsed.status !== ticket.status) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'StatusChange' as const,
|
||||
field: 'status',
|
||||
old_value: ticket.status,
|
||||
new_value: parsed.status,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'SetOwner' as const,
|
||||
field: 'owner_id',
|
||||
old_value: ticket.owner_id ?? null,
|
||||
new_value: parsed.owner_id,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
}
|
||||
|
||||
// Update the ticket
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (parsed.subject) updateData.subject = parsed.subject;
|
||||
if (parsed.status) updateData.status = parsed.status;
|
||||
if (parsed.owner_id) updateData.owner_id = parsed.owner_id;
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
const [updated] = await db.update(tickets)
|
||||
.set(updateData as any)
|
||||
.where(eq(tickets.id, id))
|
||||
.returning();
|
||||
|
||||
// Insert transactions
|
||||
if (txList.length > 0) {
|
||||
await db.insert(transactions).values(txList as any);
|
||||
}
|
||||
|
||||
// Run scrips
|
||||
const prepared = await scripEngine.prepare(id, txList as any);
|
||||
const results = scripEngine.commit(prepared);
|
||||
|
||||
return c.json({ ticket: updated, scrip_results: results });
|
||||
});
|
||||
|
||||
// POST /:id/preview — dry-run scrips
|
||||
router.post('/:id/preview', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const body = await c.req.json();
|
||||
const parsed = UpdateTicketSchema.parse(body);
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const txList: any[] = [];
|
||||
|
||||
if (parsed.status && parsed.status !== ticket.status) {
|
||||
txList.push({
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
ticket_id: id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: ticket.status,
|
||||
new_value: parsed.status,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
});
|
||||
}
|
||||
|
||||
const prepared = await scripEngine.prepare(id, txList);
|
||||
const preparedWithDryRun = prepared.map((p) => ({ ...p, dryRun: true }));
|
||||
const results = scripEngine.commit(preparedWithDryRun);
|
||||
|
||||
return c.json({ prepared_scrips: results });
|
||||
});
|
||||
|
||||
// GET /:id/transactions — list transactions for ticket
|
||||
router.get('/:id/transactions', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const result = await db.query.transactions.findMany({
|
||||
where: eq(transactions.ticket_id, id),
|
||||
orderBy: asc(transactions.created_at),
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
62
src/scrip/actions.ts
Normal file
62
src/scrip/actions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface ActionExecutor {
|
||||
execute(payload: ActionPayload): { success: boolean; message: string };
|
||||
}
|
||||
|
||||
export interface ActionPayload {
|
||||
scripId: string;
|
||||
scripName: string;
|
||||
actionType: string;
|
||||
actionConfig: Record<string, unknown>;
|
||||
recipients?: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
field_id?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export class SendEmail implements ActionExecutor {
|
||||
execute(payload: ActionPayload): { success: boolean; message: string } {
|
||||
console.log('[SendEmail] Would send email:', {
|
||||
subject: payload.subject ?? payload.actionConfig['subject'],
|
||||
body: payload.body ?? payload.actionConfig['body'],
|
||||
recipients: payload.recipients ?? payload.actionConfig['recipients'],
|
||||
});
|
||||
return { success: true, message: `Email queued: "${payload.subject ?? 'No subject'}"` };
|
||||
}
|
||||
}
|
||||
|
||||
export class Webhook implements ActionExecutor {
|
||||
execute(payload: ActionPayload): { success: boolean; message: string } {
|
||||
console.log('[Webhook] Would fire webhook:', {
|
||||
url: payload.url ?? payload.actionConfig['url'],
|
||||
method: payload.method ?? payload.actionConfig['method'] ?? 'POST',
|
||||
headers: payload.headers ?? payload.actionConfig['headers'],
|
||||
body: payload.body ?? payload.actionConfig['body'],
|
||||
});
|
||||
return { success: true, message: `Webhook fired: ${payload.url ?? 'unknown URL'}` };
|
||||
}
|
||||
}
|
||||
|
||||
export class SetCustomField implements ActionExecutor {
|
||||
execute(payload: ActionPayload): { success: boolean; message: string } {
|
||||
const fieldId = payload.field_id ?? String(payload.actionConfig['field_id'] ?? '');
|
||||
const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
|
||||
console.log('[SetCustomField] Would set:', { field_id: fieldId, value });
|
||||
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
|
||||
}
|
||||
}
|
||||
|
||||
const actionRegistry: Record<string, ActionExecutor> = {
|
||||
SendEmail: new SendEmail(),
|
||||
Webhook: new Webhook(),
|
||||
SetCustomField: new SetCustomField(),
|
||||
};
|
||||
|
||||
export function getActionExecutor(type: string): ActionExecutor | null {
|
||||
return actionRegistry[type] ?? null;
|
||||
}
|
||||
|
||||
export { actionRegistry };
|
||||
41
src/scrip/conditions.ts
Normal file
41
src/scrip/conditions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Ticket } from '../models/ticket.ts';
|
||||
import type { Transaction } from '../models/transaction.ts';
|
||||
|
||||
export interface ConditionEvaluator {
|
||||
evaluate(ticket: Ticket, transactions: Transaction[]): boolean;
|
||||
}
|
||||
|
||||
export class OnCreate implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
|
||||
return transactions.some((tx) => tx.transaction_type === 'Create');
|
||||
}
|
||||
}
|
||||
|
||||
export class OnStatusChange implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
|
||||
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
|
||||
}
|
||||
}
|
||||
|
||||
export class OnResolve implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
|
||||
return transactions.some(
|
||||
(tx) =>
|
||||
tx.transaction_type === 'StatusChange' &&
|
||||
tx.new_value !== null &&
|
||||
['resolved', 'closed', 'completed', 'rejected', 'cancelled'].includes(tx.new_value.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||
OnCreate: new OnCreate(),
|
||||
OnStatusChange: new OnStatusChange(),
|
||||
OnResolve: new OnResolve(),
|
||||
};
|
||||
|
||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||
return conditionRegistry[type] ?? null;
|
||||
}
|
||||
|
||||
export { conditionRegistry };
|
||||
174
src/scrip/engine.ts
Normal file
174
src/scrip/engine.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { Ticket } from '../models/ticket.ts';
|
||||
import type { Transaction } from '../models/transaction.ts';
|
||||
import { tickets, queues, scrips } from '../db/schema.ts';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { getConditionEvaluator } from './conditions.ts';
|
||||
import { getActionExecutor } from './actions.ts';
|
||||
import type { ActionPayload } from './actions.ts';
|
||||
import { TemplateRenderer } from './templates.ts';
|
||||
import type { TemplateContext } from './templates.ts';
|
||||
|
||||
export interface PreparedScrip {
|
||||
scripId: string;
|
||||
scripName: string;
|
||||
actionType: string;
|
||||
actionPayload: ActionPayload;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface ScripResult {
|
||||
scripId: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ScripEngine {
|
||||
private db: Db;
|
||||
private templateRenderer: TemplateRenderer;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.db = db;
|
||||
this.templateRenderer = new TemplateRenderer();
|
||||
}
|
||||
|
||||
async prepare(
|
||||
ticketId: string,
|
||||
transactions: Transaction[],
|
||||
): Promise<PreparedScrip[]> {
|
||||
const ticketRecord = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticketRecord) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const transactionTypes = [...new Set(transactions.map((tx) => tx.transaction_type))];
|
||||
|
||||
const allScrips = await this.db.query.scrips.findMany({
|
||||
orderBy: asc(scrips.sort_order),
|
||||
});
|
||||
|
||||
const matchingScrips = allScrips.filter((scrip) => {
|
||||
if (scrip.disabled) return false;
|
||||
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
|
||||
if (!transactionTypes.includes(scrip.condition_type)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const prepared: PreparedScrip[] = [];
|
||||
|
||||
for (const scrip of matchingScrips) {
|
||||
const evaluator = getConditionEvaluator(scrip.condition_type);
|
||||
if (!evaluator) {
|
||||
console.log(`[ScripEngine] Unknown condition type: ${scrip.condition_type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let subject: string | undefined;
|
||||
let body: string | undefined;
|
||||
|
||||
if (scrip.template_id) {
|
||||
const template = await this.db.query.templates.findFirst({
|
||||
where: (t, { eq }) => eq(t.id, scrip.template_id!),
|
||||
});
|
||||
|
||||
if (template) {
|
||||
const queue = await this.db.query.queues.findFirst({
|
||||
where: eq(queues.id, ticketRecord.queue_id),
|
||||
});
|
||||
const latestTx = transactions[transactions.length - 1]!;
|
||||
|
||||
const context: TemplateContext = {
|
||||
ticket: {
|
||||
id: ticketRecord.id,
|
||||
subject: ticketRecord.subject,
|
||||
status: ticketRecord.status,
|
||||
queue_id: ticketRecord.queue_id,
|
||||
owner_id: ticketRecord.owner_id,
|
||||
creator_id: ticketRecord.creator_id,
|
||||
created_at: ticketRecord.created_at?.toISOString() ?? new Date().toISOString(),
|
||||
updated_at: ticketRecord.updated_at?.toISOString() ?? new Date().toISOString(),
|
||||
},
|
||||
queue: {
|
||||
name: queue?.name ?? 'unknown',
|
||||
},
|
||||
transaction: {
|
||||
type: latestTx.transaction_type,
|
||||
field: latestTx.field,
|
||||
old_value: latestTx.old_value,
|
||||
new_value: latestTx.new_value,
|
||||
},
|
||||
custom_fields: {},
|
||||
};
|
||||
|
||||
const rendered = this.templateRenderer.render(
|
||||
template.subject_template,
|
||||
template.body_template,
|
||||
context,
|
||||
);
|
||||
subject = rendered.subject;
|
||||
body = rendered.body;
|
||||
}
|
||||
}
|
||||
|
||||
const actionPayload: ActionPayload = {
|
||||
scripId: scrip.id,
|
||||
scripName: scrip.name,
|
||||
actionType: scrip.action_type,
|
||||
actionConfig: scrip.action_config as Record<string, unknown>,
|
||||
subject,
|
||||
body,
|
||||
};
|
||||
|
||||
prepared.push({
|
||||
scripId: scrip.id,
|
||||
scripName: scrip.name,
|
||||
actionType: scrip.action_type,
|
||||
actionPayload,
|
||||
dryRun: false,
|
||||
});
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
commit(prepared: PreparedScrip[]): ScripResult[] {
|
||||
const results: ScripResult[] = [];
|
||||
|
||||
for (const p of prepared) {
|
||||
if (p.dryRun) {
|
||||
results.push({
|
||||
scripId: p.scripId,
|
||||
success: true,
|
||||
message: `Dry run: would execute ${p.actionType}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const executor = getActionExecutor(p.actionType);
|
||||
if (!executor) {
|
||||
results.push({
|
||||
scripId: p.scripId,
|
||||
success: false,
|
||||
message: `Unknown action type: ${p.actionType}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = executor.execute(p.actionPayload);
|
||||
results.push({
|
||||
scripId: p.scripId,
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
39
src/scrip/templates.ts
Normal file
39
src/scrip/templates.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import Handlebars from 'handlebars';
|
||||
|
||||
export class TemplateRenderer {
|
||||
render(
|
||||
subjectTemplate: string,
|
||||
bodyTemplate: string,
|
||||
context: TemplateContext,
|
||||
): { subject: string; body: string } {
|
||||
const subjectCompiled = Handlebars.compile(subjectTemplate);
|
||||
const bodyCompiled = Handlebars.compile(bodyTemplate);
|
||||
return {
|
||||
subject: subjectCompiled(context),
|
||||
body: bodyCompiled(context),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface TemplateContext {
|
||||
ticket: {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
queue_id: string;
|
||||
owner_id: string | null;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
queue: {
|
||||
name: string;
|
||||
};
|
||||
transaction: {
|
||||
type: string;
|
||||
field: string | null;
|
||||
old_value: string | null;
|
||||
new_value: string | null;
|
||||
};
|
||||
custom_fields: Record<string, string>;
|
||||
}
|
||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"types": ["bun"],
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user