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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||
Custom fields
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="px-3">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
Users
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<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">
|
||||
<CustomFieldsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="users" className="m-0">
|
||||
<UsersTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</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() {
|
||||
const [fields, setFields] = useState<CustomField[]>([]);
|
||||
const [queues, setQueues] = useState<Queue[]>([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Must match: <code className="rounded bg-muted px-1 font-mono">{field.pattern}</code>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -95,6 +95,24 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
|
||||
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 }> {
|
||||
return request<Queue>("/queues", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user