From c6c5272e50e0176f0bca4cb06b348b334eaba216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gjermund=20H=C3=B8s=C3=B8ien=20Wiggen?= Date: Tue, 9 Jun 2026 13:04:10 +0200 Subject: [PATCH] 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 --- src/routes/tickets.ts | 120 +++++++++------------ src/routes/users.ts | 63 ++++++++++- web/src/app/admin/page-content.tsx | 151 ++++++++++++++++++++++++++- web/src/app/dashboards/[id]/page.tsx | 10 +- web/src/app/page.tsx | 7 +- web/src/lib/api.ts | 18 ++++ 6 files changed, 297 insertions(+), 72 deletions(-) diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts index 5c89f65..e8a8696 100644 --- a/src/routes/tickets.ts +++ b/src/routes/tickets.ts @@ -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[] = []; 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(); - - 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 diff --git a/src/routes/users.ts b/src/routes/users.ts index c592646..fa8be72 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -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 = {}; + 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; } diff --git a/web/src/app/admin/page-content.tsx b/web/src/app/admin/page-content.tsx index d0286a7..c7d05c9 100644 --- a/web/src/app/admin/page-content.tsx +++ b/web/src/app/admin/page-content.tsx @@ -11,6 +11,7 @@ import { Settings2Icon, SlidersHorizontalIcon, Trash2Icon, + UsersIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -60,8 +61,12 @@ import { unassignQueueCustomField, createCustomField, updateCustomField, + getUsers, + createUser, + updateUser, + deleteUser, } from "@/lib/api"; -import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview } from "@/lib/types"; +import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User } from "@/lib/types"; import { cn } from "@/lib/utils"; function AdminHeader() { @@ -149,6 +154,10 @@ export default function AdminPage() { Custom fields + + + Users +
@@ -167,6 +176,9 @@ export default function AdminPage() { + + +
@@ -2011,6 +2023,143 @@ Location: {{custom_fields.location}}`; ); } +function UsersTab() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const fetchUsers = useCallback(async () => { + setLoading(true); + setError(null); + const { data, error } = await getUsers(); + if (error) setError(error); + else setUsers(data ?? []); + setLoading(false); + }, []); + + useEffect(() => { + void Promise.resolve().then(() => fetchUsers()); + }, [fetchUsers]); + + const resetForm = () => { + setEditingId(null); + setUsername(""); + setEmail(""); + setSaveError(null); + }; + + const handleSave = async () => { + if (!username.trim()) return; + setSaving(true); + setSaveError(null); + const payload = { username: username.trim(), email: email.trim() || null }; + const { error } = editingId + ? await updateUser(editingId, payload) + : await createUser(payload); + setSaving(false); + if (error) { setSaveError(error); return; } + resetForm(); + await fetchUsers(); + }; + + const handleDelete = async (id: string) => { + setDeletingId(id); + await deleteUser(id); + if (editingId === id) resetForm(); + await fetchUsers(); + setDeletingId(null); + }; + + return ( +
+
+
+

Users ({users.length})

+

Create, update, and manage user accounts for ticket assignment.

+
+ +
+ + {loading ? : ( +
+ +
+
+
+ {editingId ? "Editing user" : "New user"} +
+

{username.trim() || "Untitled"}

+
+
+
+ + setUsername(e.target.value)} /> +
+
+ + setEmail(e.target.value)} /> +
+ {saveError &&
{saveError}
} +
+ + +
+
+
+
+ )} +
+ ); +} + function CustomFieldsTab() { const [fields, setFields] = useState([]); const [queues, setQueues] = useState([]); diff --git a/web/src/app/dashboards/[id]/page.tsx b/web/src/app/dashboards/[id]/page.tsx index d0044c3..ce679d9 100644 --- a/web/src/app/dashboards/[id]/page.tsx +++ b/web/src/app/dashboards/[id]/page.tsx @@ -97,7 +97,15 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string const handleAddWidget = async () => { if (!addViewId || !addTitle.trim()) return; setAdding(true); - const pos = { x: 0, y: widgets.length, w: 4, h: 2 }; + // Smart positioning: fill a 3-column grid (4 units each in 12-col grid) + const COLS = 3; const W = 4; const H = 2; + const occupied = new Set(widgets.map((w) => `${w.position.x},${w.position.y}`)); + let x = 0; let y = 0; + while (occupied.has(`${x},${y}`)) { + x += W; + if (x >= COLS * W) { x = 0; y += H; } + } + const pos = { x, y, w: W, h: H }; const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {}; const { data, error } = await createWidget(id, { view_id: addViewId, diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 2102b0f..07fcce0 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1110,10 +1110,15 @@ function TicketWorkbenchContent() { ...current, [field.id]: event.target.value, }))} - placeholder={field.pattern ? field.pattern : "Optional value"} + placeholder={field.pattern ? `Pattern: ${field.pattern}` : "Optional value"} className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring" /> )} + {field.pattern && ( + + Must match: {field.pattern} + + )} ); }) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f9da707..5bcbf6c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -95,6 +95,24 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string | return request("/users"); } +export async function createUser(data: { + username: string; + email?: string | null; +}): Promise<{ data: User | null; error: string | null }> { + return request("/users", { method: "POST", body: JSON.stringify(data) }); +} + +export async function updateUser(id: string, data: { + username?: string; + email?: string | null; +}): Promise<{ data: User | null; error: string | null }> { + return request(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }); +} + +export async function deleteUser(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { + return request<{ ok: boolean }>(`/users/${id}`, { method: "DELETE" }); +} + export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> { return request("/queues", { method: "POST", body: JSON.stringify(data) }); }