From 113622751089252a5ffe03c4c584ac91977d30e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Sun, 7 Jun 2026 21:21:50 +0200 Subject: [PATCH] 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) --- .env.example | 3 + .gitignore | 35 + CLAUDE.md | 106 ++ docs/scaffold-spec.md | 202 ++++ drizzle.config.ts | 10 + .../0000_acoustic_wendell_vaughn.sql | 122 +++ drizzle/migrations/meta/0000_snapshot.json | 906 ++++++++++++++++++ drizzle/migrations/meta/_journal.json | 13 + package.json | 16 + src/config.ts | 9 + src/db/index.ts | 10 + src/db/migrate.ts | 24 + src/db/schema.ts | 113 +++ src/index.ts | 46 + src/lifecycle/validator.ts | 73 ++ src/middleware/error.ts | 15 + src/middleware/logging.ts | 16 + src/models/custom-field.ts | 13 + src/models/lifecycle.ts | 4 + src/models/queue.ts | 11 + src/models/scrip.ts | 26 + src/models/ticket.ts | 16 + src/models/transaction.ts | 16 + src/models/user.ts | 4 + src/routes/custom-fields.ts | 41 + src/routes/health.ts | 9 + src/routes/lifecycles.ts | 38 + src/routes/queues.ts | 36 + src/routes/scrips.ts | 89 ++ src/routes/tickets.ts | 226 +++++ src/scrip/actions.ts | 62 ++ src/scrip/conditions.ts | 41 + src/scrip/engine.ts | 174 ++++ src/scrip/templates.ts | 39 + tsconfig.json | 31 + 35 files changed, 2595 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 docs/scaffold-spec.md create mode 100644 drizzle.config.ts create mode 100644 drizzle/migrations/0000_acoustic_wendell_vaughn.sql create mode 100644 drizzle/migrations/meta/0000_snapshot.json create mode 100644 drizzle/migrations/meta/_journal.json create mode 100644 package.json create mode 100644 src/config.ts create mode 100644 src/db/index.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/schema.ts create mode 100644 src/index.ts create mode 100644 src/lifecycle/validator.ts create mode 100644 src/middleware/error.ts create mode 100644 src/middleware/logging.ts create mode 100644 src/models/custom-field.ts create mode 100644 src/models/lifecycle.ts create mode 100644 src/models/queue.ts create mode 100644 src/models/scrip.ts create mode 100644 src/models/ticket.ts create mode 100644 src/models/transaction.ts create mode 100644 src/models/user.ts create mode 100644 src/routes/custom-fields.ts create mode 100644 src/routes/health.ts create mode 100644 src/routes/lifecycles.ts create mode 100644 src/routes/queues.ts create mode 100644 src/routes/scrips.ts create mode 100644 src/routes/tickets.ts create mode 100644 src/scrip/actions.ts create mode 100644 src/scrip/conditions.ts create mode 100644 src/scrip/engine.ts create mode 100644 src/scrip/templates.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb59438 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgres://tessera:password@localhost:5432/tessera +SERVER_HOST=127.0.0.1 +SERVER_PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed624c3 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +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

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/docs/scaffold-spec.md b/docs/scaffold-spec.md new file mode 100644 index 0000000..aa68de3 --- /dev/null +++ b/docs/scaffold-spec.md @@ -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), creator_id (Uuid), created_at (DateTime), updated_at (DateTime), started_at (Option>), resolved_at (Option>) + +#### crates/tessera-core/src/models/queue.rs +- Queue struct: id (Uuid), name (String), description (Option), lifecycle_id (Option), created_at (DateTime) + +#### crates/tessera-core/src/models/transaction.rs +- Transaction struct: id (Uuid), ticket_id (Uuid), transaction_type (String), field (Option), old_value (Option), new_value (Option), data (Option), creator_id (Uuid), created_at (DateTime) + +#### crates/tessera-core/src/models/scrip.rs +- Scrip struct: id (Uuid), queue_id (Option), name (String), description (Option), condition_type (String), condition_config (serde_json::Value), action_type (String), action_config (serde_json::Value), template_id (Option), stage (String), sort_order (i32), disabled (bool), created_at (DateTime) +- ScripStage enum: TransactionCreate, TransactionBatch + +#### crates/tessera-core/src/models/template.rs +- Template struct: id (Uuid), name (String), queue_id (Option), subject_template (String), body_template (String), created_at (DateTime) + +#### crates/tessera-core/src/models/custom_field.rs +- CustomField struct: id (Uuid), name (String), field_type (String), values (Option), max_values (i32), pattern (Option), created_at (DateTime) +- 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) + +#### crates/tessera-core/src/models/user.rs +- User struct: id (Uuid), username (String), email (Option), created_at (DateTime) + +#### 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> + - 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) -> Result> + - 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 +- 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. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..5889e2c --- /dev/null +++ b/drizzle.config.ts @@ -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!, + }, +}); diff --git a/drizzle/migrations/0000_acoustic_wendell_vaughn.sql b/drizzle/migrations/0000_acoustic_wendell_vaughn.sql new file mode 100644 index 0000000..948a7c3 --- /dev/null +++ b/drizzle/migrations/0000_acoustic_wendell_vaughn.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0000_snapshot.json b/drizzle/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..9f72107 --- /dev/null +++ b/drizzle/migrations/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json new file mode 100644 index 0000000..a42c3b4 --- /dev/null +++ b/drizzle/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1780859982396, + "tag": "0000_acoustic_wendell_vaughn", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9ca319 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..9188efd --- /dev/null +++ b/src/config.ts @@ -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); diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..8869887 --- /dev/null +++ b/src/db/index.ts @@ -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; diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..f3562e3 --- /dev/null +++ b/src/db/migrate.ts @@ -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); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..945df06 --- /dev/null +++ b/src/db/schema.ts @@ -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), +})); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9f1fff9 --- /dev/null +++ b/src/index.ts @@ -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}`); +} diff --git a/src/lifecycle/validator.ts b/src/lifecycle/validator.ts new file mode 100644 index 0000000..296dbce --- /dev/null +++ b/src/lifecycle/validator.ts @@ -0,0 +1,73 @@ +export interface LifecycleDefinition { + statuses: { + initial: string[]; + active: string[]; + inactive: string[]; + }; + transitions: Record; +} + +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 []; + } +} diff --git a/src/middleware/error.ts b/src/middleware/error.ts new file mode 100644 index 0000000..5a48f50 --- /dev/null +++ b/src/middleware/error.ts @@ -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); +}; diff --git a/src/middleware/logging.ts b/src/middleware/logging.ts new file mode 100644 index 0000000..e3e376a --- /dev/null +++ b/src/middleware/logging.ts @@ -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, + }); +}; diff --git a/src/models/custom-field.ts b/src/models/custom-field.ts new file mode 100644 index 0000000..f9e8907 --- /dev/null +++ b/src/models/custom-field.ts @@ -0,0 +1,13 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import { customFields } from '../db/schema.ts'; + +export type CustomField = InferSelectModel; + +export const CustomFieldType = { + SelectOne: 'SelectOne', + SelectMultiple: 'SelectMultiple', + Text: 'Text', + Date: 'Date', +} as const; + +export type CustomFieldType = (typeof CustomFieldType)[keyof typeof CustomFieldType]; diff --git a/src/models/lifecycle.ts b/src/models/lifecycle.ts new file mode 100644 index 0000000..e92e40f --- /dev/null +++ b/src/models/lifecycle.ts @@ -0,0 +1,4 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import { lifecycles } from '../db/schema.ts'; + +export type Lifecycle = InferSelectModel; diff --git a/src/models/queue.ts b/src/models/queue.ts new file mode 100644 index 0000000..c3bcbdb --- /dev/null +++ b/src/models/queue.ts @@ -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; + +export const CreateQueueSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + lifecycle_id: z.string().uuid().optional(), +}); diff --git a/src/models/scrip.ts b/src/models/scrip.ts new file mode 100644 index 0000000..2262634 --- /dev/null +++ b/src/models/scrip.ts @@ -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; + +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), +}); diff --git a/src/models/ticket.ts b/src/models/ticket.ts new file mode 100644 index 0000000..51db565 --- /dev/null +++ b/src/models/ticket.ts @@ -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; + +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(), +}); diff --git a/src/models/transaction.ts b/src/models/transaction.ts new file mode 100644 index 0000000..59f8823 --- /dev/null +++ b/src/models/transaction.ts @@ -0,0 +1,16 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import { transactions } from '../db/schema.ts'; + +export type Transaction = InferSelectModel; + +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]; diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 0000000..40c8cd8 --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,4 @@ +import type { InferSelectModel } from 'drizzle-orm'; +import { users } from '../db/schema.ts'; + +export type User = InferSelectModel; diff --git a/src/routes/custom-fields.ts b/src/routes/custom-fields.ts new file mode 100644 index 0000000..0c1cabb --- /dev/null +++ b/src/routes/custom-fields.ts @@ -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; +} diff --git a/src/routes/health.ts b/src/routes/health.ts new file mode 100644 index 0000000..f836d75 --- /dev/null +++ b/src/routes/health.ts @@ -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; diff --git a/src/routes/lifecycles.ts b/src/routes/lifecycles.ts new file mode 100644 index 0000000..919b63d --- /dev/null +++ b/src/routes/lifecycles.ts @@ -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; +} diff --git a/src/routes/queues.ts b/src/routes/queues.ts new file mode 100644 index 0000000..3cc4624 --- /dev/null +++ b/src/routes/queues.ts @@ -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; +} diff --git a/src/routes/scrips.ts b/src/routes/scrips.ts new file mode 100644 index 0000000..8451c18 --- /dev/null +++ b/src/routes/scrips.ts @@ -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 = {}; + 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; +} diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts new file mode 100644 index 0000000..69492c0 --- /dev/null +++ b/src/routes/tickets.ts @@ -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 = {}; + 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; +} diff --git a/src/scrip/actions.ts b/src/scrip/actions.ts new file mode 100644 index 0000000..c86fb5a --- /dev/null +++ b/src/scrip/actions.ts @@ -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; + recipients?: string[]; + subject?: string; + body?: string; + url?: string; + method?: string; + headers?: Record; + 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 = { + SendEmail: new SendEmail(), + Webhook: new Webhook(), + SetCustomField: new SetCustomField(), +}; + +export function getActionExecutor(type: string): ActionExecutor | null { + return actionRegistry[type] ?? null; +} + +export { actionRegistry }; diff --git a/src/scrip/conditions.ts b/src/scrip/conditions.ts new file mode 100644 index 0000000..d179226 --- /dev/null +++ b/src/scrip/conditions.ts @@ -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 = { + OnCreate: new OnCreate(), + OnStatusChange: new OnStatusChange(), + OnResolve: new OnResolve(), +}; + +export function getConditionEvaluator(type: string): ConditionEvaluator | null { + return conditionRegistry[type] ?? null; +} + +export { conditionRegistry }; diff --git a/src/scrip/engine.ts b/src/scrip/engine.ts new file mode 100644 index 0000000..80c40a2 --- /dev/null +++ b/src/scrip/engine.ts @@ -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 { + 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, + 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; + } +} diff --git a/src/scrip/templates.ts b/src/scrip/templates.ts new file mode 100644 index 0000000..b411a78 --- /dev/null +++ b/src/scrip/templates.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa1fa5 --- /dev/null +++ b/tsconfig.json @@ -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/*"] + } + } +}