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