import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { asc, eq } from 'drizzle-orm'; import type { Db } from '../db/index.ts'; import { dashboards, dashboardWidgets, tickets, customFieldValues, customFields, lifecycles, queues, views, } from '../db/schema.ts'; function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string { if (def.statuses.initial.includes(status)) return 'initial'; if (def.statuses.active.includes(status)) return 'active'; if (def.statuses.inactive.includes(status)) return 'inactive'; return 'unknown'; } export function createDashboardsRouter(db: Db): Hono { const router = new Hono(); // ── Dashboards CRUD ── router.get('/', async (c) => { const result = await db.query.dashboards.findMany({ orderBy: asc(dashboards.name), }); return c.json(result); }); router.post('/', async (c) => { const body = await c.req.json(); const name = String(body.name ?? '').trim(); if (!name) { throw new HTTPException(400, { message: 'name is required' }); } const [dashboard] = await db.insert(dashboards).values({ name, description: body.description ?? null, team_id: body.team_id || null, layout: body.layout ?? [], is_default: body.is_default ?? false, }).returning(); if (!dashboard) { throw new HTTPException(500, { message: 'Failed to create dashboard' }); } return c.json(dashboard, 201); }); router.get('/:id', async (c) => { const id = c.req.param('id'); const dashboard = await db.query.dashboards.findFirst({ where: eq(dashboards.id, id), }); if (!dashboard) { throw new HTTPException(404, { message: 'Dashboard not found' }); } const widgets = await db.query.dashboardWidgets.findMany({ where: eq(dashboardWidgets.dashboard_id, id), orderBy: asc(dashboardWidgets.created_at), }); return c.json({ ...dashboard, widgets }); }); router.patch('/:id', async (c) => { const id = c.req.param('id'); const body = await c.req.json(); const existing = await db.query.dashboards.findFirst({ where: eq(dashboards.id, id), }); if (!existing) { throw new HTTPException(404, { message: 'Dashboard not found' }); } const updateData: Partial = {}; if (body.name !== undefined) updateData.name = String(body.name).trim(); if (body.description !== undefined) updateData.description = body.description ?? null; if (body.layout !== undefined) updateData.layout = body.layout; if (body.team_id !== undefined) updateData.team_id = body.team_id || null; if (body.is_default !== undefined) { updateData.is_default = body.is_default; if (body.is_default) { await db.update(dashboards) .set({ is_default: false }) .where(eq(dashboards.is_default, true)); } } const [updated] = await db.update(dashboards) .set(updateData) .where(eq(dashboards.id, id)) .returning(); return c.json(updated); }); router.delete('/:id', async (c) => { const id = c.req.param('id'); const existing = await db.query.dashboards.findFirst({ where: eq(dashboards.id, id), }); if (!existing) { throw new HTTPException(404, { message: 'Dashboard not found' }); } await db.delete(dashboards).where(eq(dashboards.id, id)); return c.json({ ok: true }); }); // ── Widgets CRUD ── router.get('/:id/widgets', async (c) => { const dashboardId = c.req.param('id'); const result = await db.query.dashboardWidgets.findMany({ where: eq(dashboardWidgets.dashboard_id, dashboardId), orderBy: asc(dashboardWidgets.created_at), }); return c.json(result); }); router.post('/:id/widgets', async (c) => { const dashboardId = c.req.param('id'); const dashboard = await db.query.dashboards.findFirst({ where: eq(dashboards.id, dashboardId), }); if (!dashboard) { throw new HTTPException(404, { message: 'Dashboard not found' }); } const body = await c.req.json(); const title = String(body.title ?? 'Widget').trim(); const widgetType = String(body.widget_type ?? 'count').trim(); const viewId = String(body.view_id ?? '').trim(); if (!viewId) { throw new HTTPException(400, { message: 'view_id is required' }); } const [widget] = await db.insert(dashboardWidgets).values({ dashboard_id: dashboardId, view_id: viewId, title, widget_type: widgetType, position: body.position ?? { x: 0, y: 0, w: 4, h: 2 }, config: body.config ?? {}, }).returning(); if (!widget) { throw new HTTPException(500, { message: 'Failed to create widget' }); } return c.json(widget, 201); }); router.patch('/:id/widgets/:widgetId', async (c) => { const widgetId = c.req.param('widgetId'); const body = await c.req.json(); const existing = await db.query.dashboardWidgets.findFirst({ where: eq(dashboardWidgets.id, widgetId), }); if (!existing) { throw new HTTPException(404, { message: 'Widget not found' }); } const updateData: Partial = {}; if (body.title !== undefined) updateData.title = String(body.title).trim(); if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type); if (body.position !== undefined) updateData.position = body.position; if (body.config !== undefined) updateData.config = body.config; const [updated] = await db.update(dashboardWidgets) .set(updateData) .where(eq(dashboardWidgets.id, widgetId)) .returning(); return c.json(updated); }); router.delete('/:id/widgets/:widgetId', async (c) => { const widgetId = c.req.param('widgetId'); const existing = await db.query.dashboardWidgets.findFirst({ where: eq(dashboardWidgets.id, widgetId), }); if (!existing) { throw new HTTPException(404, { message: 'Widget not found' }); } await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId)); return c.json({ ok: true }); }); // ── Widget data endpoint ── router.get('/:id/widgets/:widgetId/data', async (c) => { const widgetId = c.req.param('widgetId'); const widget = await db.query.dashboardWidgets.findFirst({ where: eq(dashboardWidgets.id, widgetId), }); if (!widget) { throw new HTTPException(404, { message: 'Widget not found' }); } const view = await db.query.views.findFirst({ where: eq(views.id, widget.view_id), }); if (!view) { return c.json({ error: 'View not found' }, 404); } // Apply saved view filters const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[]; let result = await db.query.tickets.findMany({ orderBy: asc(tickets.created_at), }); for (const f of savedFilters) { if (f.field === 'status') { result = result.filter((t) => t.status === f.value); } else if (f.field === 'queue') { result = result.filter((t) => t.queue_id === f.value); } else if (f.field === 'owner') { result = f.value === 'unassigned' ? result.filter((t) => !t.owner_id) : result.filter((t) => t.owner_id === f.value); } else if (f.field.startsWith('cf.')) { const cfKey = f.field.slice(3); const ticketIds = result.map((t) => t.id); if (ticketIds.length > 0) { const cfValues = await db.query.customFieldValues.findMany({ where: (table, { and, inArray, eq }) => and( inArray(table.ticket_id, ticketIds), eq(table.value, f.value), ), }); const matchingIds = new Set(cfValues.map((v) => v.ticket_id)); // Also find the field ID for the key const cfField = await db.query.customFields.findFirst({ where: eq(customFields.key, cfKey), }); if (cfField) { const cfValuesForField = await db.query.customFieldValues.findMany({ where: (table, { and, inArray, eq }) => and( inArray(table.ticket_id, ticketIds), eq(table.custom_field_id, cfField.id), eq(table.value, f.value), ), }); const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id)); result = result.filter((t) => matchSet.has(t.id)); } else { result = result.filter((t) => matchingIds.has(t.id)); } } } } // Widget-level filters override or add to view filters const widgetFilters = (widget.config as Record)?.filters as Array<{ field: string; operator: string; value: string }> | undefined; if (widgetFilters) { for (const f of widgetFilters) { if (f.field === 'status') { if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value); else result = result.filter((t) => t.status === f.value); } else if (f.field === 'queue') { if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value); else result = result.filter((t) => t.queue_id === f.value); } else if (f.field === 'owner') { if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id); else result = result.filter((t) => t.owner_id === f.value); } else if (f.field === 'q') { const q = f.value.toLowerCase(); result = result.filter((t) => t.subject.toLowerCase().includes(q) || String(t.id).includes(q) || (queueName.get(t.queue_id) ?? '').toLowerCase().includes(q) ); } } } const limit = (widget.config as Record)?.limit as number ?? 5; // Find lifecycle for status classification const queueIds = [...new Set(result.map((r) => r.queue_id))]; const queueRecords = queueIds.length > 0 ? await db.query.queues.findMany({ where: (table, { inArray }) => inArray(table.id, queueIds), }) : []; const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[]; const lifecycleRecords = lifecycleIds.length > 0 ? await db.query.lifecycles.findMany({ where: (table, { inArray }) => inArray(table.id, lifecycleIds), }) : []; const lifecycleByQueue = new Map(); for (const qr of queueRecords) { if (qr.lifecycle_id) { const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id); if (lc) lifecycleByQueue.set(qr.id, lc.definition as any); } } // Get owner usernames const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[]; const ownerUsers = ownerIds.length > 0 ? await db.query.users.findMany({ where: (table, { inArray }) => inArray(table.id, ownerIds), }) : []; const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username])); // Get queue names const queueName = new Map(queueRecords.map((q) => [q.id, q.name])); switch (widget.widget_type) { case 'count': { return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id }); } case 'ticket_list': { const slice = result.slice(0, limit).map((ticket) => ({ id: ticket.id, subject: ticket.subject, status: ticket.status, owner_id: ticket.owner_id, owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null, queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8), updated_at: ticket.updated_at?.toISOString(), })); return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id }); } case 'status_chart': { const counts: Record = {}; for (const ticket of result) { counts[ticket.status] = (counts[ticket.status] ?? 0) + 1; } return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id }); } case 'my_tickets': { const authUser = c.get('user'); const myTickets = result.filter((t) => t.owner_id === authUser.userId); return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id }); } case 'trend_chart': { const period = ((widget.config as Record)?.period as string) ?? 'day'; const days = (widget.config as Record)?.days as number ?? 30; const trendField = ((widget.config as Record)?.field as string) ?? 'created_at'; const now = new Date(); const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); const filtered = result.filter((t) => { const d = trendField === 'updated_at' ? t.updated_at : t.created_at; return d && new Date(d) >= start; }); const points: Record = {}; for (const t of filtered) { const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!); let key: string; if (period === 'week') { const weekStart = new Date(d); weekStart.setDate(d.getDate() - d.getDay()); key = weekStart.toISOString().slice(0, 10); } else { key = d.toISOString().slice(0, 10); // YYYY-MM-DD } points[key] = (points[key] ?? 0) + 1; } return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id }); } case 'overdue': { const dateFieldKey = (widget.config as Record)?.field_key as string; const now = new Date(); const overdue = result.filter((t) => { if (!dateFieldKey) { // No specific field — check if any inactive-adjacent status const lc = lifecycleByQueue.get(t.queue_id); if (lc) { const inactive = lc.statuses.inactive; if (inactive.includes(t.status)) return false; // already resolved } // Check if updated_at is older than 7 days const updated = t.updated_at ? new Date(t.updated_at) : new Date(0); return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000; } return false; // Would need CF value lookup for date field }); return c.json({ type: 'overdue', total: overdue.length, title: widget.title, view_id: view.id }); } case 'grouped_counts': { const groupBy = (widget.config as Record)?.group_by as string ?? 'owner'; const groups: Record = {}; if (groupBy === 'owner') { for (const ticket of result) { const label = ticket.owner_id ? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8)) : 'Unassigned'; groups[label] = (groups[label] ?? 0) + 1; } } else if (groupBy === 'queue') { for (const ticket of result) { const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8); groups[label] = (groups[label] ?? 0) + 1; } } else if (groupBy.startsWith('cf.')) { const cfKey = groupBy.slice(3); const cfField = await db.query.customFields.findFirst({ where: eq(customFields.key, cfKey), }); if (cfField) { const ticketIds = result.map((t) => t.id); const cfValues = ticketIds.length > 0 ? await db.query.customFieldValues.findMany({ where: (table, { and, inArray, eq }) => and( inArray(table.ticket_id, ticketIds), eq(table.custom_field_id, cfField.id), ), }) : []; for (const v of cfValues) { groups[v.value] = (groups[v.value] ?? 0) + 1; } } } return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id }); } default: return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id }); } }); return router; }