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 { 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, queueCustomFields } from '../db/schema.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 { 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';
@@ -26,94 +26,78 @@ export function createTicketsRouter(db: Db): Hono {
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 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()] const cfFilters = [...params.entries()]
.filter(([key, value]) => key.startsWith('cf.') && value.trim()) .filter(([key, value]) => key.startsWith('cf.') && value.trim())
.map(([key, value]) => ({ .map(([key, value]) => ({
key: key.slice(3), key: key.slice(3),
value: value.trim().toLowerCase(), value: value.trim(),
})); }));
let result = await db.query.tickets.findMany({ // Build SQL WHERE conditions
orderBy: asc(tickets.created_at), const conditions: ReturnType<typeof eq>[] = [];
});
if (queueId) { if (queueId) {
result = result.filter((ticket) => ticket.queue_id === queueId); conditions.push(eq(tickets.queue_id, queueId));
} }
if (status) { if (status) {
result = result.filter((ticket) => ticket.status === status); conditions.push(eq(tickets.status, status));
} }
if (ownerId) { if (ownerId) {
result = ownerId === 'unassigned' conditions.push(
? result.filter((ticket) => !ticket.owner_id) ownerId === 'unassigned' ? isNull(tickets.owner_id) : eq(tickets.owner_id, ownerId)
: result.filter((ticket) => ticket.owner_id === ownerId); );
} }
const needsCustomFields = query || cfFilters.length > 0; // Text search: push to SQL via ilike on ticket columns + queue name join
const valuesByTicket = new Map<number, { fieldId: string; fieldKey: string; fieldName: string; value: string }[]>(); if (query) {
const pattern = `%${query}%`;
if (needsCustomFields && result.length > 0) { conditions.push(
const ticketIds = result.map((ticket) => ticket.id); or(
const cfValues = await db.query.customFieldValues.findMany({ ilike(tickets.subject, pattern),
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds), ilike(tickets.status, pattern),
}); sql`${tickets.id}::text ILIKE ${pattern}`
const fieldIds = [...new Set(cfValues.map((value) => value.custom_field_id))]; )!
const fields = fieldIds.length > 0 );
? await db.query.customFields.findMany({ // Queue name search requires join — keep as post-filter
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);
}
} }
// 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) { if (query) {
const queuesForSearch = await db.query.queues.findMany(); const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name])); const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
result = result.filter((ticket) => { filtered = result.filter((ticket) =>
const customFields = valuesByTicket.get(ticket.id) ?? []; (queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
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) { return c.json(filtered);
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);
}); });
// POST / — create ticket // POST / — create ticket

View File

@@ -1,5 +1,6 @@
import { Hono } from 'hono'; 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 type { Db } from '../db/index.ts';
import { users } from '../db/schema.ts'; import { users } from '../db/schema.ts';
@@ -13,5 +14,65 @@ export function createUsersRouter(db: Db): Hono {
return c.json(result); 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; return router;
} }

View File

