Files
tessera/docs/ts-scaffold-spec.md
2026-06-07 21:12:14 +02:00

9.7 KiB

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

cd /home/gjermund/projects/tessera
bun init -y

Set name to "tessera", type to "module".

2. Install dependencies

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:

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

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

bun run drizzle-kit generate

Then verify a migration SQL file was created in drizzle/migrations/

10. Verify the project compiles

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"}