Scaffold the Tessera ticketing system as a TypeScript/Bun project at /home/gjermund/projects/tessera. ## Stack - Runtime: Bun 1.3+ - Framework: Hono (web framework) - Database: PostgreSQL + Drizzle ORM - Validation: Zod - Templating: Handlebars - Logging: Pino - Testing: Bun test ## Steps ### 1. Initialize the project ```bash cd /home/gjermund/projects/tessera bun init -y ``` Set name to "tessera", type to "module". ### 2. Install dependencies ```bash bun add hono zod drizzle-orm pg handlebars pino dotenv bun add -d drizzle-kit @types/pg @types/handlebars bun-types ``` ### 3. Create tsconfig.json - target: ES2022 - module: ESNext - moduleResolution: bundler - strict: true - paths: "@/*" → "./src/*" ### 4. Create the directory structure ``` src/ index.ts config.ts db/ schema.ts index.ts migrate.ts models/ ticket.ts queue.ts transaction.ts scrip.ts lifecycle.ts custom-field.ts user.ts scrip/ engine.ts conditions.ts actions.ts templates.ts lifecycle/ validator.ts routes/ health.ts tickets.ts queues.ts scrips.ts custom-fields.ts lifecycles.ts middleware/ error.ts logging.ts drizzle/ migrations/ tests/ ``` ### 5. Create each file with actual working code #### src/index.ts - Create Hono app - Register routes from ./routes/* - Register middleware (error handler, request logging) - Export the app #### src/config.ts - Parse DATABASE_URL, SERVER_HOST, SERVER_PORT from env - Zod schema with defaults: host=127.0.0.1, port=8080 - Export typed config object #### src/db/schema.ts Define Drizzle tables matching these PostgreSQL tables: ```sql tickets: id (uuid pk default gen_random_uuid), subject (text not null), queue_id (uuid not null ref queues.id), status (text not null), owner_id (uuid ref users.id), creator_id (uuid not null ref users.id), created_at (timestamptz default now), updated_at (timestamptz default now), started_at (timestamptz), resolved_at (timestamptz) queues: id (uuid pk default gen_random_uuid), name (text not null unique), description (text), lifecycle_id (uuid ref lifecycles.id), created_at (timestamptz default now) lifecycles: id (uuid pk default gen_random_uuid), name (text not null unique), definition (jsonb not null), created_at (timestamptz default now) transactions: id (uuid pk default gen_random_uuid), ticket_id (uuid not null ref tickets.id on delete cascade), transaction_type (text not null), field (text), old_value (text), new_value (text), data (jsonb), creator_id (uuid not null ref users.id), created_at (timestamptz default now) scrips: id (uuid pk default gen_random_uuid), queue_id (uuid ref queues.id, null=global), name (text not null), description (text), condition_type (text not null), condition_config (jsonb not null default '{}'), action_type (text not null), action_config (jsonb not null default '{}'), template_id (uuid ref templates.id), stage (text not null default 'TransactionCreate'), sort_order (int not null default 0), disabled (bool not null default false), created_at (timestamptz default now) templates: id (uuid pk default gen_random_uuid), name (text not null), queue_id (uuid ref queues.id, null=global), subject_template (text not null), body_template (text not null), created_at (timestamptz default now) custom_fields: id (uuid pk default gen_random_uuid), name (text not null), field_type (text not null), values (jsonb), max_values (int not null default 1), pattern (text), created_at (timestamptz default now) queue_custom_fields: id (uuid pk default gen_random_uuid), queue_id (uuid not null ref queues.id), custom_field_id (uuid not null ref custom_fields.id on delete cascade), sort_order (int not null default 0), unique(queue_id, custom_field_id) custom_field_values: id (uuid pk default gen_random_uuid), custom_field_id (uuid not null ref custom_fields.id on delete cascade), ticket_id (uuid not null ref tickets.id on delete cascade), value (text not null), created_at (timestamptz default now), unique(custom_field_id, ticket_id, value) users: id (uuid pk default gen_random_uuid), username (text not null unique), email (text), created_at (timestamptz default now) ``` Add indexes 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). #### src/db/index.ts - Create and export a Drizzle connection pool function - Accept DATABASE_URL parameter, return drizzle instance #### src/db/migrate.ts - Standalone script: `bun run src/db/migrate.ts` - Run Drizzle migrations from the drizzle/migrations directory #### src/models/*.ts Each model file exports TypeScript interfaces/types (from Drizzle inference) and Zod schemas for validation. ticket.ts: Ticket interface, CreateTicketSchema (subject, queue_id), UpdateTicketSchema (subject?, status?, owner_id?) queue.ts: Queue interface, CreateQueueSchema transaction.ts: Transaction interface, TransactionType enum scrip.ts: Scrip interface, ScripStage enum, CreateScripSchema lifecycle.ts: Lifecycle interface custom-field.ts: CustomField interface, CustomFieldType enum #### src/scrip/engine.ts ScripEngine class: - constructor(db: Drizzle instance) - prepare(ticketId: string, transactions: Transaction[]): PreparedScrip[] - Load scrips from DB (global + queue-specific, filtered by condition_type matching transaction type, sorted by sort_order) - For each: evaluate condition → if matches, render template → build PreparedScrip - commit(prepared: PreparedScrip[]): ScripResult[] - Execute each action - Returns results - PreparedScrip type: { scripId, scripName, actionType, actionPayload, dryRun } - ScripResult type: { scripId, success, message } - For MVP, conditions and actions are mocked (log what would happen) #### src/scrip/conditions.ts - ConditionEvaluator interface: evaluate(ticket, transactions) → boolean - OnCreate: true if any transaction is type "Create" - OnStatusChange: true if any transaction is type "StatusChange" - OnResolve: true if any status change goes to a "resolved"-like status #### src/scrip/actions.ts - ActionExecutor interface: execute(payload) → { success, message } - SendEmail: logs the email it would send (subject, body, recipients from payload) - Webhook: logs the webhook it would fire (url, method, headers, body from payload) - SetCustomField: logs the CF it would set (field_id, value from payload) #### src/scrip/templates.ts - TemplateRenderer class using Handlebars - render(template, context) → { subject, body } - Context includes: ticket (id, subject, status, etc.), queue (name), transaction (type, field, old_value, new_value), custom_fields (map of name→value) #### src/lifecycle/validator.ts - LifecycleValidator class - validateTransition(lifecycleDef, fromStatus, toStatus) → { valid, error? } - Check that toStatus is in the lifecycle's valid statuses - Check that fromStatus→toStatus is an allowed transition - Handle wildcard transitions (*) - isResolvedStatus(lifecycleDef, status) → boolean - A status is "resolved" if it's in the inactive category - For MVP, lifecycle definition is a JSON object: { statuses: { initial: [...], active: [...], inactive: [...] }, transitions: { "from": ["to", ...], ... } } #### src/routes/*.ts Each route file exports a Hono router: health.ts: GET /health → { status: "ok", version: "0.1.0" } tickets.ts: - GET / → list tickets (query params: queue_id?, status?) - POST / → create ticket (validate body, insert, return ticket) - GET /:id → get ticket with custom field values - PATCH /:id → update ticket (validate transition, create transaction, run scrips, return result) - POST /:id/preview → dry-run (validate transition, run prepare only, return prepared scrips) - GET /:id/transactions → list transactions for ticket queues.ts: - GET / → list queues - POST / → create queue scrips.ts: - GET / → list scrips - POST / → create scrip - GET /:id → get scrip - PATCH /:id → update scrip custom-fields.ts: - GET / → list custom fields - POST / → create custom field lifecycles.ts: - GET / → list lifecycles - POST / → create lifecycle ### 6. Create drizzle.config.ts ```typescript import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle/migrations", dialect: "postgresql", dbCredentials: { url: process.env.DATABASE_URL!, }, }); ``` ### 7. Update .gitignore Add: node_modules/, .env, dist/, bun.lock (if not already ignored) ### 8. Create .env.example ``` DATABASE_URL=postgres://tessera:password@localhost:5432/tessera SERVER_HOST=127.0.0.1 SERVER_PORT=8080 ``` ### 9. Create a drizzle migration ```bash bun run drizzle-kit generate ``` Then verify a migration SQL file was created in drizzle/migrations/ ### 10. Verify the project compiles ```bash bun run src/index.ts --help 2>&1 | head -5 ``` Or just check TypeScript: `bun run --print "import './src/index.ts'"` to verify no syntax errors. ## Rules - Use `bun init` and `bun add` — not npm/yarn/pnpm - All code must type-check. Do `bun run --print` to verify no TS errors - Use proper TypeScript types — no `any` unless truly unavoidable - Drizzle schemas must match the architecture exactly - Routes must be functional — not just stubs returning 501 - The scrip engine must have the prepare/commit two-phase pattern even if actions are mocked - Lifecycle validator must handle wildcard transitions (*) - Commit to git after each major step ## Verification 1. `ls -la src/` — all directories exist with proper files 2. `bun run src/db/schema.ts` — Drizzle schema parses without errors (or use drizzle-kit check) 3. `bun run src/index.ts` — should start a server on port 8080 (or at least not crash with import errors) 4. `curl http://localhost:8080/health` — should return {"status":"ok","version":"0.1.0"}