feat: add database seed script and utility scripts
- src/db/seed.ts: comprehensive seed data with idempotent upserts - 5 users (system, gjermund, operator, technician, analyst) - 5 queues (Support Desk, Operations, IT Infrastructure, Facilities, Field Ops) - 1 lifecycle (Demo service lifecycle with new→open→in_progress→resolved→closed) - 5 custom fields (impact, location, channel, urgency, outcome) with short keys - 10 realistic support tickets with varied statuses, custom fields, and history - 3 scrips (OnCreate email, OnResolve custom field, customer notification) - 2 templates (auto-response, resolve notification) - --reset flag to truncate all data before seeding - scripts/smoke-test.ts: API smoke tests - scripts/watch-frontend.sh: frontend dev helper Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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
|
||||||
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user