import { Hono } from 'hono'; import { eq, and, isNull, or, sql, count, desc } from 'drizzle-orm'; import crypto from 'node:crypto'; import { db } from '../db'; import { invitations, users } from '../db/schema'; import { authMiddleware, requireAdmin } from '../middleware/auth'; import type { AppEnv } from '../types'; const invitesRouter = new Hono(); function generateCode(): string { const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; const bytes = crypto.randomBytes(8); let code = ''; for (let i = 0; i < 8; i++) { code += chars[bytes[i] % chars.length]; } return code; } invitesRouter.post('/', authMiddleware, async (c) => { const user = c.get('user'); if (!user.email && user.role !== 'admin') { return c.json({ error: 'Must be registered to create invites' }, 403); } if (user.role !== 'admin') { const limit = parseInt(process.env.USER_INVITATIONS || '0', 10); if (limit <= 0) { return c.json({ error: 'You are not allowed to create invites' }, 403); } const [{ value: created }] = await db .select({ value: count() }) .from(invitations) .where(eq(invitations.createdBy, user.id)); if (created >= limit) { return c.json({ error: 'Invite limit reached' }, 403); } } for (let attempt = 0; attempt < 5; attempt++) { const code = generateCode(); try { await db.insert(invitations).values({ code, createdBy: user.id }); return c.json({ code }); } catch (e: unknown) { const message = e instanceof Error ? e.message : ''; if (message.includes('unique') || message.includes('duplicate')) continue; throw e; } } return c.json({ error: 'Failed to generate unique code' }, 500); }); invitesRouter.get('/validate/:code', async (c) => { const code = c.req.param('code'); const [invite] = await db .select() .from(invitations) .where( and( eq(invitations.code, code), isNull(invitations.usedBy), or( isNull(invitations.expiresAt), sql`${invitations.expiresAt} > NOW()`, ), ), ) .limit(1); return c.json({ valid: !!invite }); }); invitesRouter.get('/remaining', authMiddleware, async (c) => { const user = c.get('user'); if (user.role === 'admin') { return c.json({ remaining: -1 }); } const limit = parseInt(process.env.USER_INVITATIONS || '0', 10); if (limit <= 0) { return c.json({ remaining: 0 }); } const [{ value: created }] = await db .select({ value: count() }) .from(invitations) .where(eq(invitations.createdBy, user.id)); return c.json({ remaining: Math.max(0, limit - created) }); }); invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => { const allInvites = await db .select({ id: invitations.id, code: invitations.code, createdBy: invitations.createdBy, usedBy: invitations.usedBy, createdAt: invitations.createdAt, expiresAt: invitations.expiresAt, }) .from(invitations) .orderBy(desc(invitations.createdAt)); const userIds = new Set(); for (const inv of allInvites) { userIds.add(inv.createdBy); if (inv.usedBy) userIds.add(inv.usedBy); } const userMap = new Map(); if (userIds.size > 0) { const userList = await db .select({ id: users.id, username: users.username, email: users.email }) .from(users) .where(or(...[...userIds].map(id => eq(users.id, id)))); for (const u of userList) { userMap.set(u.id, { username: u.username, email: u.email }); } } const enriched = allInvites.map(inv => ({ id: inv.id, code: inv.code, createdBy: userMap.get(inv.createdBy) ?? { username: null, email: null }, usedBy: inv.usedBy ? (userMap.get(inv.usedBy) ?? { username: null, email: null }) : null, createdAt: inv.createdAt, expiresAt: inv.expiresAt, used: !!inv.usedBy, })); return c.json({ invitations: enriched }); }); invitesRouter.delete('/:id', authMiddleware, requireAdmin, async (c) => { const inviteId = c.req.param('id'); const [invite] = await db .select({ id: invitations.id, usedBy: invitations.usedBy }) .from(invitations) .where(eq(invitations.id, inviteId)) .limit(1); if (!invite) { return c.json({ error: 'Invitation not found' }, 404); } if (invite.usedBy) { return c.json({ error: 'Cannot revoke a used invitation' }, 400); } await db.delete(invitations).where(eq(invitations.id, inviteId)); return c.json({ deleted: true }); }); export { invitesRouter };