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

253 lines
9.7 KiB
Markdown

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