diff --git a/src/routes/custom-fields.ts b/src/routes/custom-fields.ts index 0c1cabb..5af4d44 100644 --- a/src/routes/custom-fields.ts +++ b/src/routes/custom-fields.ts @@ -1,8 +1,17 @@ import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Db } from '../db/index.ts'; -import { customFields } from '../db/schema.ts'; -import { asc } from 'drizzle-orm'; +import { customFields, queueCustomFields } from '../db/schema.ts'; +import { and, asc, eq } from 'drizzle-orm'; + +function makeFieldKey(value: string): string { + const key = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return key || 'field'; +} export function createCustomFieldsRouter(db: Db): Hono { const router = new Hono(); @@ -17,12 +26,14 @@ export function createCustomFieldsRouter(db: Db): Hono { router.post('/', async (c) => { const body = await c.req.json(); const { name, field_type, values, max_values, pattern } = body; + const key = makeFieldKey(String(body.key ?? name ?? '')); if (!name || !field_type) { throw new HTTPException(400, { message: 'name and field_type are required' }); } const [cf] = await db.insert(customFields).values({ + key, name, field_type, values: values ?? null, @@ -37,5 +48,94 @@ export function createCustomFieldsRouter(db: Db): Hono { return c.json(cf, 201); }); + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await db.query.customFields.findFirst({ + where: eq(customFields.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Custom field not found' }); + } + + const updateData: Partial = {}; + if (body.key !== undefined) updateData.key = makeFieldKey(String(body.key)); + if (body.name !== undefined) updateData.name = String(body.name); + if (body.field_type !== undefined) updateData.field_type = String(body.field_type); + if (body.values !== undefined) updateData.values = body.values ?? null; + if (body.max_values !== undefined) updateData.max_values = Number(body.max_values); + if (body.pattern !== undefined) updateData.pattern = body.pattern ? String(body.pattern) : null; + + const [updated] = await db.update(customFields) + .set(updateData) + .where(eq(customFields.id, id)) + .returning(); + + return c.json(updated); + }); + + router.get('/queues/:queueId', async (c) => { + const queueId = c.req.param('queueId'); + const assignments = await db.query.queueCustomFields.findMany({ + where: eq(queueCustomFields.queue_id, queueId), + orderBy: asc(queueCustomFields.sort_order), + }); + const fieldIds = assignments.map((assignment) => assignment.custom_field_id); + const fields = fieldIds.length > 0 + ? await db.query.customFields.findMany({ + where: (table, { inArray }) => inArray(table.id, fieldIds), + }) + : []; + const fieldMap = new Map(fields.map((field) => [field.id, field])); + + return c.json(assignments.map((assignment) => ({ + ...assignment, + custom_field: fieldMap.get(assignment.custom_field_id) ?? null, + }))); + }); + + router.post('/queues/:queueId', async (c) => { + const queueId = c.req.param('queueId'); + const body = await c.req.json(); + const customFieldId = body.custom_field_id; + + if (!customFieldId) { + throw new HTTPException(400, { message: 'custom_field_id is required' }); + } + + const [assignment] = await db.insert(queueCustomFields).values({ + queue_id: queueId, + custom_field_id: customFieldId, + sort_order: Number(body.sort_order ?? 0), + }).onConflictDoNothing().returning(); + + if (assignment) { + return c.json(assignment, 201); + } + + const existing = await db.query.queueCustomFields.findFirst({ + where: and( + eq(queueCustomFields.queue_id, queueId), + eq(queueCustomFields.custom_field_id, customFieldId), + ), + }); + + return c.json(existing, 200); + }); + + router.delete('/queues/:queueId/:fieldId', async (c) => { + const queueId = c.req.param('queueId'); + const fieldId = c.req.param('fieldId'); + + await db.delete(queueCustomFields).where(and( + eq(queueCustomFields.queue_id, queueId), + eq(queueCustomFields.custom_field_id, fieldId), + )); + + return c.json({ ok: true }); + }); + return router; } diff --git a/src/routes/lifecycles.ts b/src/routes/lifecycles.ts index 919b63d..6f91539 100644 --- a/src/routes/lifecycles.ts +++ b/src/routes/lifecycles.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Db } from '../db/index.ts'; import { lifecycles } from '../db/schema.ts'; -import { asc } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; export function createLifecyclesRouter(db: Db): Hono { const router = new Hono(); @@ -34,5 +34,29 @@ export function createLifecyclesRouter(db: Db): Hono { return c.json(lifecycle, 201); }); + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await db.query.lifecycles.findFirst({ + where: eq(lifecycles.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Lifecycle not found' }); + } + + const updateData: Partial = {}; + if (body.name !== undefined) updateData.name = String(body.name); + if (body.definition !== undefined) updateData.definition = body.definition; + + const [updated] = await db.update(lifecycles) + .set(updateData) + .where(eq(lifecycles.id, id)) + .returning(); + + return c.json(updated); + }); + return router; } diff --git a/src/routes/queues.ts b/src/routes/queues.ts index 3cc4624..1fa409c 100644 --- a/src/routes/queues.ts +++ b/src/routes/queues.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Db } from '../db/index.ts'; import { queues } from '../db/schema.ts'; -import { asc } from 'drizzle-orm'; +import { asc, eq } from 'drizzle-orm'; import { CreateQueueSchema } from '../models/queue.ts'; export function createQueuesRouter(db: Db): Hono { @@ -32,5 +32,30 @@ export function createQueuesRouter(db: Db): Hono { return c.json(queue, 201); }); + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await db.query.queues.findFirst({ + where: eq(queues.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Queue not found' }); + } + + const updateData: Partial = {}; + if (body.name !== undefined) updateData.name = String(body.name); + if (body.description !== undefined) updateData.description = body.description ? String(body.description) : null; + if (body.lifecycle_id !== undefined) updateData.lifecycle_id = body.lifecycle_id || null; + + const [updated] = await db.update(queues) + .set(updateData) + .where(eq(queues.id, id)) + .returning(); + + return c.json(updated); + }); + return router; } diff --git a/src/routes/templates.ts b/src/routes/templates.ts new file mode 100644 index 0000000..a123c4d --- /dev/null +++ b/src/routes/templates.ts @@ -0,0 +1,174 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { asc, desc, eq } from 'drizzle-orm'; +import type { Db } from '../db/index.ts'; +import { customFieldValues, queues, templates, tickets, transactions } from '../db/schema.ts'; +import { TemplateRenderer } from '../scrip/templates.ts'; +import type { TemplateContext } from '../scrip/templates.ts'; + +function buildDemoContext(): TemplateContext { + return { + ticket: { + id: 1001, + subject: 'Replace access badge reader', + status: 'open', + queue_id: 'demo-queue', + owner_id: null, + creator_id: 'demo-user', + created_at: new Date('2026-06-08T08:00:00.000Z').toISOString(), + updated_at: new Date('2026-06-08T09:15:00.000Z').toISOString(), + }, + queue: { name: 'Support Desk' }, + transaction: { + type: 'StatusChange', + field: 'status', + old_value: 'new', + new_value: 'open', + }, + custom_fields: { + impact: 'High', + location: 'HQ 2nd floor', + channel: 'Portal', + }, + }; +} + +async function buildTicketContext(db: Db, ticketId: number): Promise { + const ticket = await db.query.tickets.findFirst({ + where: eq(tickets.id, ticketId), + }); + + if (!ticket) { + throw new HTTPException(404, { message: 'Ticket not found' }); + } + + const queue = await db.query.queues.findFirst({ + where: eq(queues.id, ticket.queue_id), + }); + const latestTx = await db.query.transactions.findFirst({ + where: eq(transactions.ticket_id, ticket.id), + orderBy: desc(transactions.created_at), + }); + const cfValues = await db.query.customFieldValues.findMany({ + where: eq(customFieldValues.ticket_id, ticket.id), + }); + const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))]; + const fields = fieldIds.length > 0 + ? await db.query.customFields.findMany({ + where: (table, { inArray }) => inArray(table.id, fieldIds), + }) + : []; + const fieldById = new Map(fields.map((field) => [field.id, field])); + const customFieldsMap: Record = {}; + + for (const value of cfValues) { + const field = fieldById.get(value.custom_field_id); + if (field) customFieldsMap[field.key] = value.value; + } + + return { + ticket: { + id: ticket.id, + subject: ticket.subject, + status: ticket.status, + queue_id: ticket.queue_id, + owner_id: ticket.owner_id, + creator_id: ticket.creator_id, + created_at: ticket.created_at?.toISOString() ?? new Date().toISOString(), + updated_at: ticket.updated_at?.toISOString() ?? new Date().toISOString(), + }, + queue: { + name: queue?.name ?? 'unknown', + }, + transaction: { + type: latestTx?.transaction_type ?? 'Preview', + field: latestTx?.field ?? null, + old_value: latestTx?.old_value ?? null, + new_value: latestTx?.new_value ?? null, + }, + custom_fields: customFieldsMap, + }; +} + +export function createTemplatesRouter(db: Db): Hono { + const router = new Hono(); + const renderer = new TemplateRenderer(); + + router.get('/', async (c) => { + const result = await db.query.templates.findMany({ + orderBy: asc(templates.name), + }); + return c.json(result); + }); + + router.post('/', async (c) => { + const body = await c.req.json(); + const name = String(body.name ?? '').trim(); + const subjectTemplate = String(body.subject_template ?? ''); + const bodyTemplate = String(body.body_template ?? ''); + + if (!name || !subjectTemplate || !bodyTemplate) { + throw new HTTPException(400, { message: 'name, subject_template, and body_template are required' }); + } + + const [template] = await db.insert(templates).values({ + name, + queue_id: body.queue_id || null, + subject_template: subjectTemplate, + body_template: bodyTemplate, + }).returning(); + + if (!template) { + throw new HTTPException(500, { message: 'Failed to create template' }); + } + + return c.json(template, 201); + }); + + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + + const existing = await db.query.templates.findFirst({ + where: eq(templates.id, id), + }); + + if (!existing) { + throw new HTTPException(404, { message: 'Template not found' }); + } + + const updateData: Partial = {}; + if (body.name !== undefined) updateData.name = String(body.name).trim(); + if (body.queue_id !== undefined) updateData.queue_id = body.queue_id || null; + if (body.subject_template !== undefined) updateData.subject_template = String(body.subject_template); + if (body.body_template !== undefined) updateData.body_template = String(body.body_template); + + const [updated] = await db.update(templates) + .set(updateData) + .where(eq(templates.id, id)) + .returning(); + + return c.json(updated); + }); + + router.post('/preview', async (c) => { + const body = await c.req.json(); + const subjectTemplate = String(body.subject_template ?? ''); + const bodyTemplate = String(body.body_template ?? ''); + const ticketId = body.ticket_id === undefined || body.ticket_id === null || body.ticket_id === '' + ? null + : Number(body.ticket_id); + + if (!subjectTemplate || !bodyTemplate) { + throw new HTTPException(400, { message: 'subject_template and body_template are required' }); + } + + const context = ticketId ? await buildTicketContext(db, ticketId) : buildDemoContext(); + return c.json({ + ...renderer.render(subjectTemplate, bodyTemplate, context), + context, + }); + }); + + return router; +} diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts index 9895612..cb928c8 100644 --- a/src/routes/tickets.ts +++ b/src/routes/tickets.ts @@ -1,8 +1,8 @@ import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import type { Db } from '../db/index.ts'; -import { tickets, transactions, customFieldValues, customFields, queues, lifecycles } from '../db/schema.ts'; -import { eq, asc } from 'drizzle-orm'; +import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts'; +import { and, eq, asc } from 'drizzle-orm'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts'; import { ScripEngine } from '../scrip/engine.ts'; import { LifecycleValidator } from '../lifecycle/validator.ts'; @@ -13,21 +13,106 @@ export function createTicketsRouter(db: Db): Hono { const scripEngine = new ScripEngine(db); const lifecycleValidator = new LifecycleValidator(); + function statusClass(def: LifecycleDefinition, status: string): 'initial' | 'active' | 'inactive' | 'unknown' { + if (def.statuses.initial.includes(status)) return 'initial'; + if (def.statuses.active.includes(status)) return 'active'; + if (def.statuses.inactive.includes(status)) return 'inactive'; + return 'unknown'; + } + // GET / — list tickets router.get('/', async (c) => { + const params = new URL(c.req.url).searchParams; const queueId = c.req.query('queue_id'); const status = c.req.query('status'); + const ownerId = c.req.query('owner_id'); + const query = c.req.query('q')?.trim().toLowerCase() ?? ''; + const cfFilters = [...params.entries()] + .filter(([key, value]) => key.startsWith('cf.') && value.trim()) + .map(([key, value]) => ({ + key: key.slice(3), + value: value.trim().toLowerCase(), + })); - const result = await db.query.tickets.findMany({ - where: (t, { and, eq }) => { - const conditions = []; - if (queueId) conditions.push(eq(t.queue_id, queueId)); - if (status) conditions.push(eq(t.status, status)); - return conditions.length > 0 ? and(...conditions) : undefined; - }, + let result = await db.query.tickets.findMany({ orderBy: asc(tickets.created_at), }); + if (queueId) { + result = result.filter((ticket) => ticket.queue_id === queueId); + } + if (status) { + result = result.filter((ticket) => ticket.status === status); + } + if (ownerId) { + result = ownerId === 'unassigned' + ? result.filter((ticket) => !ticket.owner_id) + : result.filter((ticket) => ticket.owner_id === ownerId); + } + + const needsCustomFields = query || cfFilters.length > 0; + const valuesByTicket = new Map(); + + if (needsCustomFields && result.length > 0) { + const ticketIds = result.map((ticket) => ticket.id); + const cfValues = await db.query.customFieldValues.findMany({ + where: (table, { inArray }) => inArray(table.ticket_id, ticketIds), + }); + const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))]; + const fields = fieldIds.length > 0 + ? await db.query.customFields.findMany({ + where: (table, { inArray }) => inArray(table.id, fieldIds), + }) + : []; + const fieldMap = new Map(fields.map((field) => [field.id, field])); + + for (const value of cfValues) { + const rows = valuesByTicket.get(value.ticket_id) ?? []; + rows.push({ + fieldId: value.custom_field_id, + fieldKey: fieldMap.get(value.custom_field_id)?.key ?? value.custom_field_id, + fieldName: fieldMap.get(value.custom_field_id)?.name ?? value.custom_field_id, + value: value.value, + }); + valuesByTicket.set(value.ticket_id, rows); + } + } + + if (query) { + const queuesForSearch = await db.query.queues.findMany(); + const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name])); + result = result.filter((ticket) => { + const customFields = valuesByTicket.get(ticket.id) ?? []; + return ( + ticket.subject.toLowerCase().includes(query) || + String(ticket.id).includes(query) || + ticket.status.toLowerCase().includes(query) || + (queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query) || + customFields.some((field) => + field.fieldName.toLowerCase().includes(query) || + field.fieldKey.toLowerCase().includes(query) || + field.value.toLowerCase().includes(query) + ) + ); + }); + } + + if (cfFilters.length > 0) { + result = result.filter((ticket) => { + const customFields = valuesByTicket.get(ticket.id) ?? []; + return cfFilters.every((filter) => + customFields.some((field) => + ( + field.fieldId === filter.key || + field.fieldKey.toLowerCase() === filter.key.toLowerCase() || + field.fieldName.toLowerCase() === filter.key.toLowerCase() + ) && + field.value.toLowerCase() === filter.value + ) + ); + }); + } + return c.json(result); }); @@ -35,26 +120,118 @@ export function createTicketsRouter(db: Db): Hono { router.post('/', async (c) => { const body = await c.req.json(); const parsed = CreateTicketSchema.parse(body); + const creatorId = '00000000-0000-0000-0000-000000000000'; + const customFieldInput = parsed.custom_fields ?? {}; + const customFieldEntries = Object.entries(customFieldInput) + .map(([fieldId, value]) => [fieldId, value.trim()] as const) + .filter(([, value]) => value); + const queue = await db.query.queues.findFirst({ + where: eq(queues.id, parsed.queue_id), + }); + + if (!queue) { + throw new HTTPException(422, { message: 'Queue not found' }); + } + + let initialStatus = 'new'; + if (queue.lifecycle_id) { + const lifecycle = await db.query.lifecycles.findFirst({ + where: eq(lifecycles.id, queue.lifecycle_id), + }); + const definition = lifecycle?.definition as LifecycleDefinition | undefined; + initialStatus = definition?.statuses.initial[0] ?? initialStatus; + } + + let assignedFields: typeof customFields.$inferSelect[] = []; + if (customFieldEntries.length > 0) { + const assignments = await db.query.queueCustomFields.findMany({ + where: eq(queueCustomFields.queue_id, parsed.queue_id), + }); + const assignedIds = new Set(assignments.map((assignment) => assignment.custom_field_id)); + const requestedIds = customFieldEntries.map(([fieldId]) => fieldId); + + for (const fieldId of requestedIds) { + if (!assignedIds.has(fieldId)) { + throw new HTTPException(422, { message: 'Custom field is not assigned to this queue' }); + } + } + + assignedFields = await db.query.customFields.findMany({ + where: (table, { inArray }) => inArray(table.id, requestedIds), + }); + const fieldById = new Map(assignedFields.map((field) => [field.id, field])); + + for (const [fieldId, value] of customFieldEntries) { + const field = fieldById.get(fieldId); + if (!field) { + throw new HTTPException(422, { message: 'Custom field not found' }); + } + if (Array.isArray(field.values) && field.values.length > 0) { + const allowed = new Set(field.values.map((option) => String(option))); + if (!allowed.has(value)) { + throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` }); + } + } + if (field.pattern) { + const regex = new RegExp(field.pattern); + if (!regex.test(value)) { + throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` }); + } + } + } + } const [ticket] = await db.insert(tickets).values({ subject: parsed.subject, queue_id: parsed.queue_id, - status: 'new', - creator_id: '00000000-0000-0000-0000-000000000000', + status: initialStatus, + creator_id: creatorId, }).returning(); if (!ticket) { throw new HTTPException(500, { message: 'Failed to create ticket' }); } - // Record transaction - await db.insert(transactions).values({ - ticket_id: ticket.id, - transaction_type: 'Create', - field: 'status', - new_value: 'new', - creator_id: '00000000-0000-0000-0000-000000000000', - }); + const txList = [ + { + ticket_id: ticket.id, + transaction_type: 'Create', + field: 'status', + new_value: initialStatus, + creator_id: creatorId, + }, + ]; + + if (parsed.description) { + txList.push({ + ticket_id: ticket.id, + transaction_type: 'Correspond', + field: null, + new_value: null, + data: { body: parsed.description }, + creator_id: creatorId, + } as any); + } + + const fieldById = new Map(assignedFields.map((field) => [field.id, field])); + for (const [fieldId, value] of customFieldEntries) { + await db.insert(customFieldValues).values({ + ticket_id: ticket.id, + custom_field_id: fieldId, + value, + }); + txList.push({ + ticket_id: ticket.id, + transaction_type: 'CustomFieldChange', + field: fieldById.get(fieldId)?.key ?? fieldId, + new_value: value, + creator_id: creatorId, + } as any); + } + + const createdTransactions = await db.insert(transactions).values(txList as any).returning(); + const prepared = await scripEngine.prepare(ticket.id, createdTransactions as any); + await scripEngine.commit(prepared); return c.json(ticket, 201); }); @@ -104,6 +281,8 @@ export function createTicketsRouter(db: Db): Hono { throw new HTTPException(404, { message: 'Ticket not found' }); } + let lifecycleDef: LifecycleDefinition | null = null; + // Validate lifecycle transition if status is changing if (parsed.status) { const queue = await db.query.queues.findFirst({ @@ -116,8 +295,8 @@ export function createTicketsRouter(db: Db): Hono { }); if (lifecycle) { - const def = lifecycle.definition as LifecycleDefinition; - const result = lifecycleValidator.validateTransition(def, ticket.status, parsed.status); + lifecycleDef = lifecycle.definition as LifecycleDefinition; + const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status); if (!result.valid) { throw new HTTPException(422, { message: result.error ?? 'Invalid transition' }); } @@ -149,7 +328,7 @@ export function createTicketsRouter(db: Db): Hono { }); } - if (parsed.owner_id && parsed.owner_id !== ticket.owner_id) { + if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) { txList.push({ ticket_id: id, transaction_type: 'SetOwner' as const, @@ -163,8 +342,28 @@ export function createTicketsRouter(db: Db): Hono { // Update the ticket const updateData: Record = {}; if (parsed.subject) updateData.subject = parsed.subject; - if (parsed.status) updateData.status = parsed.status; - if (parsed.owner_id) updateData.owner_id = parsed.owner_id; + if (parsed.status) { + updateData.status = parsed.status; + + if (lifecycleDef && parsed.status !== ticket.status) { + const fromClass = statusClass(lifecycleDef, ticket.status); + const toClass = statusClass(lifecycleDef, parsed.status); + const now = new Date(); + + if (fromClass === 'initial' && (toClass === 'active' || toClass === 'inactive') && !ticket.started_at) { + updateData.started_at = now; + } + + if ((fromClass === 'initial' || fromClass === 'active') && toClass === 'inactive') { + updateData.resolved_at = now; + } + + if (fromClass === 'inactive' && toClass !== 'inactive') { + updateData.resolved_at = null; + } + } + } + if (parsed.owner_id !== undefined) updateData.owner_id = parsed.owner_id; updateData.updated_at = new Date(); const [updated] = await db.update(tickets) @@ -198,6 +397,26 @@ export function createTicketsRouter(db: Db): Hono { throw new HTTPException(404, { message: 'Ticket not found' }); } + if (parsed.status) { + const queue = await db.query.queues.findFirst({ + where: eq(queues.id, ticket.queue_id), + }); + + if (queue?.lifecycle_id) { + const lifecycle = await db.query.lifecycles.findFirst({ + where: eq(lifecycles.id, queue.lifecycle_id), + }); + + if (lifecycle) { + const lifecycleDef = lifecycle.definition as LifecycleDefinition; + const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, parsed.status); + if (!result.valid) { + throw new HTTPException(422, { message: result.error ?? 'Invalid transition' }); + } + } + } + } + const txList: any[] = []; if (parsed.status && parsed.status !== ticket.status) { @@ -266,5 +485,93 @@ export function createTicketsRouter(db: Db): Hono { return c.json(tx, 201); }); + // PATCH /:id/custom-fields/:fieldId — set or clear a custom field value + router.patch('/:id/custom-fields/:fieldId', async (c) => { + const id = Number(c.req.param('id')); + const fieldId = c.req.param('fieldId'); + const body = await c.req.json(); + const value = typeof body.value === 'string' ? body.value.trim() : ''; + + const ticket = await db.query.tickets.findFirst({ + where: eq(tickets.id, id), + }); + + if (!ticket) { + throw new HTTPException(404, { message: 'Ticket not found' }); + } + + const assignment = await db.query.queueCustomFields.findFirst({ + where: and( + eq(queueCustomFields.queue_id, ticket.queue_id), + eq(queueCustomFields.custom_field_id, fieldId), + ), + }); + + if (!assignment) { + throw new HTTPException(422, { message: 'Custom field is not assigned to this ticket queue' }); + } + + const field = await db.query.customFields.findFirst({ + where: eq(customFields.id, fieldId), + }); + + if (!field) { + throw new HTTPException(404, { message: 'Custom field not found' }); + } + + if (value && Array.isArray(field.values) && field.values.length > 0) { + const allowed = new Set(field.values.map((option) => String(option))); + if (!allowed.has(value)) { + throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` }); + } + } + + if (value && field.pattern) { + const regex = new RegExp(field.pattern); + if (!regex.test(value)) { + throw new HTTPException(422, { message: `${field.name}: value does not match the required pattern` }); + } + } + + const existing = await db.query.customFieldValues.findMany({ + where: and( + eq(customFieldValues.ticket_id, id), + eq(customFieldValues.custom_field_id, fieldId), + ), + }); + const oldValue = existing.map((item) => item.value).join(', '); + + await db.delete(customFieldValues).where(and( + eq(customFieldValues.ticket_id, id), + eq(customFieldValues.custom_field_id, fieldId), + )); + + if (value) { + await db.insert(customFieldValues).values({ + ticket_id: id, + custom_field_id: fieldId, + value, + }); + } + + await db.update(tickets) + .set({ updated_at: new Date() } as any) + .where(eq(tickets.id, id)); + + const [tx] = await db.insert(transactions).values({ + ticket_id: id, + transaction_type: 'CustomFieldChange', + field: field.key, + old_value: oldValue || null, + new_value: value || null, + creator_id: '00000000-0000-0000-0000-000000000000', + }).returning(); + + const prepared = await scripEngine.prepare(id, [tx] as any); + await scripEngine.commit(prepared); + + return c.json(tx, 200); + }); + return router; } diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..c592646 --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono'; +import { asc } from 'drizzle-orm'; +import type { Db } from '../db/index.ts'; +import { users } from '../db/schema.ts'; + +export function createUsersRouter(db: Db): Hono { + const router = new Hono(); + + router.get('/', async (c) => { + const result = await db.query.users.findMany({ + orderBy: asc(users.username), + }); + return c.json(result); + }); + + return router; +}