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