feat: SQL filtering, Users admin tab, dashboard polish
- Move ticket filtering from in-memory to SQL WHERE clauses (queue_id, status, owner use Drizzle eq/isNull; text search uses ilike; custom field filters use EXISTS subqueries) - Add limit param to GET /tickets - Add POST/PATCH/DELETE /users routes - Add Users tab to admin page with create/edit/delete - Smart widget positioning in dashboard (3-column grid fill) - Show pattern hint below CF inputs in New Ticket dialog Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields } from '../db/schema.ts';
|
||||
import { and, eq, asc } from 'drizzle-orm';
|
||||
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||
import { ScripEngine } from '../scrip/engine.ts';
|
||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||
@@ -26,94 +26,78 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
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 query = c.req.query('q')?.trim() ?? '';
|
||||
const limit = c.req.query('limit') ? Number(c.req.query('limit')) : undefined;
|
||||
const cfFilters = [...params.entries()]
|
||||
.filter(([key, value]) => key.startsWith('cf.') && value.trim())
|
||||
.map(([key, value]) => ({
|
||||
key: key.slice(3),
|
||||
value: value.trim().toLowerCase(),
|
||||
value: value.trim(),
|
||||
}));
|
||||
|
||||
let result = await db.query.tickets.findMany({
|
||||
orderBy: asc(tickets.created_at),
|
||||
});
|
||||
// Build SQL WHERE conditions
|
||||
const conditions: ReturnType<typeof eq>[] = [];
|
||||
|
||||
if (queueId) {
|
||||
result = result.filter((ticket) => ticket.queue_id === queueId);
|
||||
conditions.push(eq(tickets.queue_id, queueId));
|
||||
}
|
||||
if (status) {
|
||||
result = result.filter((ticket) => ticket.status === status);
|
||||
conditions.push(eq(tickets.status, status));
|
||||
}
|
||||
if (ownerId) {
|
||||
result = ownerId === 'unassigned'
|
||||
? result.filter((ticket) => !ticket.owner_id)
|
||||
: result.filter((ticket) => ticket.owner_id === ownerId);
|
||||
conditions.push(
|
||||
ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.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);
|
||||
}
|
||||
// Text search: push to SQL via ilike on ticket columns + queue name join
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(tickets.subject, pattern),
|
||||
ilike(tickets.status, pattern),
|
||||
sql`${tickets.id}::text ILIKE ${pattern}`
|
||||
)!
|
||||
);
|
||||
// Queue name search requires join — keep as post-filter
|
||||
}
|
||||
|
||||
// Custom field filters: use EXISTS subquery
|
||||
for (const cf of cfFilters) {
|
||||
conditions.push(
|
||||
exists(
|
||||
db.select({ n: sql`1` })
|
||||
.from(customFieldValues)
|
||||
.innerJoin(customFields, eq(customFieldValues.custom_field_id, customFields.id))
|
||||
.where(
|
||||
and(
|
||||
eq(customFieldValues.ticket_id, tickets.id),
|
||||
eq(customFields.key, cf.key),
|
||||
eq(customFieldValues.value, cf.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db.query.tickets.findMany({
|
||||
where: conditions.length > 0 ? and(...conditions) : undefined,
|
||||
orderBy: asc(tickets.created_at),
|
||||
limit,
|
||||
});
|
||||
|
||||
// Post-filter for queue name text search (requires in-memory join)
|
||||
let filtered = result;
|
||||
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)
|
||||
)
|
||||
);
|
||||
});
|
||||
filtered = result.filter((ticket) =>
|
||||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
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(filtered);
|
||||
});
|
||||
|
||||
// POST / — create ticket
|
||||
|
||||
Reference in New Issue
Block a user