diff --git a/docs/ts-scaffold-spec.md b/docs/ts-scaffold-spec.md new file mode 100644 index 0000000..16967d9 --- /dev/null +++ b/docs/ts-scaffold-spec.md @@ -0,0 +1,252 @@ +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"}