Compare commits

...

7 Commits

Author SHA1 Message Date
Gjermund Høsøien Wiggen
60d2196e51 chore: exclude web and node_modules from tsconfig
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:52 +02:00
Gjermund Høsøien Wiggen
ade966ace7 docs: update CLAUDE.md with current project state and workflow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:34 +02:00
Gjermund Høsøien Wiggen
06cc7c79a3 feat: enhance frontend UI — command palette, admin redesign, API coverage
Types + API:
- Add User, TemplatePreview, QueueCustomField types
- Add getUsers, getTemplates, createTemplate, updateTemplate,
  previewTemplate, updateQueue, updateLifecycle, updateCustomField API functions

UI:
- Command palette: keyboard-first navigation with fuzzy ticket search
- Admin: comprehensive redesign with tab-based layout (Queues, Lifecycles,
  Scrips, Custom Fields, Templates, Users)
- Ticket list: improved inbox-style rows with quick actions
- Ticket detail: enhanced conversation thread and properties sidebar
- App shell: sidebar visual refinement with active indicator bar
- Theme toggle: smoother transitions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:28 +02:00
Gjermund Høsøien Wiggen
b96ba21e99 feat: add database seed script and utility scripts
- src/db/seed.ts: comprehensive seed data with idempotent upserts
  - 5 users (system, gjermund, operator, technician, analyst)
  - 5 queues (Support Desk, Operations, IT Infrastructure, Facilities, Field Ops)
  - 1 lifecycle (Demo service lifecycle with new→open→in_progress→resolved→closed)
  - 5 custom fields (impact, location, channel, urgency, outcome) with short keys
  - 10 realistic support tickets with varied statuses, custom fields, and history
  - 3 scrips (OnCreate email, OnResolve custom field, customer notification)
  - 2 templates (auto-response, resolve notification)
  - --reset flag to truncate all data before seeding
- scripts/smoke-test.ts: API smoke tests
- scripts/watch-frontend.sh: frontend dev helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:18 +02:00
Gjermund Høsøien Wiggen
54ef6fcc5b feat: add users and templates routes, enhance existing API routes
New routes:
- GET /users — list all users
- GET/POST /templates — list and create templates
- PATCH /templates/:id — update template
- POST /templates/preview — render template with ticket/demo context

Enhanced routes:
- tickets: custom field support on create, status classification helper
- custom-fields: PATCH endpoint, auto-generate short key from name
- lifecycles: PATCH endpoint
- queues: PATCH endpoint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:08 +02:00
Gjermund Høsøien Wiggen
e960df61ad feat: implement full scrip action engine with real executors
- SendEmail: real nodemailer transport with SMTP config, dynamic recipient resolution
  (static + ticket creator/owner lookup), Handlebars template support
- Webhook: HTTP POST/any method with configurable headers and JSON body
- FetchMetadata: external HTTP fetch, Handlebars URL/body templating,
  auto-adds result as comment/correspondence on ticket
- RunScript: arbitrary async JS execution with helpers (addComment,
  createTransaction, updateTicket, touchTicket), ticket context, and
  Drizzle ORM access
- SetCustomField: lookup by id/key/name, clear+insert value, record
  CustomFieldChange transaction
- CreateTransaction: insert arbitrary transaction record
- Add OnCustomFieldChange condition
- Pass condition_config to evaluator in engine

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:42:59 +02:00
Gjermund Høsøien Wiggen
9e884546f2 feat: add infrastructure foundation — scripts, schema key, new routes, model fields
- Add npm scripts for dev, migrate, seed, smoke
- Add key column to scrips table (unique short identifier)
- Register users and templates routes in server
- Set development: false in Bun.serve for production mode
- Add description and custom_fields to CreateTicketSchema
- Make owner_id nullable/optional for unassigned tickets
- Add migration for custom field key column

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:42:42 +02:00
35 changed files with 5990 additions and 1382 deletions

View File

