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

View File

@@ -1,5 +1,6 @@
import { Hono } from 'hono';
import { asc } from 'drizzle-orm';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { users } from '../db/schema.ts';
@@ -13,5 +14,65 @@ export function createUsersRouter(db: Db): Hono {
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const username = String(body.username ?? '').trim();
const email = body.email ? String(body.email).trim() : null;
if (!username) {
throw new HTTPException(400, { message: 'username is required' });
}
const [user] = await db.insert(users).values({
username,
email,
}).returning();
if (!user) {
throw new HTTPException(500, { message: 'Failed to create user' });
}
return c.json(user, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
const updateData: Partial<typeof users.$inferInsert> = {};
if (body.username !== undefined) updateData.username = String(body.username).trim();
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
const [updated] = await db.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'User not found' });
}
await db.delete(users).where(eq(users.id, id));
return c.json({ ok: true });
});
return router;
}