Compare commits
7 Commits
599ca75fc4
...
60d2196e51
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d2196e51 | ||
|
|
ade966ace7 | ||
|
|
06cc7c79a3 | ||
|
|
b96ba21e99 | ||
|
|
54ef6fcc5b | ||
|
|
e960df61ad | ||
|
|
9e884546f2 |
12
CLAUDE.md
12
CLAUDE.md
@@ -40,20 +40,22 @@ tessera/
|
|||||||
```bash
|
```bash
|
||||||
cd ~/projects/tessera
|
cd ~/projects/tessera
|
||||||
cp .env.example .env # Edit DATABASE_URL to point to postgres://tessera:***@127.0.0.1:5433/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
|
### Run migrations
|
||||||
```bash
|
```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
|
### Start frontend
|
||||||
```bash
|
```bash
|
||||||
cd web
|
cd web
|
||||||
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server)
|
npm install # Use npm, NOT bun (bun has compatibility issues with Next.js dev server)
|
||||||
npx next build # Production build
|
npm run build # Production build
|
||||||
npx next start --port 5173 # Production server
|
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.
|
**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:
|
After OpenCode makes changes:
|
||||||
1. `cd web && npx next build` — verify zero errors
|
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
|
3. `git push` — push to origin
|
||||||
|
|
||||||
## Common Issues
|
## Common Issues
|
||||||
|
|||||||
10
drizzle/migrations/0002_short_custom_field_keys.sql
Normal file
10
drizzle/migrations/0002_short_custom_field_keys.sql
Normal 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");
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1780867177929,
|
"when": 1780867177929,
|
||||||
"tag": "0001_lovely_quentin_quire",
|
"tag": "0001_lovely_quentin_quire",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780904200000,
|
||||||
|
"tag": "0002_short_custom_field_keys",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"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": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/handlebars": "^4.1.0",
|
"@types/handlebars": "^4.1.0",
|
||||||
|
|||||||
112
scripts/smoke-test.ts
Normal file
112
scripts/smoke-test.ts
Normal 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
15
scripts/watch-frontend.sh
Normal 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
|
||||||
@@ -83,6 +83,7 @@ export const scrips = pgTable('scrips', {
|
|||||||
|
|
||||||
export const customFields = pgTable('custom_fields', {
|
export const customFields = pgTable('custom_fields', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
key: text('key').notNull().unique(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
field_type: text('field_type').notNull(),
|
field_type: text('field_type').notNull(),
|
||||||
values: jsonb('values'),
|
values: jsonb('values'),
|
||||||
|
|||||||
787
src/db/seed.ts
Normal file
787
src/db/seed.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import { createQueuesRouter } from './routes/queues.ts';
|
|||||||
import { createScripsRouter } from './routes/scrips.ts';
|
import { createScripsRouter } from './routes/scrips.ts';
|
||||||
import { createCustomFieldsRouter } from './routes/custom-fields.ts';
|
import { createCustomFieldsRouter } from './routes/custom-fields.ts';
|
||||||
import { createLifecyclesRouter } from './routes/lifecycles.ts';
|
import { createLifecyclesRouter } from './routes/lifecycles.ts';
|
||||||
|
import { createUsersRouter } from './routes/users.ts';
|
||||||
|
import { createTemplatesRouter } from './routes/templates.ts';
|
||||||
|
|
||||||
let db: Db | null = null;
|
let db: Db | null = null;
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ app.route('/queues', createQueuesRouter(getDb()));
|
|||||||
app.route('/scrips', createScripsRouter(getDb()));
|
app.route('/scrips', createScripsRouter(getDb()));
|
||||||
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||||
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||||
|
app.route('/users', createUsersRouter(getDb()));
|
||||||
|
app.route('/templates', createTemplatesRouter(getDb()));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
export { app };
|
export { app };
|
||||||
@@ -41,6 +45,7 @@ if (Bun.main === import.meta.path) {
|
|||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port: config.SERVER_PORT,
|
port: config.SERVER_PORT,
|
||||||
hostname: config.SERVER_HOST,
|
hostname: config.SERVER_HOST,
|
||||||
|
development: false,
|
||||||
});
|
});
|
||||||
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ export type Ticket = InferSelectModel<typeof tickets>;
|
|||||||
export const CreateTicketSchema = z.object({
|
export const CreateTicketSchema = z.object({
|
||||||
subject: z.string().min(1),
|
subject: z.string().min(1),
|
||||||
queue_id: z.string().uuid(),
|
queue_id: z.string().uuid(),
|
||||||
|
description: z.string().trim().optional(),
|
||||||
|
custom_fields: z.record(z.string(), z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateTicketSchema = z.object({
|
export const UpdateTicketSchema = z.object({
|
||||||
subject: z.string().min(1).optional(),
|
subject: z.string().min(1).optional(),
|
||||||
status: 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({
|
export const CommentSchema = z.object({
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { customFields } from '../db/schema.ts';
|
import { customFields, queueCustomFields } from '../db/schema.ts';
|
||||||
import { asc } from 'drizzle-orm';
|
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 {
|
export function createCustomFieldsRouter(db: Db): Hono {
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
@@ -17,12 +26,14 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
|||||||
router.post('/', async (c) => {
|
router.post('/', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { name, field_type, values, max_values, pattern } = body;
|
const { name, field_type, values, max_values, pattern } = body;
|
||||||
|
const key = makeFieldKey(String(body.key ?? name ?? ''));
|
||||||
|
|
||||||
if (!name || !field_type) {
|
if (!name || !field_type) {
|
||||||
throw new HTTPException(400, { message: 'name and field_type are required' });
|
throw new HTTPException(400, { message: 'name and field_type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [cf] = await db.insert(customFields).values({
|
const [cf] = await db.insert(customFields).values({
|
||||||
|
key,
|
||||||
name,
|
name,
|
||||||
field_type,
|
field_type,
|
||||||
values: values ?? null,
|
values: values ?? null,
|
||||||
@@ -37,5 +48,94 @@ export function createCustomFieldsRouter(db: Db): Hono {
|
|||||||
return c.json(cf, 201);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { lifecycles } from '../db/schema.ts';
|
import { lifecycles } from '../db/schema.ts';
|
||||||
import { asc } from 'drizzle-orm';
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
export function createLifecyclesRouter(db: Db): Hono {
|
export function createLifecyclesRouter(db: Db): Hono {
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
@@ -34,5 +34,29 @@ export function createLifecyclesRouter(db: Db): Hono {
|
|||||||
return c.json(lifecycle, 201);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { queues } from '../db/schema.ts';
|
import { queues } from '../db/schema.ts';
|
||||||
import { asc } from 'drizzle-orm';
|
import { asc, eq } from 'drizzle-orm';
|
||||||
import { CreateQueueSchema } from '../models/queue.ts';
|
import { CreateQueueSchema } from '../models/queue.ts';
|
||||||
|
|
||||||
export function createQueuesRouter(db: Db): Hono {
|
export function createQueuesRouter(db: Db): Hono {
|
||||||
@@ -32,5 +32,30 @@ export function createQueuesRouter(db: Db): Hono {
|
|||||||
return c.json(queue, 201);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
174
src/routes/templates.ts
Normal file
174
src/routes/templates.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { HTTPException } from 'hono/http-exception';
|
import { HTTPException } from 'hono/http-exception';
|
||||||
import type { Db } from '../db/index.ts';
|
import type { Db } from '../db/index.ts';
|
||||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts';
|
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
|
||||||
import { eq, asc } from 'drizzle-orm';
|
import { and, eq, asc } from 'drizzle-orm';
|
||||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||||
import { ScripEngine } from '../scrip/engine.ts';
|
import { ScripEngine } from '../scrip/engine.ts';
|
||||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||||
@@ -13,21 +13,106 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
const scripEngine = new ScripEngine(db);
|
const scripEngine = new ScripEngine(db);
|
||||||
const lifecycleValidator = new LifecycleValidator();
|
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
|
// GET / — list tickets
|
||||||
router.get('/', async (c) => {
|
router.get('/', async (c) => {
|
||||||
|
const params = new URL(c.req.url).searchParams;
|
||||||
const queueId = c.req.query('queue_id');
|
const queueId = c.req.query('queue_id');
|
||||||
const status = c.req.query('status');
|
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({
|
let 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;
|
|
||||||
},
|
|
||||||
orderBy: asc(tickets.created_at),
|
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);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,26 +120,118 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
router.post('/', async (c) => {
|
router.post('/', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const parsed = CreateTicketSchema.parse(body);
|
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({
|
const [ticket] = await db.insert(tickets).values({
|
||||||
subject: parsed.subject,
|
subject: parsed.subject,
|
||||||
queue_id: parsed.queue_id,
|
queue_id: parsed.queue_id,
|
||||||
status: 'new',
|
status: initialStatus,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
creator_id: creatorId,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
throw new HTTPException(500, { message: 'Failed to create ticket' });
|
throw new HTTPException(500, { message: 'Failed to create ticket' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record transaction
|
const txList = [
|
||||||
await db.insert(transactions).values({
|
{
|
||||||
ticket_id: ticket.id,
|
ticket_id: ticket.id,
|
||||||
transaction_type: 'Create',
|
transaction_type: 'Create',
|
||||||
field: 'status',
|
field: 'status',
|
||||||
new_value: 'new',
|
new_value: initialStatus,
|
||||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
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);
|
return c.json(ticket, 201);
|
||||||
});
|
});
|
||||||
@@ -104,6 +281,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lifecycleDef: LifecycleDefinition | null = null;
|
||||||
|
|
||||||
// Validate lifecycle transition if status is changing
|
// Validate lifecycle transition if status is changing
|
||||||
if (parsed.status) {
|
if (parsed.status) {
|
||||||
const queue = await db.query.queues.findFirst({
|
const queue = await db.query.queues.findFirst({
|
||||||
@@ -116,8 +295,8 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (lifecycle) {
|
if (lifecycle) {
|
||||||
const def = lifecycle.definition as LifecycleDefinition;
|
lifecycleDef = lifecycle.definition as LifecycleDefinition;
|
||||||
const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status);
|
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status);
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
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({
|
txList.push({
|
||||||
ticket_id: id,
|
ticket_id: id,
|
||||||
transaction_type: 'SetOwner' as const,
|
transaction_type: 'SetOwner' as const,
|
||||||
@@ -163,8 +342,28 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
// Update the ticket
|
// Update the ticket
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (parsed.subject) updateData.subject = parsed.subject;
|
if (parsed.subject) updateData.subject = parsed.subject;
|
||||||
if (parsed.status) updateData.status = parsed.status;
|
if (parsed.status) {
|
||||||
if (parsed.owner_id) updateData.owner_id = parsed.owner_id;
|
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();
|
updateData.updated_at = new Date();
|
||||||
|
|
||||||
const [updated] = await db.update(tickets)
|
const [updated] = await db.update(tickets)
|
||||||
@@ -198,6 +397,26 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
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[] = [];
|
const txList: any[] = [];
|
||||||
|
|
||||||
if (parsed.status && parsed.status !== ticket.status) {
|
if (parsed.status && parsed.status !== ticket.status) {
|
||||||
@@ -266,5 +485,93 @@ export function createTicketsRouter(db: Db): Hono {
|
|||||||
return c.json(tx, 201);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/routes/users.ts
Normal file
17
src/routes/users.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import nodemailer from 'nodemailer';
|
import nodemailer from 'nodemailer';
|
||||||
import type { Transporter } from 'nodemailer';
|
import type { Transporter } from 'nodemailer';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
import { config } from '../config.ts';
|
import { config } from '../config.ts';
|
||||||
import type { Db } from '../db/index.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 {
|
export interface ActionExecutor {
|
||||||
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
|
execute(payload: ActionPayload): Promise<{ success: boolean; message: string }>;
|
||||||
@@ -21,12 +24,15 @@ export interface ActionPayload {
|
|||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
field_id?: string;
|
field_id?: string;
|
||||||
|
field_key?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SendEmail implements ActionExecutor {
|
export class SendEmail implements ActionExecutor {
|
||||||
private transporter: Transporter | null = null;
|
private transporter: Transporter | null = null;
|
||||||
|
|
||||||
|
constructor(private db: Db) {}
|
||||||
|
|
||||||
private getTransporter(): Transporter {
|
private getTransporter(): Transporter {
|
||||||
if (!this.transporter) {
|
if (!this.transporter) {
|
||||||
this.transporter = nodemailer.createTransport({
|
this.transporter = nodemailer.createTransport({
|
||||||
@@ -39,8 +45,55 @@ export class SendEmail implements ActionExecutor {
|
|||||||
return this.transporter;
|
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 }> {
|
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 subject = payload.subject ?? (payload.actionConfig['subject'] as string | undefined);
|
||||||
const body = payload.body ?? (payload.actionConfig['body'] 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 {
|
export class SetCustomField implements ActionExecutor {
|
||||||
constructor(private db: Db) {}
|
constructor(private db: Db) {}
|
||||||
|
|
||||||
async execute(payload: ActionPayload): Promise<{ success: boolean; message: string }> {
|
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 value = payload.value ?? String(payload.actionConfig['value'] ?? '');
|
||||||
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
const ticketId = payload.ticketId ?? Number(payload.actionConfig['ticket_id'] ?? 0);
|
||||||
|
|
||||||
if (!fieldId || !value || !ticketId) {
|
if (!fieldRef || !value || !ticketId) {
|
||||||
return { success: false, message: 'SetCustomField: missing field_id, value, or ticket_id' };
|
return { success: false, message: 'SetCustomField: missing field reference, value, or ticket_id' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
await this.db.insert(customFieldValues).values({
|
||||||
custom_field_id: fieldId,
|
custom_field_id: field.id,
|
||||||
ticket_id: ticketId,
|
ticket_id: ticketId,
|
||||||
value,
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
return { success: false, message: `SetCustomField failed: ${message}` };
|
return { success: false, message: `SetCustomField failed: ${message}` };
|
||||||
@@ -150,8 +457,10 @@ export class CreateTransaction implements ActionExecutor {
|
|||||||
|
|
||||||
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
|
export function createActionRegistry(db: Db): Record<string, ActionExecutor> {
|
||||||
return {
|
return {
|
||||||
SendEmail: new SendEmail(),
|
SendEmail: new SendEmail(db),
|
||||||
Webhook: new Webhook(),
|
Webhook: new Webhook(),
|
||||||
|
FetchMetadata: new FetchMetadata(db),
|
||||||
|
RunScript: new RunScript(db),
|
||||||
SetCustomField: new SetCustomField(db),
|
SetCustomField: new SetCustomField(db),
|
||||||
CreateTransaction: new CreateTransaction(db),
|
CreateTransaction: new CreateTransaction(db),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,29 @@ export interface ConditionEvaluateContext {
|
|||||||
lifecycleDef?: LifecycleDefinition;
|
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 {
|
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 {
|
export class OnCreate implements ConditionEvaluator {
|
||||||
@@ -18,19 +39,25 @@ export class OnCreate implements ConditionEvaluator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OnStatusChange implements ConditionEvaluator {
|
export class OnStatusChange implements ConditionEvaluator {
|
||||||
evaluate(_ticket: Ticket, transactions: Transaction[]): boolean {
|
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||||
return transactions.some((tx) => tx.transaction_type === 'StatusChange');
|
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 {
|
export class OnResolve implements ConditionEvaluator {
|
||||||
private lifecycleValidator = new LifecycleValidator();
|
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;
|
const lifecycleDef = context?.lifecycleDef;
|
||||||
|
|
||||||
return transactions.some((tx) => {
|
return transactions.some((tx) => {
|
||||||
if (tx.transaction_type !== 'StatusChange' || tx.new_value === null) return false;
|
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) {
|
if (lifecycleDef) {
|
||||||
return this.lifecycleValidator.isResolvedStatus(lifecycleDef, tx.new_value);
|
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> = {
|
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||||
OnCreate: new OnCreate(),
|
OnCreate: new OnCreate(),
|
||||||
OnStatusChange: new OnStatusChange(),
|
OnStatusChange: new OnStatusChange(),
|
||||||
OnResolve: new OnResolve(),
|
OnResolve: new OnResolve(),
|
||||||
|
OnCustomFieldChange: new OnCustomFieldChange(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Transaction } from '../models/transaction.ts';
|
|||||||
import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
|
import { tickets, queues, scrips, lifecycles, customFieldValues, customFields } from '../db/schema.ts';
|
||||||
import { eq, asc, inArray } from 'drizzle-orm';
|
import { eq, asc, inArray } from 'drizzle-orm';
|
||||||
import { getConditionEvaluator } from './conditions.ts';
|
import { getConditionEvaluator } from './conditions.ts';
|
||||||
import type { ConditionEvaluateContext } from './conditions.ts';
|
import type { ConditionConfig, ConditionEvaluateContext } from './conditions.ts';
|
||||||
import { getActionExecutor } from './actions.ts';
|
import { getActionExecutor } from './actions.ts';
|
||||||
import type { ActionPayload } from './actions.ts';
|
import type { ActionPayload } from './actions.ts';
|
||||||
import { TemplateRenderer } from './templates.ts';
|
import { TemplateRenderer } from './templates.ts';
|
||||||
@@ -103,7 +103,12 @@ export class ScripEngine {
|
|||||||
continue;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class TemplateRenderer {
|
|||||||
|
|
||||||
export interface TemplateContext {
|
export interface TemplateContext {
|
||||||
ticket: {
|
ticket: {
|
||||||
id: string;
|
id: number;
|
||||||
subject: string;
|
subject: string;
|
||||||
status: string;
|
status: string;
|
||||||
queue_id: string;
|
queue_id: string;
|
||||||
|
|||||||
@@ -27,5 +27,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"exclude": ["web", "node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pnpm dev
|
|||||||
bun 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.
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import { dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const appRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
outputFileTracingRoot: appRoot,
|
||||||
|
turbopack: {
|
||||||
|
root: appRoot,
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/api/:path*',
|
source: "/api/:path*",
|
||||||
destination: 'http://127.0.0.1:9876/:path*',
|
destination: "http://127.0.0.1:9876/:path*",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|||||||
95
web/package-lock.json
generated
95
web/package-lock.json
generated
@@ -9,15 +9,21 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
"shadcn": "^4.10.0",
|
"shadcn": "^4.10.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -943,6 +949,18 @@
|
|||||||
"hono": "^4"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1981,6 +1999,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2261,6 +2285,39 @@
|
|||||||
"tailwindcss": "4.3.0"
|
"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": {
|
"node_modules/@ts-morph/common": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz",
|
||||||
@@ -3947,6 +4004,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -8002,6 +8079,22 @@
|
|||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -H 127.0.0.1 --port 3100",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -49,72 +49,72 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.982 0.006 106);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.19 0.018 248);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.996 0.003 106);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.19 0.018 248);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.996 0.003 106);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.19 0.018 248);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.31 0.046 243);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.99 0.003 106);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.945 0.01 105);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.25 0.026 244);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.948 0.008 106);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.49 0.023 250);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.925 0.024 184);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.21 0.028 246);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.55 0.18 27);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.865 0.014 102);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.84 0.015 102);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.58 0.068 185);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.62 0.095 184);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.53 0.078 243);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.64 0.12 77);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.55 0.15 28);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.44 0.055 257);
|
||||||
--radius: 0.625rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.245 0.026 248);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.93 0.012 108);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.69 0.105 184);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.18 0.022 248);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.31 0.031 248);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.98 0.006 106);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(1 0 0 / 11%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.66 0.102 184);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.18 0.018 248);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.94 0.011 105);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.225 0.022 248);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.94 0.011 105);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.225 0.022 248);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.94 0.011 105);
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: oklch(0.74 0.105 184);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.17 0.018 248);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.27 0.026 248);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.94 0.011 105);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.28 0.023 248);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.7 0.019 105);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.31 0.043 184);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.94 0.011 105);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.68 0.17 24);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 12%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 16%);
|
||||||
--ring: oklch(0.556 0 0);
|
--ring: oklch(0.68 0.095 184);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.74 0.105 184);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.7 0.105 74);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.66 0.12 25);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.61 0.08 245);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.8 0.04 108);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.145 0.018 248);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.94 0.011 105);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.74 0.105 184);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.17 0.018 248);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.24 0.026 248);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.94 0.011 105);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 11%);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.68 0.095 184);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-in-right {
|
@keyframes slide-in-right {
|
||||||
@@ -135,6 +135,10 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-feature-settings: "cv01" 1, "ss03" 1;
|
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 {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { Metadata } from "next";
|
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 { Suspense } from "react";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AppShell } from "@/components/app-shell";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
const inter = Inter({
|
const ibmPlexSans = IBM_Plex_Sans({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-inter",
|
weight: ["400", "500", "600", "700"],
|
||||||
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
@@ -26,7 +27,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
className={`${ibmPlexSans.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||||
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
style={{ fontSize: "15px", lineHeight: 1.5 }}
|
||||||
>
|
>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
|
|||||||
1036
web/src/app/page.tsx
1036
web/src/app/page.tsx
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
|
CommandIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTickets, getQueues } from "@/lib/api";
|
import { getTickets, getQueues } from "@/lib/api";
|
||||||
import type { Queue } from "@/lib/types";
|
import type { Queue } from "@/lib/types";
|
||||||
@@ -51,11 +52,11 @@ function SidebarNavItem({
|
|||||||
href={href}
|
href={href}
|
||||||
title={collapsed ? label : undefined}
|
title={collapsed ? label : undefined}
|
||||||
className={cn(
|
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",
|
collapsed ? "justify-center w-full" : "justify-between",
|
||||||
active
|
active
|
||||||
? "bg-accent text-foreground font-medium"
|
? "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-muted-foreground hover:text-foreground hover:bg-accent font-normal"
|
: "text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent font-normal"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
|
<span className={cn("flex items-center", collapsed ? "" : "gap-2.5")}>
|
||||||
@@ -63,7 +64,10 @@ function SidebarNavItem({
|
|||||||
{!collapsed && label}
|
{!collapsed && label}
|
||||||
</span>
|
</span>
|
||||||
{!collapsed && count !== undefined && count > 0 && (
|
{!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}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -171,7 +175,7 @@ function SidebarNav() {
|
|||||||
{queues.length > 0 && (
|
{queues.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{!collapsed && (
|
{!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
|
Queues
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -179,7 +183,7 @@ function SidebarNav() {
|
|||||||
const active =
|
const active =
|
||||||
pathname === "/" && searchParams.get("queue") === queue.id;
|
pathname === "/" && searchParams.get("queue") === queue.id;
|
||||||
const QueueIcon = () => (
|
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 (
|
return (
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
@@ -203,7 +207,7 @@ function SidebarBottom() {
|
|||||||
const collapsed = useSidebarCollapsed();
|
const collapsed = useSidebarCollapsed();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border p-2">
|
<div className="border-t border-sidebar-border p-2">
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
href="/admin"
|
href="/admin"
|
||||||
icon={SettingsIcon}
|
icon={SettingsIcon}
|
||||||
@@ -217,13 +221,13 @@ function SidebarBottom() {
|
|||||||
)}
|
)}
|
||||||
title={collapsed ? "User" : undefined}
|
title={collapsed ? "User" : undefined}
|
||||||
>
|
>
|
||||||
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center flex-shrink-0">
|
<div className="w-5 h-5 rounded-md bg-sidebar-primary flex items-center justify-center flex-shrink-0">
|
||||||
<span className="text-primary-foreground text-[10px] font-semibold">
|
<span className="text-sidebar-primary-foreground text-[10px] font-semibold">
|
||||||
U
|
U
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="text-[13px] text-muted-foreground truncate">
|
<span className="text-[13px] text-sidebar-foreground/65 truncate">
|
||||||
User
|
User
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -259,39 +263,54 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
|
<SidebarCollapsedContext.Provider value={sidebarCollapsed}>
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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"
|
sidebarCollapsed ? "w-[60px]" : "w-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Brand */}
|
{/* 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">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<div className="w-5 h-5 rounded-md bg-primary flex items-center justify-center">
|
<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-primary-foreground text-[11px] font-semibold">
|
<span className="text-sidebar-primary-foreground text-[12px] font-bold">
|
||||||
T
|
T
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<span className="font-semibold text-foreground text-sm tracking-tight">
|
<span className="leading-tight">
|
||||||
Tessera
|
<span className="block font-semibold text-sidebar-foreground text-sm">
|
||||||
|
Tessera
|
||||||
|
</span>
|
||||||
|
<span className="block text-[10px] text-sidebar-foreground/45">
|
||||||
|
ScripFoundry
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Nav */}
|
{/* Nav */}
|
||||||
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
<nav className="flex-1 overflow-y-auto py-3 px-2">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="space-y-1.5 px-2">
|
<div className="space-y-1.5 px-2">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="h-7 bg-muted rounded-md animate-pulse"
|
className="h-7 bg-sidebar-accent rounded-md animate-pulse"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +325,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<main className="flex-1 overflow-hidden">{children}</main>
|
<main className="flex-1 overflow-hidden bg-background/88">{children}</main>
|
||||||
|
|
||||||
{/* Command Palette */}
|
{/* Command Palette */}
|
||||||
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
||||||
@@ -315,7 +334,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Collapse toggle */}
|
{/* Collapse toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
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 }}
|
style={{ left: sidebarCollapsed ? 60 : 240 }}
|
||||||
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -15,7 +16,7 @@ import type { Ticket } from "@/lib/types";
|
|||||||
interface CommandItem {
|
interface CommandItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
action: () => void;
|
action: () => void;
|
||||||
category?: string;
|
category?: string;
|
||||||
}
|
}
|
||||||
@@ -41,79 +42,87 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const alwaysCommands: CommandItem[] = [
|
const filtered = useMemo(() => {
|
||||||
{
|
const normalizedQuery = query.toLowerCase();
|
||||||
id: "new-ticket",
|
const alwaysCommands: CommandItem[] = [
|
||||||
label: "New ticket",
|
{
|
||||||
icon: PlusIcon,
|
id: "new-ticket",
|
||||||
action: () => {
|
label: "New ticket",
|
||||||
onOpenChange(false);
|
icon: PlusIcon,
|
||||||
router.push("/?new=true");
|
action: () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
router.push("/?new=true");
|
||||||
|
},
|
||||||
|
category: "Actions",
|
||||||
},
|
},
|
||||||
category: "Actions",
|
{
|
||||||
},
|
id: "admin",
|
||||||
{
|
label: "Go to admin",
|
||||||
id: "admin",
|
icon: SettingsIcon,
|
||||||
label: "Go to admin",
|
action: () => {
|
||||||
icon: SettingsIcon,
|
onOpenChange(false);
|
||||||
action: () => {
|
router.push("/admin");
|
||||||
onOpenChange(false);
|
},
|
||||||
router.push("/admin");
|
category: "Navigate",
|
||||||
},
|
},
|
||||||
category: "Navigate",
|
{
|
||||||
},
|
id: "all-tickets",
|
||||||
{
|
label: "All tickets",
|
||||||
id: "all-tickets",
|
icon: LayoutGridIcon,
|
||||||
label: "All tickets",
|
action: () => {
|
||||||
icon: LayoutGridIcon,
|
onOpenChange(false);
|
||||||
action: () => {
|
router.push("/");
|
||||||
onOpenChange(false);
|
},
|
||||||
router.push("/");
|
category: "Navigate",
|
||||||
},
|
},
|
||||||
category: "Navigate",
|
];
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ticketCommands: CommandItem[] = tickets
|
const alwaysFiltered = alwaysCommands.filter((cmd) =>
|
||||||
.filter((t) => t.subject.toLowerCase().includes(query.toLowerCase()))
|
cmd.label.toLowerCase().includes(normalizedQuery)
|
||||||
.map((t) => ({
|
);
|
||||||
id: `ticket-${t.id}`,
|
const ticketCommands: CommandItem[] = tickets
|
||||||
label: t.subject,
|
.filter((t) => t.subject.toLowerCase().includes(normalizedQuery))
|
||||||
icon: MessageSquareIcon,
|
.map((t) => ({
|
||||||
action: () => {
|
id: `ticket-${t.id}`,
|
||||||
onOpenChange(false);
|
label: t.subject,
|
||||||
router.push(`/tickets/${t.id}`);
|
icon: MessageSquareIcon,
|
||||||
},
|
action: () => {
|
||||||
category: "Tickets",
|
onOpenChange(false);
|
||||||
}));
|
router.push(`/tickets/${t.id}`);
|
||||||
|
},
|
||||||
|
category: "Tickets",
|
||||||
|
}));
|
||||||
|
|
||||||
const alwaysFiltered = alwaysCommands.filter((cmd) =>
|
return [...alwaysFiltered, ...ticketCommands];
|
||||||
cmd.label.toLowerCase().includes(query.toLowerCase())
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setQuery("");
|
queueMicrotask(() => {
|
||||||
setSelectedIndex(0);
|
setQuery("");
|
||||||
|
setSelectedIndex(0);
|
||||||
|
});
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0);
|
queueMicrotask(() => setSelectedIndex(0));
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export function ThemeToggle() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => setMounted(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return <div className="w-8 h-8" />;
|
return <div className="w-8 h-8" />;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
Ticket,
|
Ticket,
|
||||||
Queue,
|
Queue,
|
||||||
|
User,
|
||||||
Transaction,
|
Transaction,
|
||||||
Scrip,
|
Scrip,
|
||||||
|
Template,
|
||||||
|
TemplatePreview,
|
||||||
Lifecycle,
|
Lifecycle,
|
||||||
|
LifecycleDefinition,
|
||||||
CustomField,
|
CustomField,
|
||||||
|
QueueCustomField,
|
||||||
PreviewResult,
|
PreviewResult,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from "./types";
|
} 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();
|
const sp = new URLSearchParams();
|
||||||
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
if (params?.queue_id) sp.set("queue_id", params.queue_id);
|
||||||
if (params?.status) sp.set("status", params.status);
|
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();
|
const qs = sp.toString();
|
||||||
return request<Ticket[]>(`/tickets${qs ? `?${qs}` : ""}`);
|
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}`);
|
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) });
|
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) });
|
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");
|
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) });
|
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 }> {
|
export async function getScrips(): Promise<{ data: Scrip[] | null; error: string | null }> {
|
||||||
return request<Scrip[]>("/scrips");
|
return request<Scrip[]>("/scrips");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScrip(data: {
|
export async function createScrip(data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
queue_id?: string | null;
|
queue_id?: string | null;
|
||||||
condition_type: string;
|
condition_type: string;
|
||||||
|
condition_config?: Record<string, unknown>;
|
||||||
action_type: string;
|
action_type: string;
|
||||||
action_config?: Record<string, unknown>;
|
action_config?: Record<string, unknown>;
|
||||||
template_id?: string | null;
|
template_id?: string | null;
|
||||||
@@ -88,8 +121,10 @@ export async function createScrip(data: {
|
|||||||
|
|
||||||
export async function updateScrip(id: string, data: {
|
export async function updateScrip(id: string, data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
queue_id?: string | null;
|
queue_id?: string | null;
|
||||||
condition_type?: string;
|
condition_type?: string;
|
||||||
|
condition_config?: Record<string, unknown>;
|
||||||
action_type?: string;
|
action_type?: string;
|
||||||
action_config?: Record<string, unknown>;
|
action_config?: Record<string, unknown>;
|
||||||
template_id?: string | null;
|
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) });
|
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 }> {
|
export async function getLifecycles(): Promise<{ data: Lifecycle[] | null; error: string | null }> {
|
||||||
return request<Lifecycle[]>("/lifecycles");
|
return request<Lifecycle[]>("/lifecycles");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLifecycle(data: {
|
export async function createLifecycle(data: {
|
||||||
name: string;
|
name: string;
|
||||||
definition: Record<string, unknown>;
|
definition: Record<string, unknown> | LifecycleDefinition;
|
||||||
}): Promise<{ data: Lifecycle | null; error: string | null }> {
|
}): Promise<{ data: Lifecycle | null; error: string | null }> {
|
||||||
return request<Lifecycle>("/lifecycles", { method: "POST", body: JSON.stringify(data) });
|
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 }> {
|
export async function getCustomFields(): Promise<{ data: CustomField[] | null; error: string | null }> {
|
||||||
return request<CustomField[]>("/custom-fields");
|
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: {
|
export async function createCustomField(data: {
|
||||||
|
key?: string;
|
||||||
name: string;
|
name: string;
|
||||||
field_type: string;
|
field_type: string;
|
||||||
values?: unknown | null;
|
values?: unknown | null;
|
||||||
max_values?: number;
|
max_values?: number;
|
||||||
|
pattern?: string | null;
|
||||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||||
return request<CustomField>("/custom-fields", { method: "POST", body: JSON.stringify(data) });
|
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) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export interface Queue {
|
|||||||
lifecycle_id: string | null;
|
lifecycle_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: string;
|
id: string;
|
||||||
ticket_id: number;
|
ticket_id: number;
|
||||||
@@ -35,13 +42,16 @@ export interface Scrip {
|
|||||||
id: string;
|
id: string;
|
||||||
queue_id: string | null;
|
queue_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string | null;
|
||||||
condition_type: string;
|
condition_type: string;
|
||||||
|
condition_config: Record<string, unknown>;
|
||||||
action_type: string;
|
action_type: string;
|
||||||
action_config: Record<string, unknown>;
|
action_config: Record<string, unknown>;
|
||||||
template_id: string | null;
|
template_id: string | null;
|
||||||
stage: string;
|
stage: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
@@ -50,6 +60,13 @@ export interface Template {
|
|||||||
queue_id: string | null;
|
queue_id: string | null;
|
||||||
subject_template: string;
|
subject_template: string;
|
||||||
body_template: string;
|
body_template: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatePreview {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
context: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Lifecycle {
|
export interface Lifecycle {
|
||||||
@@ -65,16 +82,26 @@ export interface LifecycleDefinition {
|
|||||||
|
|
||||||
export interface CustomField {
|
export interface CustomField {
|
||||||
id: string;
|
id: string;
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
field_type: string;
|
field_type: string;
|
||||||
values: unknown | null;
|
values: unknown | null;
|
||||||
max_values: number;
|
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 {
|
export interface CustomFieldValue {
|
||||||
id: string;
|
id: string;
|
||||||
custom_field_id: string;
|
custom_field_id: string;
|
||||||
ticket_id: string;
|
ticket_id: number;
|
||||||
value: string;
|
value: string;
|
||||||
custom_field?: CustomField;
|
custom_field?: CustomField;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user