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:
Gjermund Høsøien Wiggen
2026-06-07 21:21:50 +02:00
parent 7be1810162
commit 1136227510
35 changed files with 2595 additions and 0 deletions

3
.env.example Normal file
View 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
View 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
View 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
View 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
View 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!,
},
});

View 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");

View 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": {}
}
}

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
import type { InferSelectModel } from 'drizzle-orm';
import { users } from '../db/schema.ts';
export type User = InferSelectModel<typeof users>;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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/*"]
}
}
}