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:
Gjermund Høsøien Wiggen
2026-06-09 13:04:10 +02:00
parent affbbdaa46
commit c6c5272e50
6 changed files with 297 additions and 72 deletions

View File

@@ -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