@@ -40,20 +40,22 @@ tessera/
```bash
cd ~/projects/tessera
cp .env.example .env # Edit DATABASE_URL to point to postgres://tessera:***@127.0.0.1:5433/tessera
bun run src/index.ts # Starts on port 9876
npm run dev:backend # Starts API on port 9876
```
### Run migrations
```bash
bun run src/db/migrate.ts
npm run db:migrate
npm run db:seed # Optional demo data for UI review
npm run db:seed:reset # Reset local app data, then recreate demo data
```
### Start frontend
```bash
cd web
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server)
npx next build # Production build
npx next start --port 5173 # Production server
npm run build # Production build
npm run start # Production server on 127.0.0.1:3100
```
**Note:** `bun run dev` (Turbopack) has WebSocket HMR issues in this environment. Use production mode only.
@@ -103,7 +105,7 @@ OpenCode server: `opencode serve --port 4096` (Gjermund attaches with `opencode
After OpenCode makes changes:
1. `cd web && npx next build` — verify zero errors
2. `npx next start --port 5173` — restart production server
2. `npm run start` — restart production server on 127.0.0.1:3100
3. `git push` — push to origin
## Common Issues

View File

@@ -0,0 +1,10 @@
ALTER TABLE "custom_fields" ADD COLUMN "key" text;
--> statement-breakpoint
UPDATE "custom_fields"
SET "key" = trim(both '_' from regexp_replace(lower("name"), '[^a-z0-9]+', '_', 'g'));
--> statement-breakpoint
UPDATE "custom_fields" SET "key" = 'field_' || substring("id"::text, 1, 8) WHERE "key" IS NULL OR "key" = '';
--> statement-breakpoint
ALTER TABLE "custom_fields" ALTER COLUMN "key" SET NOT NULL;
--> statement-breakpoint
ALTER TABLE "custom_fields" ADD CONSTRAINT "custom_fields_key_unique" UNIQUE("key");

View File

@@ -15,6 +15,13 @@
"when": 1780867177929,
"tag": "0001_lovely_quentin_quire",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780904200000,
"tag": "0002_short_custom_field_keys",
"breakpoints": true
}
]
}
}

View File

@@ -3,6 +3,13 @@
"module": "src/index.ts",
"type": "module",
"private": true,
"scripts": {
"dev:backend": "bun run src/index.ts",
"db:migrate": "bun run src/db/migrate.ts",
"db:seed": "bun run src/db/seed.ts",
"db:seed:reset": "bun run src/db/seed.ts --reset",
"smoke": "bun run scripts/smoke-test.ts"
},
"devDependencies": {
"@types/bun": "latest",
"@types/handlebars": "^4.1.0",

112
scripts/smoke-test.ts Normal file
View File

@@ -0,0 +1,112 @@
const backendUrl = process.env.BACKEND_URL ?? 'http://127.0.0.1:9876';
const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:3100';
interface Ticket {
id: number;
subject: string;
}
interface Queue {
id: string;
name: string;
}
interface Transaction {
id: string;
ticket_id: number;
transaction_type: string;
}
async function requestJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${url} returned ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
async function requestOk(url: string): Promise<void> {
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`${url} returned ${response.status} ${response.statusText}`);
}
}
async function check(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
console.log(`ok ${name}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`fail ${name}`);
console.error(` ${message}`);
process.exitCode = 1;
}
}
async function main() {
let ticketForDetail: Ticket | null = null;
await check('backend health', async () => {
const health = await requestJson<{ status: string }>(`${backendUrl}/health`);
if (health.status !== 'ok') {
throw new Error(`expected status ok, got ${JSON.stringify(health)}`);
}
});
await check('queues exist', async () => {
const queues = await requestJson<Queue[]>(`${backendUrl}/queues`);
if (queues.length < 1) {
throw new Error('expected at least one queue');
}
});
await check('tickets exist', async () => {
const tickets = await requestJson<Ticket[]>(`${backendUrl}/tickets`);
if (tickets.length < 1) {
throw new Error('expected at least one ticket');
}
ticketForDetail = tickets.find((ticket) => ticket.subject.includes('VPN access')) ?? tickets[0] ?? null;
});
await check('ticket detail has activity', async () => {
if (!ticketForDetail) {
throw new Error('no ticket available for detail check');
}
const transactions = await requestJson<Transaction[]>(
`${backendUrl}/tickets/${ticketForDetail.id}/transactions`,
);
if (transactions.length < 1) {
throw new Error(`expected ticket ${ticketForDetail.id} to have transactions`);
}
});
await check('frontend index responds', async () => {
await requestOk(frontendUrl);
});
await check('frontend ticket detail responds', async () => {
if (!ticketForDetail) {
throw new Error('no ticket available for frontend detail check');
}
await requestOk(`${frontendUrl}/tickets/${ticketForDetail.id}`);
});
await check('frontend api proxy responds', async () => {
const health = await requestJson<{ status: string }>(`${frontendUrl}/api/health`);
if (health.status !== 'ok') {
throw new Error(`expected status ok, got ${JSON.stringify(health)}`);
}
});
if (process.exitCode) {
process.exit(process.exitCode);
}
console.log('Smoke test passed');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

15
scripts/watch-frontend.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Watch for source changes and auto-rebuild + restart Tessera frontend
DIR="/home/gjermund/projects/tessera/web/src"
LAST_BUILD=0
echo "Watching $DIR for changes..."
inotifywait -m -r -e modify,create,delete "$DIR" --format '%w%f' 2>/dev/null | while read FILE; do
NOW=$(date +%s)
if [ $((NOW - LAST_BUILD)) -gt 3 ]; then
echo "[$(date +%H:%M:%S)] Change detected, rebuilding..."
cd /home/gjermund/projects/tessera/web && npx next build 2>&1 | tail -1
LAST_BUILD=$NOW
fi
done

View File

@@ -83,6 +83,7 @@ export const scrips = pgTable('scrips', {
export const customFields = pgTable('custom_fields', {
id: uuid('id').primaryKey().defaultRandom(),
key: text('key').notNull().unique(),
name: text('name').notNull(),
field_type: text('field_type').notNull(),
values: jsonb('values'),

787
src/db/seed.ts Normal file
View File

@@ -0,0 +1,787 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { eq, inArray } from 'drizzle-orm';
import * as schema from './schema.ts';
import {
customFields,
customFieldValues,
lifecycles,
queueCustomFields,
queues,
scrips,
templates,
tickets,
transactions,
users,
} from './schema.ts';
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
const lifecycleDefinition = {
statuses: {
initial: ['new'],
active: ['open', 'in_progress'],
inactive: ['resolved', 'closed'],
},
transitions: {
new: ['open', 'in_progress', 'closed'],
open: ['in_progress', 'resolved', 'closed'],
in_progress: ['open', 'resolved', 'closed'],
resolved: ['open', 'closed'],
closed: ['open'],
'*': ['closed'],
},
};
function daysAgo(days: number, hour = 9, minute = 0): Date {
const date = new Date();
date.setDate(date.getDate() - days);
date.setHours(hour, minute, 0, 0);
return date;
}
function hoursAgo(hours: number): Date {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}
function createSeedDb(pool: Pool) {
return drizzle(pool, { schema });
}
type Db = ReturnType<typeof createSeedDb>;
type UserSeed = { id: string; username: string; email: string };
type QueueSeed = { name: string; description: string };
type FieldSeed = {
key?: string;
name: string;
field_type: string;
values?: unknown;
max_values?: number;
pattern?: string | null;
};
function makeFieldKey(value: string): string {
const key = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return key || 'field';
}
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
const existingById = await db.query.users.findFirst({
where: eq(users.id, seed.id),
});
if (existingById) {
await db.update(users)
.set({ username: seed.username, email: seed.email })
.where(eq(users.id, seed.id));
return existingById.id;
}
const existingByUsername = await db.query.users.findFirst({
where: eq(users.username, seed.username),
});
if (existingByUsername) {
await db.update(users)
.set({ email: seed.email })
.where(eq(users.id, existingByUsername.id));
return existingByUsername.id;
}
const [created] = await db.insert(users).values(seed).returning();
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
return created.id;
}
async function ensureLifecycle(db: Db) {
const existing = await db.query.lifecycles.findFirst({
where: eq(lifecycles.name, 'Demo service lifecycle'),
});
if (existing) {
const [updated] = await db.update(lifecycles)
.set({ definition: lifecycleDefinition })
.where(eq(lifecycles.id, existing.id))
.returning();
if (!updated) throw new Error('Failed to update demo lifecycle');
return updated;
}
const [created] = await db.insert(lifecycles).values({
name: 'Demo service lifecycle',
definition: lifecycleDefinition,
}).returning();
if (!created) throw new Error('Failed to seed demo lifecycle');
return created;
}
async function ensureQueue(db: Db, lifecycleId: string, seed: QueueSeed) {
const existing = await db.query.queues.findFirst({
where: eq(queues.name, seed.name),
});
if (existing) {
const [updated] = await db.update(queues)
.set({
description: seed.description,
lifecycle_id: lifecycleId,
})
.where(eq(queues.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update queue ${seed.name}`);
return updated;
}
const [created] = await db.insert(queues).values({
name: seed.name,
description: seed.description,
lifecycle_id: lifecycleId,
}).returning();
if (!created) throw new Error(`Failed to seed queue ${seed.name}`);
return created;
}
async function ensureCustomField(db: Db, seed: FieldSeed) {
const existing = await db.query.customFields.findFirst({
where: eq(customFields.name, seed.name),
});
const values = {
key: seed.key ?? makeFieldKey(seed.name),
field_type: seed.field_type,
values: seed.values ?? null,
max_values: seed.max_values ?? 1,
pattern: seed.pattern ?? null,
};
if (existing) {
const [updated] = await db.update(customFields)
.set(values)
.where(eq(customFields.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update custom field ${seed.name}`);
return updated;
}
const [created] = await db.insert(customFields).values({
name: seed.name,
...values,
}).returning();
if (!created) throw new Error(`Failed to seed custom field ${seed.name}`);
return created;
}
async function attachFieldToQueue(db: Db, queueId: string, fieldId: string, sortOrder: number) {
await db.insert(queueCustomFields)
.values({
queue_id: queueId,
custom_field_id: fieldId,
sort_order: sortOrder,
})
.onConflictDoUpdate({
target: [queueCustomFields.queue_id, queueCustomFields.custom_field_id],
set: { sort_order: sortOrder },
});
}
async function ensureTemplate(
db: Db,
name: string,
queueId: string | null,
subjectTemplate: string,
bodyTemplate: string,
) {
const existing = await db.query.templates.findFirst({
where: (row, { and, eq, isNull }) =>
queueId ? and(eq(row.name, name), eq(row.queue_id, queueId)) : and(eq(row.name, name), isNull(row.queue_id)),
});
if (existing) {
const [updated] = await db.update(templates)
.set({ subject_template: subjectTemplate, body_template: bodyTemplate })
.where(eq(templates.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update template ${name}`);
return updated;
}
const [created] = await db.insert(templates).values({
name,
queue_id: queueId,
subject_template: subjectTemplate,
body_template: bodyTemplate,
}).returning();
if (!created) throw new Error(`Failed to seed template ${name}`);
return created;
}
async function ensureScrip(
db: Db,
seed: {
name: string;
description: string;
queueId: string | null;
conditionType: string;
actionType: string;
actionConfig: Record<string, unknown>;
templateId?: string | null;
sortOrder: number;
disabled?: boolean;
},
) {
const existing = await db.query.scrips.findFirst({
where: (row, { and, eq, isNull }) =>
seed.queueId
? and(eq(row.name, seed.name), eq(row.queue_id, seed.queueId))
: and(eq(row.name, seed.name), isNull(row.queue_id)),
});
const values = {
queue_id: seed.queueId,
name: seed.name,
description: seed.description,
condition_type: seed.conditionType,
condition_config: {},
action_type: seed.actionType,
action_config: seed.actionConfig,
template_id: seed.templateId ?? null,
stage: 'TransactionCreate',
sort_order: seed.sortOrder,
disabled: seed.disabled ?? false,
};
if (existing) {
const [updated] = await db.update(scrips)
.set(values)
.where(eq(scrips.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update scrip ${seed.name}`);
return updated;
}
const [created] = await db.insert(scrips).values(values).returning();
if (!created) throw new Error(`Failed to seed scrip ${seed.name}`);
return created;
}
async function ensureTicket(
db: Db,
seed: {
subject: string;
queueId: string;
status: string;
ownerId: string | null;
creatorId: string;
createdAt: Date;
updatedAt: Date;
startedAt?: Date | null;
resolvedAt?: Date | null;
},
) {
const existing = await db.query.tickets.findFirst({
where: eq(tickets.subject, seed.subject),
});
const values = {
subject: seed.subject,
queue_id: seed.queueId,
status: seed.status,
owner_id: seed.ownerId,
creator_id: seed.creatorId,
created_at: seed.createdAt,
updated_at: seed.updatedAt,
started_at: seed.startedAt ?? null,
resolved_at: seed.resolvedAt ?? null,
};
if (existing) {
const [updated] = await db.update(tickets)
.set(values)
.where(eq(tickets.id, existing.id))
.returning();
if (!updated) throw new Error(`Failed to update ticket ${seed.subject}`);
return updated;
}
const [created] = await db.insert(tickets).values(values).returning();
if (!created) throw new Error(`Failed to seed ticket ${seed.subject}`);
return created;
}
async function resetDatabase(db: Db) {
await db.delete(customFieldValues);
await db.delete(transactions);
await db.delete(queueCustomFields);
await db.delete(scrips);
await db.delete(templates);
await db.delete(tickets);
await db.delete(queues);
await db.delete(customFields);
await db.delete(lifecycles);
await db.delete(users);
}
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
const pool = new Pool({ connectionString: databaseUrl });
const db = createSeedDb(pool);
const reset = process.argv.includes('--reset');
try {
if (reset) {
console.log('Resetting database before seeding demo data...');
await resetDatabase(db);
}
const userIds = {
system: await ensureUser(db, {
id: SYSTEM_USER_ID,
username: 'system',
email: 'system@tessera.local',
}),
dispatcher: await ensureUser(db, {
id: '11111111-1111-4111-8111-111111111111',
username: 'maria.dispatch',
email: 'maria.dispatch@tessera.local',
}),
technician: await ensureUser(db, {
id: '22222222-2222-4222-8222-222222222222',
username: 'liam.field',
email: 'liam.field@tessera.local',
}),
facilities: await ensureUser(db, {
id: '33333333-3333-4333-8333-333333333333',
username: 'nora.facilities',
email: 'nora.facilities@tessera.local',
}),
security: await ensureUser(db, {
id: '44444444-4444-4444-8444-444444444444',
username: 'sam.security',
email: 'sam.security@tessera.local',
}),
};
const lifecycle = await ensureLifecycle(db);
const supportQueue = await ensureQueue(db, lifecycle.id, {
name: 'Support Desk',
description: 'Employee requests, account access, hardware, and everyday service desk intake.',
});
const fieldQueue = await ensureQueue(db, lifecycle.id, {
name: 'Field Operations',
description: 'Technician dispatch, site work, parts, and customer-impacting operational issues.',
});
const facilitiesQueue = await ensureQueue(db, lifecycle.id, {
name: 'Facilities',
description: 'Building maintenance, access, meeting rooms, and office environment requests.',
});
const securityQueue = await ensureQueue(db, lifecycle.id, {
name: 'Security',
description: 'Badge access, incident review, and compliance-sensitive operational requests.',
});
const impactField = await ensureCustomField(db, {
key: 'impact',
name: 'Impact',
field_type: 'select',
values: ['Low', 'Medium', 'High', 'Critical'],
});
const locationField = await ensureCustomField(db, {
key: 'location',
name: 'Location',
field_type: 'text',
});
const assetField = await ensureCustomField(db, {
key: 'asset_tag',
name: 'Asset tag',
field_type: 'text',
pattern: '^ASSET-[0-9]{4}$',
});
const channelField = await ensureCustomField(db, {
key: 'channel',
name: 'Channel',
field_type: 'select',
values: ['Portal', 'Email', 'Phone', 'Walk-up', 'Monitoring'],
});
const outcomeField = await ensureCustomField(db, {
key: 'resolution_outcome',
name: 'Resolution outcome',
field_type: 'select',
values: ['Completed', 'Workaround', 'Duplicate', 'Declined'],
});
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
await attachFieldToQueue(db, queue.id, impactField.id, 10);
await attachFieldToQueue(db, queue.id, locationField.id, 20);
await attachFieldToQueue(db, queue.id, channelField.id, 30);
}
await attachFieldToQueue(db, supportQueue.id, assetField.id, 40);
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
const resolveTemplate = await ensureTemplate(
db,
'Demo resolution note',
null,
'Ticket {{ticket.id}} resolved: {{ticket.subject}}',
'Ticket {{ticket.id}} in {{queue.name}} moved from {{transaction.old_value}} to {{transaction.new_value}}.',
);
await ensureScrip(db, {
name: 'Demo: mark outcome on resolve',
description: 'When a ticket resolves, set the Resolution outcome custom field to Completed.',
queueId: null,
conditionType: 'OnResolve',
actionType: 'SetCustomField',
actionConfig: {
field_key: 'resolution_outcome',
value: 'Completed',
},
sortOrder: 10,
});
await ensureScrip(db, {
name: 'Demo: customer notification template',
description: 'Disabled sample email action showing how resolution templates render.',
queueId: null,
conditionType: 'OnResolve',
actionType: 'SendEmail',
actionConfig: {
recipients: ['requester@example.com'],
},
templateId: resolveTemplate.id,
sortOrder: 20,
disabled: true,
});
const demoTickets = [
await ensureTicket(db, {
subject: 'VPN access fails after password reset',
queueId: supportQueue.id,
status: 'open',
ownerId: userIds.dispatcher,
creatorId: userIds.system,
createdAt: daysAgo(4, 8, 40),
updatedAt: hoursAgo(3),
startedAt: daysAgo(4, 9, 10),
}),
await ensureTicket(db, {
subject: 'Warehouse scanner ASSET-1042 will not sync inventory',
queueId: fieldQueue.id,
status: 'in_progress',
ownerId: userIds.technician,
creatorId: userIds.system,
createdAt: daysAgo(2, 10, 15),
updatedAt: hoursAgo(1),
startedAt: daysAgo(2, 11, 0),
}),
await ensureTicket(db, {
subject: 'Badge reader intermittently denies access at north entrance',
queueId: securityQueue.id,
status: 'new',
ownerId: null,
creatorId: userIds.system,
createdAt: hoursAgo(7),
updatedAt: hoursAgo(7),
}),
await ensureTicket(db, {
subject: 'Conference room display flickers during video calls',
queueId: facilitiesQueue.id,
status: 'open',
ownerId: userIds.facilities,
creatorId: userIds.system,
createdAt: daysAgo(1, 14, 20),
updatedAt: hoursAgo(4),
startedAt: daysAgo(1, 15, 0),
}),
await ensureTicket(db, {
subject: 'New hire laptop provisioning for Monday start',
queueId: supportQueue.id,
status: 'resolved',
ownerId: userIds.dispatcher,
creatorId: userIds.system,
createdAt: daysAgo(6, 13, 30),
updatedAt: daysAgo(1, 16, 45),
startedAt: daysAgo(6, 14, 0),
resolvedAt: daysAgo(1, 16, 45),
}),
await ensureTicket(db, {
subject: 'Temperature alert in server closet B',
queueId: facilitiesQueue.id,
status: 'in_progress',
ownerId: userIds.facilities,
creatorId: userIds.system,
createdAt: hoursAgo(18),
updatedAt: hoursAgo(2),
startedAt: hoursAgo(17),
}),
await ensureTicket(db, {
subject: 'Quarterly access review export requested',
queueId: securityQueue.id,
status: 'closed',
ownerId: userIds.security,
creatorId: userIds.system,
createdAt: daysAgo(9, 10, 0),
updatedAt: daysAgo(3, 11, 20),
startedAt: daysAgo(9, 10, 30),
resolvedAt: daysAgo(3, 11, 20),
}),
await ensureTicket(db, {
subject: 'POS terminal receipt printer jam at front desk',
queueId: fieldQueue.id,
status: 'new',
ownerId: null,
creatorId: userIds.system,
createdAt: hoursAgo(5),
updatedAt: hoursAgo(5),
}),
];
const demoTicketIds = demoTickets.map((ticket) => ticket.id);
if (demoTicketIds.length > 0) {
await db.delete(customFieldValues).where(inArray(customFieldValues.ticket_id, demoTicketIds));
await db.delete(transactions).where(inArray(transactions.ticket_id, demoTicketIds));
}
const ticketBySubject = new Map(demoTickets.map((ticket) => [ticket.subject, ticket]));
const txRows = [
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(4, 8, 40),
},
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
creator_id: userIds.dispatcher,
created_at: daysAgo(4, 9, 10),
},
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'Correspond',
data: { body: 'I reset my password this morning and now the VPN client rejects the new password. Browser login works.' },
creator_id: userIds.system,
created_at: daysAgo(4, 9, 12),
},
{
ticket_id: ticketBySubject.get('VPN access fails after password reset')!.id,
transaction_type: 'Comment',
data: { body: 'Likely stale cached credentials. Ask user to clear saved VPN profile and confirm MFA prompt.' },
creator_id: userIds.dispatcher,
created_at: hoursAgo(3),
},
{
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(2, 10, 15),
},
{
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'in_progress',
creator_id: userIds.technician,
created_at: daysAgo(2, 11, 0),
},
{
ticket_id: ticketBySubject.get('Warehouse scanner ASSET-1042 will not sync inventory')!.id,
transaction_type: 'Comment',
data: { body: 'Device reaches Wi-Fi but sync service returns 409. Pulling logs before factory reset.' },
creator_id: userIds.technician,
created_at: hoursAgo(1),
},
{
ticket_id: ticketBySubject.get('Badge reader intermittently denies access at north entrance')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: hoursAgo(7),
},
{
ticket_id: ticketBySubject.get('Badge reader intermittently denies access at north entrance')!.id,
transaction_type: 'Correspond',
data: { body: 'Three employees reported failures between 07:40 and 08:05. Security desk can override manually.' },
creator_id: userIds.security,
created_at: hoursAgo(6),
},
{
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(1, 14, 20),
},
{
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
creator_id: userIds.facilities,
created_at: daysAgo(1, 15, 0),
},
{
ticket_id: ticketBySubject.get('Conference room display flickers during video calls')!.id,
transaction_type: 'Comment',
data: { body: 'Cable path looks strained. Spare HDMI and USB-C adapters staged in the room.' },
creator_id: userIds.facilities,
created_at: hoursAgo(4),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(6, 13, 30),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
creator_id: userIds.dispatcher,
created_at: daysAgo(6, 14, 0),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'Correspond',
data: { body: 'Laptop imaged, account created, and pickup instructions sent to hiring manager.' },
creator_id: userIds.dispatcher,
created_at: daysAgo(1, 16, 20),
},
{
ticket_id: ticketBySubject.get('New hire laptop provisioning for Monday start')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'open',
new_value: 'resolved',
creator_id: userIds.dispatcher,
created_at: daysAgo(1, 16, 45),
},
{
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: hoursAgo(18),
},
{
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'in_progress',
creator_id: userIds.facilities,
created_at: hoursAgo(17),
},
{
ticket_id: ticketBySubject.get('Temperature alert in server closet B')!.id,
transaction_type: 'Comment',
data: { body: 'Portable cooling installed. HVAC vendor scheduled; rack intake is back under threshold.' },
creator_id: userIds.facilities,
created_at: hoursAgo(2),
},
{
ticket_id: ticketBySubject.get('Quarterly access review export requested')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: daysAgo(9, 10, 0),
},
{
ticket_id: ticketBySubject.get('Quarterly access review export requested')!.id,
transaction_type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'closed',
creator_id: userIds.security,
created_at: daysAgo(3, 11, 20),
},
{
ticket_id: ticketBySubject.get('POS terminal receipt printer jam at front desk')!.id,
transaction_type: 'Create',
field: 'status',
new_value: 'new',
creator_id: userIds.system,
created_at: hoursAgo(5),
},
{
ticket_id: ticketBySubject.get('POS terminal receipt printer jam at front desk')!.id,
transaction_type: 'Correspond',
data: { body: 'Front desk can still email receipts, but lunch rush needs a working printer.' },
creator_id: userIds.system,
created_at: hoursAgo(5),
},
];
await db.insert(transactions).values(txRows);
const fieldRows = [
['VPN access fails after password reset', impactField.id, 'Medium'],
['VPN access fails after password reset', channelField.id, 'Portal'],
['VPN access fails after password reset', locationField.id, 'Remote'],
['Warehouse scanner ASSET-1042 will not sync inventory', impactField.id, 'High'],
['Warehouse scanner ASSET-1042 will not sync inventory', channelField.id, 'Phone'],
['Warehouse scanner ASSET-1042 will not sync inventory', locationField.id, 'Warehouse A'],
['Warehouse scanner ASSET-1042 will not sync inventory', assetField.id, 'ASSET-1042'],
['Badge reader intermittently denies access at north entrance', impactField.id, 'High'],
['Badge reader intermittently denies access at north entrance', channelField.id, 'Walk-up'],
['Badge reader intermittently denies access at north entrance', locationField.id, 'North entrance'],
['Conference room display flickers during video calls', impactField.id, 'Medium'],
['Conference room display flickers during video calls', channelField.id, 'Email'],
['Conference room display flickers during video calls', locationField.id, 'Room 4B'],
['New hire laptop provisioning for Monday start', impactField.id, 'Low'],
['New hire laptop provisioning for Monday start', channelField.id, 'Portal'],
['New hire laptop provisioning for Monday start', assetField.id, 'ASSET-2201'],
['New hire laptop provisioning for Monday start', outcomeField.id, 'Completed'],
['Temperature alert in server closet B', impactField.id, 'Critical'],
['Temperature alert in server closet B', channelField.id, 'Monitoring'],
['Temperature alert in server closet B', locationField.id, 'Server closet B'],
['Quarterly access review export requested', impactField.id, 'Low'],
['Quarterly access review export requested', channelField.id, 'Portal'],
['Quarterly access review export requested', outcomeField.id, 'Completed'],
['POS terminal receipt printer jam at front desk', impactField.id, 'Medium'],
['POS terminal receipt printer jam at front desk', channelField.id, 'Phone'],
['POS terminal receipt printer jam at front desk', locationField.id, 'Front desk'],
] as const;
await db.insert(customFieldValues).values(fieldRows.map(([subject, fieldId, value]) => ({
ticket_id: ticketBySubject.get(subject)!.id,
custom_field_id: fieldId,
value,
})));
console.log(`${reset ? 'Reset and seeded' : 'Seeded'} ${demoTickets.length} demo tickets across 4 queues`);
console.log('Demo data ready');
} finally {
await pool.end();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -10,6 +10,8 @@ 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';
import { createUsersRouter } from './routes/users.ts';
import { createTemplatesRouter } from './routes/templates.ts';
let db: Db | null = null;
@@ -31,6 +33,8 @@ app.route('/queues', createQueuesRouter(getDb()));
app.route('/scrips', createScripsRouter(getDb()));
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
app.route('/lifecycles', createLifecyclesRouter(getDb()));
app.route('/users', createUsersRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb()));
export default app;
export { app };
@@ -41,6 +45,7 @@ if (Bun.main === import.meta.path) {
fetch: app.fetch,
port: config.SERVER_PORT,
hostname: config.SERVER_HOST,
development: false,
});
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
}

