import { Hono } from 'hono'; import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm'; import { z } from 'zod'; import { zValidator } from '@hono/zod-validator'; import { db } from './db'; import { topics, users, subscriptions, alertTasks, topicGroupChats, knownGroupChats } from './db/schema'; import { requireAuth, requireAdmin, AuthSession } from './middleware'; const api = new Hono<{ Variables: { session: AuthSession } }>(); const topicSchema = z.object({ name: z.string().min(1), slug: z.string().min(1), description: z.string().optional(), }); const groupBindingSchema = z.object({ chatId: z.string().min(1), name: z.string().min(1), }); const userSchema = z.object({ name: z.string().min(1), feishuUserId: z.string().min(1), email: z.string().email().optional().or(z.literal('')), }); // --- Topics --- // --- Topics --- api.get('/topics', requireAuth, async (c) => { const session = c.get('session'); const isAdmin = session.isAdmin; const currentUserId = session.id; const allTopics = await db.query.topics.findMany({ where: eq(topics.status, 'approved'), with: { creator: true, approver: true, subscriptions: { where: (subscriptions, { eq }) => isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined), with: { user: true } } } }); return c.json(allTopics); }); api.get('/topics/requests', requireAdmin, async (c) => { const requests = await db.query.topics.findMany({ where: eq(topics.status, 'pending'), with: { creator: true } }); return c.json(requests); }); api.get('/topics/all', requireAdmin, async (c) => { const allTopics = await db.query.topics.findMany({ with: { creator: true, approver: true, subscriptions: true }, orderBy: [desc(topics.createdAt)] }); return c.json(allTopics); }); api.get('/topics/my-requests', requireAuth, async (c) => { const session = c.get('session'); const requests = await db.query.topics.findMany({ where: eq(topics.createdBy, session.id), orderBy: [desc(topics.createdAt)], with: { approver: true, } }); return c.json(requests); }); api.post('/topics/:id/approve', requireAdmin, async (c) => { const id = c.req.param('id'); const session = c.get('session'); const result = await db.update(topics) .set({ status: 'approved', approvedBy: session.id }) .where(eq(topics.id, id)) .returning(); return c.json(result[0]); }); api.post('/topics/:id/reject', requireAdmin, async (c) => { const id = c.req.param('id'); const result = await db.update(topics) .set({ status: 'rejected' }) .where(eq(topics.id, id)) .returning(); return c.json(result[0]); }); // Only admins can create topics // Authenticated users can create topics (requests) api.post('/topics', requireAuth, zValidator('json', topicSchema), async (c) => { const body = c.req.valid('json'); const session = c.get('session'); const status = session.isAdmin ? 'approved' : 'pending'; const result = await db.insert(topics).values({ ...body, status, createdBy: session.id, approvedBy: session.isAdmin ? session.id : null, }).returning(); return c.json(result[0]); }); // Only admins can update topics api.put('/topics/:id', requireAdmin, zValidator('json', topicSchema.partial()), async (c) => { const id = c.req.param('id'); const body = c.req.valid('json'); const result = await db.update(topics).set(body).where(eq(topics.id, id)).returning(); return c.json(result[0]); }); // Only admins can delete topics api.delete('/topics/:id', requireAdmin, async (c) => { const id = c.req.param('id'); await db.delete(topics).where(eq(topics.id, id)); return c.json({ success: true }); }); // --- Users --- api.get('/users', requireAdmin, async (c) => { const allUsers = await db.query.users.findMany({ with: { subscriptions: { with: { topic: true } } } }); return c.json(allUsers); }); api.post('/users', requireAdmin, zValidator('json', userSchema), async (c) => { const body = c.req.valid('json'); const result = await db.insert(users).values(body).returning(); return c.json(result[0]); }); api.put('/users/:id', requireAdmin, zValidator('json', userSchema.partial()), async (c) => { const id = c.req.param('id'); const body = c.req.valid('json'); const result = await db.update(users).set(body).where(eq(users.id, id)).returning(); return c.json(result[0]); }); api.delete('/users/:id', requireAdmin, async (c) => { const id = c.req.param('id'); await db.delete(users).where(eq(users.id, id)); return c.json({ success: true }); }); // --- Subscriptions --- // --- Subscriptions --- // Users can subscribe themselves or admins can subscribe anyone api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => { const { topicId, userId } = c.req.param(); const session = c.get('session'); // Check if user is subscribing themselves or is an admin if (session.id !== userId && !session.isAdmin) { return c.json({ error: 'You can only subscribe yourself' }, 403); } const result = await db.insert(subscriptions).values({ topicId, userId }).returning(); return c.json(result[0]); }); // Users can unsubscribe themselves or admins can unsubscribe anyone api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => { const { topicId, userId } = c.req.param(); const session = c.get('session'); // Check if user is unsubscribing themselves or is an admin if (session.id !== userId && !session.isAdmin) { return c.json({ error: 'You can only unsubscribe yourself' }, 403); } await db.delete(subscriptions) .where(and( eq(subscriptions.topicId, topicId), eq(subscriptions.userId, userId) )); return c.json({ success: true }); }); // --- Group Bindings (App Bot) --- // Get list of known groups (for selection) api.get('/groups', requireAuth, async (c) => { // Return recent active groups const groups = await db.select().from(knownGroupChats) .orderBy(desc(knownGroupChats.lastActiveAt)) .limit(50); return c.json(groups); }); // Get bindings for a topic api.get('/topics/:id/groups', requireAuth, async (c) => { const topicId = c.req.param('id'); const groups = await db.select().from(topicGroupChats) .where(eq(topicGroupChats.topicId, topicId)) .orderBy(desc(topicGroupChats.createdAt)); return c.json(groups); }); // Bind a group to a topic api.post('/topics/:id/groups', requireAuth, zValidator('json', groupBindingSchema), async (c) => { const topicId = c.req.param('id'); const body = c.req.valid('json'); const session = c.get('session'); const result = await db.insert(topicGroupChats).values({ topicId, chatId: body.chatId, name: body.name, createdBy: session.id, }).returning(); return c.json(result[0]); }); // Unbind a group api.delete('/topics/:id/groups/:bindingId', requireAuth, async (c) => { const { id: topicId, bindingId } = c.req.param(); await db.delete(topicGroupChats) .where(and( eq(topicGroupChats.id, bindingId), eq(topicGroupChats.topicId, topicId) )); return c.json({ success: true }); }); // --- Alert Tasks --- api.get('/alerts/tasks', requireAdmin, async (c) => { const limit = Math.min(Number(c.req.query('limit') || 50), 100); const tasks = await db.query.alertTasks.findMany({ orderBy: [desc(alertTasks.createdAt)], limit, with: { sender: true, logs: { limit: 10, // Only show first 10 logs inline } } }); return c.json(tasks); }); api.get('/alerts/tasks/:id', requireAuth, async (c) => { const id = c.req.param('id'); const task = await db.query.alertTasks.findFirst({ where: eq(alertTasks.id, id), with: { sender: true, logs: true // Show all logs for detail view } }); if (!task) { return c.json({ error: 'Task not found' }, 404); } return c.json(task); }); // --- Stats --- api.get('/stats', requireAdmin, async (c) => { // 1. Message count per topic const topicStats = await db.select({ topicSlug: alertTasks.topicSlug, totalTasks: count(), totalRecipients: sql`cast(${sum(alertTasks.recipientCount)} as int)`, totalSuccess: sql`cast(${sum(alertTasks.successCount)} as int)`, }) .from(alertTasks) .groupBy(alertTasks.topicSlug); // 2. Recent metrics (last 24h) const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000); const recentStats = await db.select({ totalRecipients: sql`cast(${sum(alertTasks.recipientCount)} as int)`, totalSuccess: sql`cast(${sum(alertTasks.successCount)} as int)`, taskCount: count(), }) .from(alertTasks) .where(gt(alertTasks.createdAt, last24h)); const recent = recentStats[0] || { totalRecipients: 0, totalSuccess: 0, taskCount: 0 }; const totalRecipients = Number(recent.totalRecipients || 0); const totalSuccess = Number(recent.totalSuccess || 0); const failedCount = totalRecipients - totalSuccess; const successRate = totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100; return c.json({ topics: topicStats, recent: { alertsReceived: Number(recent.taskCount || 0), plannedMessages: totalRecipients, successCount: totalSuccess, failedCount: failedCount, successRate: successRate, } }); }); export default api;