feat: auth system, scrip scheduler, UI widgets, and new API routes

- Add session-based authentication (login page, middleware, auth context)
- Add cron-like scrip scheduler for time-based conditions
- Add layout builder, scrip wizard, searchable select components
- Add trend chart widget for dashboards
- Add notifications, attachments, queue-permissions API routes
- Add seed-users script
- Update schema with 10 new migrations (0008-0017)
- Apply redesign: Linear-inspired dark theme, conversation-centric UI
- Gitignore runtime data directory

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-15 20:42:17 +02:00
parent 1d4dc38d06
commit 70f0924d4b
59 changed files with 21795 additions and 321 deletions

View File

@@ -276,6 +276,30 @@ export function createDashboardsRouter(db: Db): Hono {
}
}
// Widget-level filters override or add to view filters
const widgetFilters = (widget.config as Record<string, unknown>)?.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<string, unknown>)?.limit as number ?? 5;
// Find lifecycle for status classification
@@ -337,6 +361,61 @@ export function createDashboardsRouter(db: Db): Hono {
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<string, unknown>)?.period as string) ?? 'day';
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
const trendField = ((widget.config as Record<string, unknown>)?.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<string, number> = {};
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<string, unknown>)?.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<string, unknown>)?.group_by as string ?? 'owner';
const groups: Record<string, number> = {};