diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts new file mode 100644 index 0000000..ba3e34d --- /dev/null +++ b/scripts/smoke-test.ts @@ -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(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`${url} returned ${response.status} ${response.statusText}`); + } + return response.json() as Promise; +} + +async function requestOk(url: string): Promise { + 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): Promise { + 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(`${backendUrl}/queues`); + if (queues.length < 1) { + throw new Error('expected at least one queue'); + } + }); + + await check('tickets exist', async () => { + const tickets = await requestJson(`${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( + `${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); +}); diff --git a/scripts/watch-frontend.sh b/scripts/watch-frontend.sh new file mode 100644 index 0000000..36a1166 --- /dev/null +++ b/scripts/watch-frontend.sh @@ -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 diff --git a/src/db/seed.ts b/src/db/seed.ts new file mode 100644 index 0000000..a1d73e6 --- /dev/null +++ b/src/db/seed.ts @@ -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; +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 { + 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; + 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); +});