@@ -11,6 +11,7 @@ import {
Settings2Icon, Settings2Icon,
SlidersHorizontalIcon, SlidersHorizontalIcon,
Trash2Icon, Trash2Icon,
UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -60,8 +61,12 @@ import {
unassignQueueCustomField, unassignQueueCustomField,
createCustomField, createCustomField,
updateCustomField, updateCustomField,
getUsers,
createUser,
updateUser,
deleteUser,
} from "@/lib/api"; } 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"; import { cn } from "@/lib/utils";
function AdminHeader() { function AdminHeader() {
@@ -149,6 +154,10 @@ export default function AdminPage() {
<SlidersHorizontalIcon className="h-4 w-4" /> <SlidersHorizontalIcon className="h-4 w-4" />
Custom fields Custom fields
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="users" className="px-3">
<UsersIcon className="h-4 w-4" />
Users
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6"> <div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
@@ -167,6 +176,9 @@ export default function AdminPage() {
<TabsContent value="customfields" className="m-0"> <TabsContent value="customfields" className="m-0">
<CustomFieldsTab /> <CustomFieldsTab />
</TabsContent> </TabsContent>
<TabsContent value="users" className="m-0">
<UsersTab />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
</div> </div>
@@ -2011,6 +2023,143 @@ Location: {{custom_fields.location}}`;
); );
} }
function UsersTab() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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 (
<section className="overflow-hidden rounded-md border border-border bg-card/82 shadow-sm">
<div className="flex flex-col gap-3 border-b border-border bg-muted/35 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Users ({users.length})</h2>
<p className="mt-0.5 text-sm text-muted-foreground">Create, update, and manage user accounts for ticket assignment.</p>
</div>
<Button size="sm" onClick={resetForm} className="h-8 bg-primary">
<PlusIcon className="h-4 w-4" />
New user
</Button>
</div>
<ErrorBanner error={error} />
{loading ? <LoadingState /> : (
<div className="grid min-h-[400px] lg:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-w-0 border-b border-border bg-background/70 lg:border-b-0 lg:border-r">
<div className="px-4 py-3">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">User directory</div>
</div>
<div className="max-h-[400px] overflow-auto">
{users.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No users yet.</div>
) : (
users.map((user) => (
<div
key={user.id}
className={cn(
"flex items-center justify-between border-b border-border/50 px-4 py-2.5 transition-colors hover:bg-accent/40",
editingId === user.id && "bg-primary/10"
)}
>
<button
type="button"
onClick={() => { setEditingId(user.id); setUsername(user.username); setEmail(user.email ?? ""); setSaveError(null); }}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-sm font-medium text-foreground">{user.username}</div>
<div className="truncate text-xs text-muted-foreground">{user.email ?? "No email"}</div>
</button>
<button
type="button"
onClick={() => void handleDelete(user.id)}
disabled={deletingId === user.id}
className="ml-2 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground/60 transition-all hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
title="Delete user"
>
<Trash2Icon className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
</aside>
<div className="min-w-0 p-4">
<div className="mb-4 border-b border-border pb-4">
<div className="text-[11px] font-semibold uppercase text-muted-foreground">
{editingId ? "Editing user" : "New user"}
</div>
<h3 className="mt-0.5 text-lg font-semibold text-foreground">{username.trim() || "Untitled"}</h3>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="u-username">Username</Label>
<Input id="u-username" placeholder="gjermund" value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="u-email">Email</Label>
<Input id="u-email" type="email" placeholder="gjermund@example.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
{saveError && <div className="text-sm text-destructive">{saveError}</div>}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
<Button onClick={() => void handleSave()} disabled={!username.trim() || saving} size="sm" className="bg-primary">
{saving ? "Saving..." : editingId ? "Save changes" : "Create user"}
</Button>
</div>
</div>
</div>
</div>
)}
</section>
);
}
function CustomFieldsTab() { function CustomFieldsTab() {
const [fields, setFields] = useState<CustomField[]>([]); const [fields, setFields] = useState<CustomField[]>([]);
const [queues, setQueues] = useState<Queue[]>([]); const [queues, setQueues] = useState<Queue[]>([]);

View File

@@ -97,7 +97,15 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
const handleAddWidget = async () => { const handleAddWidget = async () => {
if (!addViewId || !addTitle.trim()) return; if (!addViewId || !addTitle.trim()) return;
setAdding(true); 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 config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
const { data, error } = await createWidget(id, { const { data, error } = await createWidget(id, {
view_id: addViewId, view_id: addViewId,

View File

@@ -1110,10 +1110,15 @@ function TicketWorkbenchContent() {
...current, ...current,
[field.id]: event.target.value, [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" className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
/> />
)} )}
{field.pattern && (
<span className="text-[11px] text-muted-foreground">
Must match: <code className="rounded bg-muted px-1 font-mono">{field.pattern}</code>
</span>
)}
</label> </label>
); );
}) })

View File

@@ -95,6 +95,24 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
return request<User[]>("/users"); return request<User[]>("/users");
} }
export async function createUser(data: {
username: string;
email?: string | null;
}): Promise<{ data: User | null; error: string | null }> {
return request<User>("/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<User>(`/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 }> { export async function createQueue(data: { name: string; description?: string | null; lifecycle_id?: string | null }): Promise<{ data: Queue | null; error: string | null }> {
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) }); return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
} }