View File

@@ -7,12 +7,14 @@ export type Ticket = InferSelectModel<typeof tickets>;
export const CreateTicketSchema = z.object({
subject: z.string().min(1),
queue_id: z.string().uuid(),
description: z.string().trim().optional(),
custom_fields: z.record(z.string(), z.string()).optional(),
});
export const UpdateTicketSchema = z.object({
subject: z.string().min(1).optional(),
status: z.string().min(1).optional(),
owner_id: z.string().uuid().optional(),
owner_id: z.string().uuid().nullable().optional(),
});
export const CommentSchema = z.object({

View File

@@ -1,8 +1,17 @@
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';
import { customFields, queueCustomFields } from '../db/schema.ts';
import { and, asc, eq } from 'drizzle-orm';
function makeFieldKey(value: string): string {
const key = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return key || 'field';
}
export function createCustomFieldsRouter(db: Db): Hono {
const router = new Hono();
@@ -17,12 +26,14 @@ export function createCustomFieldsRouter(db: Db): Hono {
router.post('/', async (c) => {
const body = await c.req.json();
const { name, field_type, values, max_values, pattern } = body;
const key = makeFieldKey(String(body.key ?? name ?? ''));
if (!name || !field_type) {
throw new HTTPException(400, { message: 'name and field_type are required' });
}
const [cf] = await db.insert(customFields).values({
key,
name,
field_type,
values: values ?? null,
@@ -37,5 +48,94 @@ export function createCustomFieldsRouter(db: Db): Hono {
return c.json(cf, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.customFields.findFirst({
where: eq(customFields.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Custom field not found' });
}
const updateData: Partial<typeof customFields.$inferInsert> = {};
if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key));
if (body.name !== undefined) updateData.name = String(body.name);
if (body.field_type !== undefined) updateData.field_type = String(body.field_type);
if (body.values !== undefined) updateData.values = body.values ?? null;
if (body.max_values !== undefined) updateData.max_values = Number(body.max_values);
if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null;
const [updated] = await db.update(customFields)
.set(updateData)
.where(eq(customFields.id, id))
.returning();
return c.json(updated);
});
router.get('/queues/:queueId', async (c) => {
const queueId = c.req.param('queueId');
const assignments = await db.query.queueCustomFields.findMany({
where: eq(queueCustomFields.queue_id, queueId),
orderBy: asc(queueCustomFields.sort_order),
});
const fieldIds = assignments.map((assignment) => assignment.custom_field_id);
const fields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldMap = new Map(fields.map((field) => [field.id, field]));
return c.json(assignments.map((assignment) => ({
...assignment,
custom_field: fieldMap.get(assignment.custom_field_id) ?? null,
})));
});
router.post('/queues/:queueId', async (c) => {
const queueId = c.req.param('queueId');
const body = await c.req.json();
const customFieldId = body.custom_field_id;
if (!customFieldId) {
throw new HTTPException(400, { message: 'custom_field_id is required' });
}
const [assignment] = await db.insert(queueCustomFields).values({
queue_id: queueId,
custom_field_id: customFieldId,
sort_order: Number(body.sort_order ?? 0),
}).onConflictDoNothing().returning();
if (assignment) {
return c.json(assignment, 201);
}
const existing = await db.query.queueCustomFields.findFirst({
where: and(
eq(queueCustomFields.queue_id, queueId),
eq(queueCustomFields.custom_field_id, customFieldId),
),
});
return c.json(existing, 200);
});
router.delete('/queues/:queueId/:fieldId', async (c) => {
const queueId = c.req.param('queueId');
const fieldId = c.req.param('fieldId');
await db.delete(queueCustomFields).where(and(
eq(queueCustomFields.queue_id, queueId),
eq(queueCustomFields.custom_field_id, fieldId),
));
return c.json({ ok: true });
});
return router;
}

View File

@@ -2,7 +2,7 @@ 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';
import { asc, eq } from 'drizzle-orm';
export function createLifecyclesRouter(db: Db): Hono {
const router = new Hono();
@@ -34,5 +34,29 @@ export function createLifecyclesRouter(db: Db): Hono {
return c.json(lifecycle, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Lifecycle not found' });
}
const updateData: Partial<typeof lifecycles.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name);
if (body.definition !== undefined) updateData.definition = body.definition;
const [updated] = await db.update(lifecycles)
.set(updateData)
.where(eq(lifecycles.id, id))
.returning();
return c.json(updated);
});
return router;
}

View File

@@ -2,7 +2,7 @@ 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 { asc, eq } from 'drizzle-orm';
import { CreateQueueSchema } from '../models/queue.ts';
export function createQueuesRouter(db: Db): Hono {
@@ -32,5 +32,30 @@ export function createQueuesRouter(db: Db): Hono {
return c.json(queue, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.queues.findFirst({
where: eq(queues.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Queue not found' });
}
const updateData: Partial<typeof queues.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name);
if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null;
if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null;
const [updated] = await db.update(queues)
.set(updateData)
.where(eq(queues.id, id))
.returning();
return c.json(updated);
});
return router;
}

174
src/routes/templates.ts Normal file
View File

@@ -0,0 +1,174 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, desc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { customFieldValues, queues, templates, tickets, transactions } from '../db/schema.ts';
import { TemplateRenderer } from '../scrip/templates.ts';
import type { TemplateContext } from '../scrip/templates.ts';
function buildDemoContext(): TemplateContext {
return {
ticket: {
id: 1001,
subject: 'Replace access badge reader',
status: 'open',
queue_id: 'demo-queue',
owner_id: null,
creator_id: 'demo-user',
created_at: new Date('2026-06-08T08:00:00.000Z').toISOString(),
updated_at: new Date('2026-06-08T09:15:00.000Z').toISOString(),
},
queue: { name: 'Support Desk' },
transaction: {
type: 'StatusChange',
field: 'status',
old_value: 'new',
new_value: 'open',
},
custom_fields: {
impact: 'High',
location: 'HQ 2nd floor',
channel: 'Portal',
},
};
}
async function buildTicketContext(db: Db, ticketId: number): Promise<TemplateContext> {
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const queue = await db.query.queues.findFirst({
where: eq(queues.id, ticket.queue_id),
});
const latestTx = await db.query.transactions.findFirst({
where: eq(transactions.ticket_id, ticket.id),
orderBy: desc(transactions.created_at),
});
const cfValues = await db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, ticket.id),
});
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
const fields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldById = new Map(fields.map((field) => [field.id, field]));
const customFieldsMap: Record<string, string> = {};
for (const value of cfValues) {
const field = fieldById.get(value.custom_field_id);
if (field) customFieldsMap[field.key] = value.value;
}
return {
ticket: {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
queue_id: ticket.queue_id,
owner_id: ticket.owner_id,
creator_id: ticket.creator_id,
created_at: ticket.created_at?.toISOString() ?? new Date().toISOString(),
updated_at: ticket.updated_at?.toISOString() ?? new Date().toISOString(),
},
queue: {
name: queue?.name ?? 'unknown',
},
transaction: {
type: latestTx?.transaction_type ?? 'Preview',
field: latestTx?.field ?? null,
old_value: latestTx?.old_value ?? null,
new_value: latestTx?.new_value ?? null,
},
custom_fields: customFieldsMap,
};
}
export function createTemplatesRouter(db: Db): Hono {
const router = new Hono();
const renderer = new TemplateRenderer();
router.get('/', async (c) => {
const result = await db.query.templates.findMany({
orderBy: asc(templates.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
const subjectTemplate = String(body.subject_template ?? '');
const bodyTemplate = String(body.body_template ?? '');
if (!name || !subjectTemplate || !bodyTemplate) {
throw new HTTPException(400, { message: 'name, subject_template, and body_template are required' });
}
const [template] = await db.insert(templates).values({
name,
queue_id: body.queue_id || null,
subject_template: subjectTemplate,
body_template: bodyTemplate,
}).returning();
if (!template) {
throw new HTTPException(500, { message: 'Failed to create template' });
}
return c.json(template, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.templates.findFirst({
where: eq(templates.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Template not found' });
}
const updateData: Partial<typeof templates.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.queue_id !== undefined) updateData.queue_id = body.queue_id || null;
if (body.subject_template !== undefined) updateData.subject_template = String(body.subject_template);
if (body.body_template !== undefined) updateData.body_template = String(body.body_template);
const [updated] = await db.update(templates)
.set(updateData)
.where(eq(templates.id, id))
.returning();
return c.json(updated);
});
router.post('/preview', async (c) => {
const body = await c.req.json();
const subjectTemplate = String(body.subject_template ?? '');
const bodyTemplate = String(body.body_template ?? '');
const ticketId = body.ticket_id === undefined || body.ticket_id === null || body.ticket_id === ''
? null
: Number(body.ticket_id);
if (!subjectTemplate || !bodyTemplate) {
throw new HTTPException(400, { message: 'subject_template and body_template are required' });
}
const context = ticketId ? await buildTicketContext(db, ticketId) : buildDemoContext();
return c.json({
...renderer.render(subjectTemplate, bodyTemplate, context),
context,
});
});
return router;
}

View File

@@ -1,8 +1,8 @@
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 { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
import { and, eq, asc } from 'drizzle-orm';
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
import { ScripEngine } from '../scrip/engine.ts';
import { LifecycleValidator } from '../lifecycle/validator.ts';
@@ -13,21 +13,106 @@ export function createTicketsRouter(db: Db): Hono {
const scripEngine = new ScripEngine(db);
const lifecycleValidator = new LifecycleValidator();
function statusClass(def: LifecycleDefinition, status: string): 'initial' | 'active' | 'inactive' | 'unknown' {
if (def.statuses.initial.includes(status)) return 'initial';
if (def.statuses.active.includes(status)) return 'active';
if (def.statuses.inactive.includes(status)) return 'inactive';
return 'unknown';
}
// GET / — list tickets
router.get('/', async (c) => {
const params = new URL(c.req.url).searchParams;
const queueId = c.req.query('queue_id');
const status = c.req.query('status');
const ownerId = c.req.query('owner_id');
const query = c.req.query('q')?.trim().toLowerCase() ?? '';
const cfFilters = [...params.entries()]
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
.map(([key, value]) => ({
key: key.slice(3),
value: value.trim().toLowerCase(),
}));
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;
},
let result = await db.query.tickets.findMany({
orderBy: asc(tickets.created_at),
});
if (queueId) {
result = result.filter((ticket) => ticket.queue_id === queueId);
}
if (status) {
result = result.filter((ticket) => ticket.status === status);
}
if (ownerId) {
result = ownerId === 'unassigned'
? result.filter((ticket) => !ticket.owner_id)
: result.filter((ticket) => ticket.owner_id === ownerId);
}
const needsCustomFields = query || cfFilters.length > 0;
const valuesByTicket = new Map<number, { fieldId: string; fieldKey: string; fieldName: string; value: string }[]>();
if (needsCustomFields && result.length > 0) {
const ticketIds = result.map((ticket) => ticket.id);
const cfValues = await db.query.customFieldValues.findMany({
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
});
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))];
const fields = fieldIds.length > 0
? await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, fieldIds),
})
: [];
const fieldMap = new Map(fields.map((field) => [field.id, field]));
for (const value of cfValues) {
const rows = valuesByTicket.get(value.ticket_id) ?? [];
rows.push({
fieldId: value.custom_field_id,
fieldKey: fieldMap.get(value.custom_field_id)?.key ?? value.custom_field_id,
fieldName: fieldMap.get(value.custom_field_id)?.name ?? value.custom_field_id,
value: value.value,
});
valuesByTicket.set(value.ticket_id, rows);
}
}
if (query) {
const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
result = result.filter((ticket) => {
const customFields = valuesByTicket.get(ticket.id) ?? [];
return (
ticket.subject.toLowerCase().includes(query) ||
String(ticket.id).includes(query) ||
ticket.status.toLowerCase().includes(query) ||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query) ||
customFields.some((field) =>
field.fieldName.toLowerCase().includes(query) ||
field.fieldKey.toLowerCase().includes(query) ||
field.value.toLowerCase().includes(query)
)
);
});
}
if (cfFilters.length > 0) {
result = result.filter((ticket) => {
const customFields = valuesByTicket.get(ticket.id) ?? [];
return cfFilters.every((filter) =>
customFields.some((field) =>
(
field.fieldId === filter.key ||
field.fieldKey.toLowerCase() === filter.key.toLowerCase() ||
field.fieldName.toLowerCase() === filter.key.toLowerCase()
) &&
field.value.toLowerCase() === filter.value
)
);
});
}
return c.json(result);
});
@@ -35,26 +120,118 @@ export function createTicketsRouter(db: Db): Hono {
router.post('/', async (c) => {
const body = await c.req.json();
const parsed = CreateTicketSchema.parse(body);
const creatorId = '00000000-0000-0000-0000-000000000000';
const customFieldInput = parsed.custom_fields ?? {};
const customFieldEntries = Object.entries(customFieldInput)
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
.filter(([, value]) => value);
const queue = await db.query.queues.findFirst({
where: eq(queues.id, parsed.queue_id),
});
if (!queue) {
throw new HTTPException(422, { message: 'Queue not found' });
}
let initialStatus = 'new';
if (queue.lifecycle_id) {
const lifecycle = await db.query.lifecycles.findFirst({
where: eq(lifecycles.id, queue.lifecycle_id),
});
const definition = lifecycle?.definition as LifecycleDefinition | undefined;
initialStatus = definition?.statuses.initial[0] ?? initialStatus;
}
let assignedFields: typeof customFields.$inferSelect[] = [];
if (customFieldEntries.length > 0) {
const assignments = await db.query.queueCustomFields.findMany({
where: eq(queueCustomFields.queue_id, parsed.queue_id),
});
const assignedIds = new Set(assignments.map((assignment) => assignment.custom_field_id));
const requestedIds = customFieldEntries.map(([fieldId]) => fieldId);
for (const fieldId of requestedIds) {
if (!assignedIds.has(fieldId)) {
throw new HTTPException(422, { message: 'Custom field is not assigned to this queue' });
}
}
assignedFields = await db.query.customFields.findMany({
where: (table, { inArray }) => inArray(table.id, requestedIds),
});
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
for (const [fieldId, value] of customFieldEntries) {
const field = fieldById.get(fieldId);
if (!field) {
throw new HTTPException(422, { message: 'Custom field not found' });
}
if (Array.isArray(field.values) && field.values.length > 0) {
const allowed = new Set(field.values.map((option) => String(option)));
if (!allowed.has(value)) {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
}
}
if (field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
}
}
}
}
const [ticket] = await db.insert(tickets).values({
subject: parsed.subject,
queue_id: parsed.queue_id,
status: 'new',
creator_id: '00000000-0000-0000-0000-000000000000',
status: initialStatus,
creator_id: creatorId,
}).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',
});
const txList = [
{
ticket_id: ticket.id,
transaction_type: 'Create',
field: 'status',
new_value: initialStatus,
creator_id: creatorId,
},
];
if (parsed.description) {
txList.push({
ticket_id: ticket.id,
transaction_type: 'Correspond',
field: null,
new_value: null,
data: { body: parsed.description },
creator_id: creatorId,
} as any);
}
const fieldById = new Map(assignedFields.map((field) => [field.id, field]));
for (const [fieldId, value] of customFieldEntries) {
await db.insert(customFieldValues).values({
ticket_id: ticket.id,
custom_field_id: fieldId,
value,
});
txList.push({
ticket_id: ticket.id,
transaction_type: 'CustomFieldChange',
field: fieldById.get(fieldId)?.key ?? fieldId,
new_value: value,
creator_id: creatorId,
} as any);
}
const createdTransactions = await db.insert(transactions).values(txList as any).returning();
const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any);
await scripEngine.commit(prepared);
return c.json(ticket, 201);
});
@@ -104,6 +281,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' });
}
let lifecycleDef: LifecycleDefinition | null = null;
// Validate lifecycle transition if status is changing
if (parsed.status) {
const queue = await db.query.queues.findFirst({
@@ -116,8 +295,8 @@ export function createTicketsRouter(db: Db): Hono {
});
if (lifecycle) {
const def = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status);
lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
}
@@ -149,7 +328,7 @@ export function createTicketsRouter(db: Db): Hono {
});
}
if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) {
if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
txList.push({
ticket_id: id,
transaction_type: 'SetOwner' as const,
@@ -163,8 +342,28 @@ export function createTicketsRouter(db: Db): Hono {
// Update the ticket
const updateData: Record<string, unknown> = {};
if (parsed.subject) updateData.subject = parsed.subject;
if (parsed.status) updateData.status = parsed.status;
if (parsed.owner_id) updateData.owner_id = parsed.owner_id;
if (parsed.status) {
updateData.status = parsed.status;
if (lifecycleDef && parsed.status !== ticket.status) {
const fromClass = statusClass(lifecycleDef, ticket.status);
const toClass = statusClass(lifecycleDef, parsed.status);
const now = new Date();
if (fromClass === 'initial' && (toClass === 'active' || toClass === 'inactive') && !ticket.started_at) {
updateData.started_at = now;
}
if ((fromClass === 'initial' || fromClass === 'active') && toClass === 'inactive') {
updateData.resolved_at = now;
}
if (fromClass === 'inactive' && toClass !== 'inactive') {
updateData.resolved_at = null;
}
}
}
if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id;
updateData.updated_at = new Date();
const [updated] = await db.update(tickets)
@@ -198,6 +397,26 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' });
}
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 lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
}
}
}
}
const txList: any[] = [];
if (parsed.status && parsed.status !== ticket.status) {
@@ -266,5 +485,93 @@ export function createTicketsRouter(db: Db): Hono {
return c.json(tx, 201);
});
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
router.patch('/:id/custom-fields/:fieldId', async (c) => {
const id = Number(c.req.param('id'));
const fieldId = c.req.param('fieldId');
const body = await c.req.json();
const value = typeof body.value === 'string' ? body.value.trim() : '';
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const assignment = await db.query.queueCustomFields.findFirst({
where: and(
eq(queueCustomFields.queue_id, ticket.queue_id),
eq(queueCustomFields.custom_field_id, fieldId),
),
});
if (!assignment) {
throw new HTTPException(422, { message: 'Custom field is not assigned to this ticket queue' });
}
const field = await db.query.customFields.findFirst({
where: eq(customFields.id, fieldId),
});
if (!field) {
throw new HTTPException(404, { message: 'Custom field not found' });
}
if (value && Array.isArray(field.values) && field.values.length > 0) {
const allowed = new Set(field.values.map((option) => String(option)));
if (!allowed.has(value)) {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
}
}
if (value && field.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` });
}
}
const existing = await db.query.customFieldValues.findMany({
where: and(
eq(customFieldValues.ticket_id, id),
eq(customFieldValues.custom_field_id, fieldId),
),
});
const oldValue = existing.map((item) => item.value).join(', ');
await db.delete(customFieldValues).where(and(
eq(customFieldValues.ticket_id, id),
eq(customFieldValues.custom_field_id, fieldId),
));
if (value) {
await db.insert(customFieldValues).values({
ticket_id: id,
custom_field_id: fieldId,
value,
});
}
await db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, id));
const [tx] = await db.insert(transactions).values({
ticket_id: id,
transaction_type: 'CustomFieldChange',
field: field.key,
old_value: oldValue || null,
new_value: value || null,
creator_id: '00000000-0000-0000-0000-000000000000',
}).returning();
const prepared = await scripEngine.prepare(id, [tx] as any);
await scripEngine.commit(prepared);
return c.json(tx, 200);
});
return router;
}

17
src/routes/users.ts Normal file
View File

@@ -0,0 +1,17 @@
import { Hono } from 'hono';
import { asc } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { users } from '../db/schema.ts';
export function createUsersRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.users.findMany({
orderBy: asc(users.username),
});
return c.json(result);
});
return router;
}

View File

@@ -1,8 +1,11 @@
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
import Handlebars from 'handlebars';
import { config } from '../config.ts';
import type { Db } from '../db/index.ts';
import { customFieldValues, transactions } from '../db/schema.ts';
import * as schema from '../db/schema.ts';
import { customFieldValues, tickets, transactions, users } from '../db/schema.ts';
import { and, eq, inArray } from 'drizzle-orm';
export interface ActionExecutor {
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
@@ -21,12 +24,15 @@ export interface ActionPayload {
method?: string;
headers?: Record<string, string>;
field_id?: string;
field_key?: string;
value?: string;
}
export class SendEmail implements ActionExecutor {
private transporter: Transporter | null = null;
constructor(private db: Db) {}
private getTransporter(): Transporter {
if (!this.transporter) {
this.transporter = nodemailer.createTransport({
@@ -39,8 +45,55 @@ export class SendEmail implements ActionExecutor {
return this.transporter;
}
private async resolveRecipients(payload: ActionPayload): Promise<string[]> {
const configuredRecipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
const recipients = new Set(configuredRecipients.filter(Boolean));
const sources = payload.actionConfig['recipient_sources'] ?? payload.actionConfig['recipient_source'];
const recipientSources = Array.isArray(sources)
? sources.map((source) => String(source))
: sources
? [String(sources)]
: [];
if (recipientSources.length === 0 || !payload.ticketId) {
return Array.from(recipients);
}
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, payload.ticketId),
});
if (!ticket) {
return Array.from(recipients);
}
const userIds = new Set<string>();
for (const source of recipientSources) {
if (['requester', 'requestor', 'requestors', 'creator', 'ticket_creator'].includes(source)) {
userIds.add(ticket.creator_id);
}
if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) {
userIds.add(ticket.owner_id);
}
}
if (userIds.size === 0) {
return Array.from(recipients);
}
const rows = await this.db.query.users.findMany({
where: inArray(users.id, Array.from(userIds)),
});
for (const user of rows) {
if (user.email) recipients.add(user.email);
}
return Array.from(recipients);
}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const recipients = payload.recipients ?? (payload.actionConfig['recipients'] as string[] | undefined) ?? [];
const recipients = await this.resolveRecipients(payload);
const subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
const body = payload.body ?? (payload.actionConfig['body'] as string | undefined);
@@ -91,25 +144,279 @@ export class Webhook implements ActionExecutor {
}
}
function parseResponseBody(text: string): unknown {
if (!text.trim()) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
function renderHandlebars(template: string, context: Record<string, unknown>): string {
const instance = Handlebars.create();
instance.registerHelper('json', (value: unknown) => JSON.stringify(value, null, 2));
return instance.compile(template)(context);
}
export class FetchMetadata implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
const rawUrl = String(payload.actionConfig['url'] ?? '');
const method = String(payload.actionConfig['method'] ?? 'GET').toUpperCase();
const headers = (payload.actionConfig['headers'] ?? {}) as Record<string, string>;
const requestBodyTemplate = String(payload.actionConfig['body'] ?? '');
const commentTemplate = String(
payload.actionConfig['comment_template'] ??
'External metadata\n\n{{json metadata}}',
);
const internal = payload.actionConfig['internal'] !== false;
if (!ticketId || !rawUrl) {
return { success: false, message: 'FetchMetadata: missing ticket_id or URL' };
}
try {
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
return { success: false, message: `FetchMetadata: ticket ${ticketId} not found` };
}
const baseContext = {
ticket: {
id: ticket.id,
subject: ticket.subject,
status: ticket.status,
queue_id: ticket.queue_id,
owner_id: ticket.owner_id,
creator_id: ticket.creator_id,
created_at: ticket.created_at?.toISOString(),
updated_at: ticket.updated_at?.toISOString(),
},
};
const url = renderHandlebars(rawUrl, baseContext);
const requestBody = requestBodyTemplate
? renderHandlebars(requestBodyTemplate, baseContext)
: undefined;
const response = await fetch(url, {
method,
headers: { Accept: 'application/json', ...headers },
body: ['GET', 'HEAD'].includes(method) ? undefined : requestBody,
});
const responseText = await response.text();
const metadata = parseResponseBody(responseText);
if (!response.ok) {
return { success: false, message: `FetchMetadata failed: HTTP ${response.status}` };
}
const commentBody = renderHandlebars(commentTemplate, {
...baseContext,
metadata,
response: {
status: response.status,
ok: response.ok,
body: metadata,
text: responseText,
},
});
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: internal ? 'Comment' : 'Correspond',
data: {
body: commentBody,
metadata,
source_url: url,
},
creator_id: '00000000-0000-0000-0000-000000000000',
});
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
return { success: true, message: `Metadata fetched and added to ticket ${ticketId}` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `FetchMetadata failed: ${message}` };
}
}
}
type ScriptResult = string | { success?: boolean; message?: string } | undefined | null;
export class RunScript implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const script = String(payload.actionConfig['script'] ?? payload.actionConfig['code'] ?? '');
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
if (!script.trim()) {
return { success: false, message: 'RunScript: no script configured' };
}
if (!ticketId) {
return { success: false, message: 'RunScript: missing ticket_id' };
}
try {
const ticket = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
return { success: false, message: `RunScript: ticket ${ticketId} not found` };
}
const helpers = {
addComment: async (body: string, options?: { internal?: boolean; creator_id?: string }) => {
const [tx] = await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: options?.internal === false ? 'Correspond' : 'Comment',
data: { body },
creator_id: options?.creator_id ?? '00000000-0000-0000-0000-000000000000',
}).returning();
return tx;
},
createTransaction: async (data: {
transaction_type: string;
field?: string | null;
old_value?: string | null;
new_value?: string | null;
data?: unknown;
creator_id?: string;
}) => {
const [tx] = await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: data.transaction_type,
field: data.field ?? null,
old_value: data.old_value ?? null,
new_value: data.new_value ?? null,
data: data.data,
creator_id: data.creator_id ?? '00000000-0000-0000-0000-000000000000',
}).returning();
return tx;
},
updateTicket: async (data: Partial<typeof tickets.$inferInsert>) => {
const [updated] = await this.db.update(tickets)
.set({ ...data, updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId))
.returning();
return updated;
},
touchTicket: async () => {
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
},
};
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(
'context',
`"use strict";
const { ticket, payload, actionConfig, helpers, db, schema, orm, fetch, console } = context;
${script}`,
) as (context: Record<string, unknown>) => Promise<ScriptResult>;
const result = await fn({
ticket,
payload,
actionConfig: payload.actionConfig,
helpers,
db: this.db,
schema,
orm: { and, eq, inArray },
fetch,
console,
});
if (typeof result === 'string') {
return { success: true, message: result };
}
if (result && typeof result === 'object') {
return {
success: result.success !== false,
message: result.message ?? 'RunScript completed',
};
}
return { success: true, message: 'RunScript completed' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `RunScript failed: ${message}` };
}
}
}
export class SetCustomField implements ActionExecutor {
constructor(private db: Db) {}
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
const fieldId = payload.field_id ?? String(payload.actionConfig['field_id'] ?? '');
const fieldRef =
payload.field_id ??
payload.field_key ??
String(payload.actionConfig['field_id'] ?? payload.actionConfig['field_key'] ?? payload.actionConfig['field'] ?? '');
const value = payload.value ?? String(payload.actionConfig['value'] ?? '');
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
if (!fieldId || !value || !ticketId) {
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' };
if (!fieldRef || !value || !ticketId) {
return { success: false, message: 'SetCustomField: missing field reference, value, or ticket_id' };
}
try {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(fieldRef);
const field = await this.db.query.customFields.findFirst({
where: (row, { or, eq }) =>
isUuid
? or(eq(row.id, fieldRef), eq(row.key, fieldRef), eq(row.name, fieldRef))
: or(eq(row.key, fieldRef), eq(row.name, fieldRef)),
});
if (!field) {
return { success: false, message: `SetCustomField: unknown field ${fieldRef}` };
}
const existing = await this.db.query.customFieldValues.findMany({
where: and(
eq(customFieldValues.ticket_id, ticketId),
eq(customFieldValues.custom_field_id, field.id),
),
});
const oldValue = existing.map((row) => row.value).join(', ');
await this.db.delete(customFieldValues).where(and(
eq(customFieldValues.ticket_id, ticketId),
eq(customFieldValues.custom_field_id, field.id),
));
await this.db.insert(customFieldValues).values({
custom_field_id: fieldId,
custom_field_id: field.id,
ticket_id: ticketId,
value,
});
return { success: true, message: `Custom field ${fieldId} set to "${value}"` };
await this.db.update(tickets)
.set({ updated_at: new Date() } as any)
.where(eq(tickets.id, ticketId));
await this.db.insert(transactions).values({
ticket_id: ticketId,
transaction_type: 'CustomFieldChange',
field: field.key,
old_value: oldValue || null,
new_value: value,
creator_id: '00000000-0000-0000-0000-000000000000',
});
return { success: true, message: `${field.name} set to "${value}"` };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, message: `SetCustomField failed: ${message}` };
@@ -150,8 +457,10 @@ export class CreateTransaction implements ActionExecutor {
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
return {
SendEmail: new SendEmail(),
SendEmail: new SendEmail(db),
Webhook: new Webhook(),
FetchMetadata: new FetchMetadata(db),
RunScript: new RunScript(db),
SetCustomField: new SetCustomField(db),
CreateTransaction: new CreateTransaction(db),
};

View File

@@ -7,8 +7,29 @@ export interface ConditionEvaluateContext {
lifecycleDef?: LifecycleDefinition;
}
export interface ConditionConfig {
from_status?: unknown;
to_status?: unknown;
field_key?: unknown;
field_id?: unknown;
field?: unknown;
old_value?: unknown;
new_value?: unknown;
value?: unknown;
}
export interface ConditionEvaluator {
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean;
evaluate(ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean;
}
function matchesStatusFilter(value: string | null, filter: unknown): boolean {
if (filter === undefined || filter === null || filter === '') return true;
if (value === null) return false;
const normalizedValue = value.toLowerCase();
if (Array.isArray(filter)) {
return filter.map((item) => String(item).toLowerCase()).includes(normalizedValue);
}
return normalizedValue === String(filter).toLowerCase();
}
export class OnCreate implements ConditionEvaluator {
@@ -18,19 +39,25 @@ export class OnCreate implements ConditionEvaluator {
}
export class OnStatusChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
return transactions.some((tx) =>
tx.transaction_type === 'StatusChange' &&
matchesStatusFilter(tx.old_value, config?.from_status) &&
matchesStatusFilter(tx.new_value, config?.to_status)
);
}
}
export class OnResolve implements ConditionEvaluator {
private lifecycleValidator = new LifecycleValidator();
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext): boolean {
evaluate(_ticket: Ticket, transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const lifecycleDef = context?.lifecycleDef;
return transactions.some((tx) => {
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
if (!matchesStatusFilter(tx.old_value, config?.from_status)) return false;
if (!matchesStatusFilter(tx.new_value, config?.to_status)) return false;
if (lifecycleDef) {
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
@@ -41,10 +68,25 @@ export class OnResolve implements ConditionEvaluator {
}
}
export class OnCustomFieldChange implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const fieldFilter = config?.field_key ?? config?.field_id ?? config?.field;
const newValueFilter = config?.new_value ?? config?.value;
return transactions.some((tx) =>
tx.transaction_type === 'CustomFieldChange' &&
matchesStatusFilter(tx.field, fieldFilter) &&
matchesStatusFilter(tx.old_value, config?.old_value) &&
matchesStatusFilter(tx.new_value, newValueFilter)
);
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(),
OnCustomFieldChange: new OnCustomFieldChange(),
};
export function getConditionEvaluator(type: string): ConditionEvaluator | null {

View File

@@ -4,7 +4,7 @@ import type { Transaction } from '../models/transaction.ts';
import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
import { eq, asc, inArray } from 'drizzle-orm';
import { getConditionEvaluator } from './conditions.ts';
import type { ConditionEvaluateContext } from './conditions.ts';
import type { ConditionConfig, ConditionEvaluateContext } from './conditions.ts';
import { getActionExecutor } from './actions.ts';
import type { ActionPayload } from './actions.ts';
import { TemplateRenderer } from './templates.ts';
@@ -103,7 +103,12 @@ export class ScripEngine {
continue;
}
if (!evaluator.evaluate(ticketRecord as unknown as Ticket, transactions, conditionContext)) {
if (!evaluator.evaluate(
ticketRecord as unknown as Ticket,
transactions,
conditionContext,
scrip.condition_config as ConditionConfig,
)) {
continue;
}

View File

@@ -17,7 +17,7 @@ export class TemplateRenderer {
export interface TemplateContext {
ticket: {
id: string;
id: number;
subject: string;
status: string;
queue_id: string;

View File

@@ -27,5 +27,6 @@
"paths": {
"@/*": ["./src/*"]
}
}
},
"exclude": ["web", "node_modules"]
}

View File

@@ -14,7 +14,7 @@ pnpm dev
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://127.0.0.1:3100](http://127.0.0.1:3100) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

View File

@@ -1,11 +1,19 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";
const appRoot = dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
outputFileTracingRoot: appRoot,
turbopack: {
root: appRoot,
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://127.0.0.1:9876/:path*',
source: "/api/:path*",
destination: "http://127.0.0.1:9876/:path*",
},
];
},

95
web/package-lock.json generated
View File

@@ -9,15 +9,21 @@
"version": "0.1.0",
"dependencies": {
"@base-ui/react": "^1.5.0",
"@hookform/resolvers": "^5.4.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0",
"next": "16.2.7",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.77.0",
"shadcn": "^4.10.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -943,6 +949,18 @@
"hono": "^4"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1981,6 +1999,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2261,6 +2285,39 @@
"tailwindcss": "4.3.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@ts-morph/common": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
@@ -3947,6 +4004,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7217,6 +7284,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -8002,6 +8079,22 @@
"react": "^19.2.4"
}
},
"node_modules/react-hook-form": {
"version": "7.77.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz",
"integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev --webpack -H 127.0.0.1 --port 3100",
"dev:prod": "next build && next start -H 127.0.0.1 --port 3100",
"build": "next build",
"start": "next start",
"start": "next start -H 127.0.0.1 --port 3100",
"lint": "eslint"
},
"dependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -49,72 +49,72 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(0.982 0.006 106);
--foreground: oklch(0.19 0.018 248);
--card: oklch(0.996 0.003 106);
--card-foreground: oklch(0.19 0.018 248);
--popover: oklch(0.996 0.003 106);
--popover-foreground: oklch(0.19 0.018 248);
--primary: oklch(0.31 0.046 243);
--primary-foreground: oklch(0.99 0.003 106);
--secondary: oklch(0.945 0.01 105);
--secondary-foreground: oklch(0.25 0.026 244);
--muted: oklch(0.948 0.008 106);
--muted-foreground: oklch(0.49 0.023 250);
--accent: oklch(0.925 0.024 184);
--accent-foreground: oklch(0.21 0.028 246);
--destructive: oklch(0.55 0.18 27);
--border: oklch(0.865 0.014 102);
--input: oklch(0.84 0.015 102);
--ring: oklch(0.58 0.068 185);
--chart-1: oklch(0.62 0.095 184);
--chart-2: oklch(0.53 0.078 243);
--chart-3: oklch(0.64 0.12 77);
--chart-4: oklch(0.55 0.15 28);
--chart-5: oklch(0.44 0.055 257);
--radius: 0.5rem;
--sidebar: oklch(0.245 0.026 248);
--sidebar-foreground: oklch(0.93 0.012 108);
--sidebar-primary: oklch(0.69 0.105 184);
--sidebar-primary-foreground: oklch(0.18 0.022 248);
--sidebar-accent: oklch(0.31 0.031 248);
--sidebar-accent-foreground: oklch(0.98 0.006 106);
--sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.66 0.102 184);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.18 0.018 248);
--foreground: oklch(0.94 0.011 105);
--card: oklch(0.225 0.022 248);
--card-foreground: oklch(0.94 0.011 105);
--popover: oklch(0.225 0.022 248);
--popover-foreground: oklch(0.94 0.011 105);
--primary: oklch(0.74 0.105 184);
--primary-foreground: oklch(0.17 0.018 248);
--secondary: oklch(0.27 0.026 248);
--secondary-foreground: oklch(0.94 0.011 105);
--muted: oklch(0.28 0.023 248);
--muted-foreground: oklch(0.7 0.019 105);
--accent: oklch(0.31 0.043 184);
--accent-foreground: oklch(0.94 0.011 105);
--destructive: oklch(0.68 0.17 24);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 16%);
--ring: oklch(0.68 0.095 184);
--chart-1: oklch(0.74 0.105 184);
--chart-2: oklch(0.7 0.105 74);
--chart-3: oklch(0.66 0.12 25);
--chart-4: oklch(0.61 0.08 245);
--chart-5: oklch(0.8 0.04 108);
--sidebar: oklch(0.145 0.018 248);
--sidebar-foreground: oklch(0.94 0.011 105);
--sidebar-primary: oklch(0.74 0.105 184);
--sidebar-primary-foreground: oklch(0.17 0.018 248);
--sidebar-accent: oklch(0.24 0.026 248);
--sidebar-accent-foreground: oklch(0.94 0.011 105);
--sidebar-border: oklch(1 0 0 / 11%);
--sidebar-ring: oklch(0.68 0.095 184);
}
@keyframes slide-in-right {
@@ -135,8 +135,12 @@
body {
@apply bg-background text-foreground;
font-feature-settings: "cv01" 1, "ss03" 1;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--border) 45%, transparent) 1px, transparent 1px);
background-size: 44px 44px;
}
html {
@apply font-sans;
}
}
}

View File

@@ -1,13 +1,14 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
import { Suspense } from "react";
import { ThemeProvider } from "next-themes";
import "./globals.css";
import { AppShell } from "@/components/app-shell";
const inter = Inter({
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
variable: "--font-inter",
weight: ["400", "500", "600", "700"],
variable: "--font-sans",
});
const jetbrainsMono = JetBrains_Mono({
@@ -26,7 +27,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
className={`${ibmPlexSans.variable} ${jetbrainsMono.variable} font-sans antialiased`}
style={{ fontSize: "15px", lineHeight: 1.5 }}
>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import {
SettingsIcon,
PanelLeftCloseIcon,
PanelLeftIcon,
CommandIcon,
} from "lucide-react";
import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types";
@@ -51,11 +52,11 @@ function SidebarNavItem({
href={href}
title={collapsed ? label : undefined}
className={cn(
"flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
"group flex items-center px-2 py-1.5 rounded-md text-[13px] transition-all duration-150 mb-0.5",
collapsed ? "justify-center w-full" : "justify-between",
active
? "bg-accent text-foreground font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-accent font-normal"
? "bg-sidebar-primary text-sidebar-primary-foreground font-semibold shadow-[inset_3px_0_0_color-mix(in_oklch,var(--sidebar-primary-foreground)_55%,transparent)]"
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
)}
>
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
@@ -63,7 +64,10 @@ function SidebarNavItem({
{!collapsed && label}
</span>
{!collapsed && count !== undefined && count > 0 && (
<span className="text-xs tabular-nums text-muted-foreground">
<span className={cn(
"min-w-5 rounded px-1 text-right text-[11px] tabular-nums",
active ? "text-sidebar-primary-foreground/80" : "text-sidebar-foreground/45"
)}>
{count}
</span>
)}
@@ -171,7 +175,7 @@ function SidebarNav() {
{queues.length > 0 && (
<div>
{!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
Queues
</div>
)}
@@ -179,7 +183,7 @@ function SidebarNav() {
const active =
pathname === "/" && searchParams.get("queue") === queue.id;
const QueueIcon = () => (
<span className="w-2 h-2 rounded-full bg-muted-foreground flex-shrink-0" />
<span className="w-2 h-2 rounded-full bg-sidebar-primary flex-shrink-0 shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-primary)_18%,transparent)]" />
);
return (
<SidebarNavItem
@@ -203,7 +207,7 @@ function SidebarBottom() {
const collapsed = useSidebarCollapsed();
return (
<div className="border-t border-border p-2">
<div className="border-t border-sidebar-border p-2">
<SidebarNavItem
href="/admin"
icon={SettingsIcon}
@@ -217,13 +221,13 @@ function SidebarBottom() {
)}
title={collapsed ? "User" : undefined}
>
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
<span className="text-primary-foreground text-[10px] font-semibold">
<div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
<span className="text-sidebar-primary-foreground text-[10px] font-semibold">
U
</span>
</div>
{!collapsed && (
<span className="text-[13px] text-muted-foreground truncate">
<span className="text-[13px] text-sidebar-foreground/65 truncate">
User
</span>
)}
@@ -259,39 +263,54 @@ export function AppShell({ children }: { children: React.ReactNode }) {
return (
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
<div className="flex h-screen overflow-hidden">
<div className="flex h-screen overflow-hidden bg-background">
{/* Sidebar */}
<aside
className={cn(
"flex-shrink-0 flex flex-col bg-sidebar border-r border-border transition-all duration-150",
"flex-shrink-0 flex flex-col bg-sidebar border-r border-sidebar-border transition-all duration-150 shadow-[16px_0_42px_color-mix(in_oklch,var(--sidebar)_18%,transparent)]",
sidebarCollapsed ? "w-[60px]" : "w-60"
)}
>
{/* Brand */}
<div className="h-11 flex items-center px-3 border-b border-border">
<div className="h-14 flex items-center justify-between gap-2 px-3 border-b border-sidebar-border">
<Link href="/" className="flex items-center gap-2">
<div className="w-5 h-5 rounded-md bg-primary flex items-center justify-center">
<span className="text-primary-foreground text-[11px] font-semibold">
<div className="w-7 h-7 rounded-md bg-sidebar-primary flex items-center justify-center shadow-[0_0_0_1px_color-mix(in_oklch,var(--sidebar-primary)_55%,white_20%)]">
<span className="text-sidebar-primary-foreground text-[12px] font-bold">
T
</span>
</div>
{!sidebarCollapsed && (
<span className="font-semibold text-foreground text-sm tracking-tight">
Tessera
<span className="leading-tight">
<span className="block font-semibold text-sidebar-foreground text-sm">
Tessera
</span>
<span className="block text-[10px] text-sidebar-foreground/45">
ScripFoundry
</span>
</span>
)}
</Link>
{!sidebarCollapsed && (
<button
onClick={() => setCommandOpen(true)}
className="flex h-7 items-center gap-1 rounded-md border border-sidebar-border px-2 text-[11px] text-sidebar-foreground/55 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
aria-label="Open command palette"
>
<CommandIcon className="h-3.5 w-3.5" />
K
</button>
)}
</div>
{/* Nav */}
<nav className="flex-1 overflow-y-auto py-2 px-2">
<nav className="flex-1 overflow-y-auto py-3 px-2">
<Suspense
fallback={
<div className="space-y-1.5 px-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-7 bg-muted rounded-md animate-pulse"
className="h-7 bg-sidebar-accent rounded-md animate-pulse"
/>
))}
</div>
@@ -306,7 +325,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</aside>
{/* Main */}
<main className="flex-1 overflow-hidden">{children}</main>
<main className="flex-1 overflow-hidden bg-background/88">{children}</main>
{/* Command Palette */}
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
@@ -315,7 +334,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{/* Collapse toggle */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-border border-l-0 text-muted-foreground hover:text-foreground transition-all duration-150"
className="fixed bottom-4 left-0 z-40 w-6 h-6 flex items-center justify-center rounded-r-md bg-sidebar border border-sidebar-border border-l-0 text-sidebar-foreground/55 hover:text-sidebar-foreground transition-all duration-150"
style={{ left: sidebarCollapsed ? 60 : 240 }}
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import type { ComponentType, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import {
SearchIcon,
@@ -15,7 +16,7 @@ import type { Ticket } from "@/lib/types";
interface CommandItem {
id: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
icon: ComponentType<{ className?: string }>;
action: () => void;
category?: string;
}
@@ -41,79 +42,87 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
}
}, [open]);
const alwaysCommands: CommandItem[] = [
{
id: "new-ticket",
label: "New ticket",
icon: PlusIcon,
action: () => {
onOpenChange(false);
router.push("/?new=true");
const filtered = useMemo(() => {
const normalizedQuery = query.toLowerCase();
const alwaysCommands: CommandItem[] = [
{
id: "new-ticket",
label: "New ticket",
icon: PlusIcon,
action: () => {
onOpenChange(false);
router.push("/?new=true");
},
category: "Actions",
},
category: "Actions",
},
{
id: "admin",
label: "Go to admin",
icon: SettingsIcon,
action: () => {
onOpenChange(false);
router.push("/admin");
{
id: "admin",
label: "Go to admin",
icon: SettingsIcon,
action: () => {
onOpenChange(false);
router.push("/admin");
},
category: "Navigate",
},
category: "Navigate",
},
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
{
id: "all-tickets",
label: "All tickets",
icon: LayoutGridIcon,
action: () => {
onOpenChange(false);
router.push("/");
},
category: "Navigate",
},
category: "Navigate",
},
];
];
const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(query.toLowerCase()))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
icon: MessageSquareIcon,
action: () => {
onOpenChange(false);
router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(normalizedQuery)
);
const ticketCommands: CommandItem[] = tickets
.filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
.map((t) => ({
id: `ticket-${t.id}`,
label: t.subject,
icon: MessageSquareIcon,
action: () => {
onOpenChange(false);
router.push(`/tickets/${t.id}`);
},
category: "Tickets",
}));
const alwaysFiltered = alwaysCommands.filter((cmd) =>
cmd.label.toLowerCase().includes(query.toLowerCase())
return [...alwaysFiltered, ...ticketCommands];
}, [onOpenChange, query, router, tickets]);
const grouped = useMemo(
() =>
filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const cat = cmd.category || "Other";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {}),
[filtered]
);
const filtered = [...alwaysFiltered, ...ticketCommands];
const grouped = filtered.reduce<Record<string, CommandItem[]>>((acc, cmd) => {
const cat = cmd.category || "Other";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(cmd);
return acc;
}, {});
useEffect(() => {
if (open) {
setQuery("");
setSelectedIndex(0);
queueMicrotask(() => {
setQuery("");
setSelectedIndex(0);
});
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
useEffect(() => {
setSelectedIndex(0);
queueMicrotask(() => setSelectedIndex(0));
}, [query]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
(e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));

View File

@@ -8,7 +8,9 @@ export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
queueMicrotask(() => setMounted(true));
}, []);
if (!mounted) {
return <div className="w-8 h-8" />;

View File

@@ -1,10 +1,15 @@
import type {
Ticket,
Queue,
User,
Transaction,
Scrip,
Template,
TemplatePreview,
Lifecycle,
LifecycleDefinition,
CustomField,
QueueCustomField,
PreviewResult,
UpdateResult,
} from "./types";
@@ -28,10 +33,23 @@ async function request<T>(url: string, options?: RequestInit): Promise<{ data: T
}
}
export async function getTickets(params?: { queue_id?: string; status?: string }): Promise<{ data: Ticket[] | null; error: string | null }> {
export async function getTickets(params?: {
queue_id?: string;
status?: string;
q?: string;
owner_id?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id);
if (params?.status) sp.set("status", params.status);
if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value);
}
}
const qs = sp.toString();
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`);
}
@@ -40,11 +58,16 @@ export async function getTicket(id: number): Promise<{ data: Ticket | null; erro
return request<Ticket>(`/tickets/${id}`);
}
export async function createTicket(data: { subject: string; queue_id: string }): Promise<{ data: Ticket | null; error: string | null }> {
export async function createTicket(data: {
subject: string;
queue_id: string;
description?: string;
custom_fields?: Record<string, string>;
}): Promise<{ data: Ticket | null; error: string | null }> {
return request<Ticket>("/tickets", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTicket(id: number, data: { subject?: string; status?: string }): Promise<{ data: UpdateResult | null; error: string | null }> {
export async function updateTicket(id: number, data: { subject?: string; status?: string; owner_id?: string | null }): Promise<{ data: UpdateResult | null; error: string | null }> {
return request<UpdateResult>(`/tickets/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
@@ -64,18 +87,28 @@ export async function getQueues(): Promise<{ data: Queue[] | null; error: string
return request<Queue[]>("/queues");
}
export async function createQueue(data: { name: string; description?: string }): Promise<{ data: Queue | null; error: string | null }> {
export async function getUsers(): Promise<{ data: User[] | null; error: string | null }> {
return request<User[]>("/users");
}
export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
}
export async function updateQueue(id: string, data: { name?: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>(`/queues/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
return request<Scrip[]>("/scrips");
}
export async function createScrip(data: {
name: string;
description?: string | null;
queue_id?: string | null;
condition_type: string;
condition_config?: Record<string, unknown>;
action_type: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
@@ -88,8 +121,10 @@ export async function createScrip(data: {
export async function updateScrip(id: string, data: {
name?: string;
description?: string | null;
queue_id?: string | null;
condition_type?: string;
condition_config?: Record<string, unknown>;
action_type?: string;
action_config?: Record<string, unknown>;
template_id?: string | null;
@@ -100,26 +135,98 @@ export async function updateScrip(id: string, data: {
return request<Scrip>(`/scrips/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getTemplates(): Promise<{ data: Template[] | null; error: string | null }> {
return request<Template[]>("/templates");
}
export async function createTemplate(data: {
name: string;
queue_id?: string | null;
subject_template: string;
body_template: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>("/templates", { method: "POST", body: JSON.stringify(data) });
}
export async function updateTemplate(id: string, data: {
name?: string;
queue_id?: string | null;
subject_template?: string;
body_template?: string;
}): Promise<{ data: Template | null; error: string | null }> {
return request<Template>(`/templates/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function previewTemplate(data: {
subject_template: string;
body_template: string;
ticket_id?: number | null;
}): Promise<{ data: TemplatePreview | null; error: string | null }> {
return request<TemplatePreview>("/templates/preview", { method: "POST", body: JSON.stringify(data) });
}
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
return request<Lifecycle[]>("/lifecycles");
}
export async function createLifecycle(data: {
name: string;
definition: Record<string, unknown>;
definition: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) });
}
export async function updateLifecycle(id: string, data: {
name?: string;
definition?: Record<string, unknown> | LifecycleDefinition;
}): Promise<{ data: Lifecycle | null; error: string | null }> {
return request<Lifecycle>(`/lifecycles/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
return request<CustomField[]>("/custom-fields");
}
export async function getQueueCustomFields(queueId: string): Promise<{ data: QueueCustomField[] | null; error: string | null }> {
return request<QueueCustomField[]>(`/custom-fields/queues/${queueId}`);
}
export async function assignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: QueueCustomField | null; error: string | null }> {
return request<QueueCustomField>(`/custom-fields/queues/${queueId}`, {
method: "POST",
body: JSON.stringify({ custom_field_id: customFieldId }),
});
}
export async function unassignQueueCustomField(queueId: string, customFieldId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/custom-fields/queues/${queueId}/${customFieldId}`, { method: "DELETE" });
}
export async function updateTicketCustomField(ticketId: number, customFieldId: string, value: string): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${ticketId}/custom-fields/${customFieldId}`, {
method: "PATCH",
body: JSON.stringify({ value }),
});
}
export async function createCustomField(data: {
key?: string;
name: string;
field_type: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
}
export async function updateCustomField(id: string, data: {
key?: string;
name?: string;
field_type?: string;
values?: unknown | null;
max_values?: number;
pattern?: string | null;
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}

View File

@@ -19,6 +19,13 @@ export interface Queue {
lifecycle_id: string | null;
}
export interface User {
id: string;
username: string;
email: string | null;
created_at: string;
}
export interface Transaction {
id: string;
ticket_id: number;
@@ -35,13 +42,16 @@ export interface Scrip {
id: string;
queue_id: string | null;
name: string;
description: string | null;
condition_type: string;
condition_config: Record<string, unknown>;
action_type: string;
action_config: Record<string, unknown>;
template_id: string | null;
stage: string;
sort_order: number;
disabled: boolean;
created_at: string;
}
export interface Template {
@@ -50,6 +60,13 @@ export interface Template {
queue_id: string | null;
subject_template: string;
body_template: string;
created_at: string;
}
export interface TemplatePreview {
subject: string;
body: string;
context: unknown;
}
export interface Lifecycle {
@@ -65,16 +82,26 @@ export interface LifecycleDefinition {
export interface CustomField {
id: string;
key: string;
name: string;
field_type: string;
values: unknown | null;
max_values: number;
pattern: string | null;
}
export interface QueueCustomField {
id: string;
queue_id: string;
custom_field_id: string;
sort_order: number;
custom_field: CustomField | null;
}
export interface CustomFieldValue {
id: string;
custom_field_id: string;
ticket_id: string;
ticket_id: number;
value: string;
custom_field?: CustomField;
}