/** * bOSScord API routes for broccolini-bot: ticket list/detail, thread from Discord, send message. * Auth via BOSSCORD_API_KEY. Mount on Express in broccolini-discord.js. */ require('../models'); // ensure Ticket model is registered const express = require('express'); const mongoose = require('mongoose'); const { getBot } = require('../api/bosscordClient'); const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { updateTicketActivity } = require('../services/tickets'); const { extractRawEmail } = require('../utils'); const { CONFIG } = require('../config'); const router = express.Router(); const Ticket = mongoose.model('Ticket'); const CORS_ORIGIN = process.env.BOSSCORD_CORS_ORIGIN || '*'; function corsMiddleware(req, res, next) { res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Staff-Discord-Id'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); } function authMiddleware(req, res, next) { const key = process.env.BOSSCORD_API_KEY; if (!key) { return res.status(503).json({ error: 'bOSScord API not configured (BOSSCORD_API_KEY)' }); } const auth = req.headers.authorization; const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null; if (token !== key) { return res.status(401).json({ error: 'Unauthorized' }); } next(); } router.use(corsMiddleware); router.use(authMiddleware); function requireDb(req, res, next) { if (mongoose.connection.readyState !== 1) { return res.status(503).json({ error: 'Database not ready yet. Wait for the bot to finish starting.' }); } next(); } router.use(requireDb); function resolveTicketId(id) { if (mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id) { return Ticket.findOne({ _id: id }); } const num = parseInt(id, 10); if (!Number.isNaN(num)) { return Ticket.findOne({ ticketNumber: num }); } return Ticket.findOne({ gmailThreadId: id }); } /** GET /api/tickets — list tickets. Query: status, priority, claimedBy, limit */ router.get('/tickets', async (req, res, next) => { try { if (!Ticket) return res.status(503).json({ error: 'Ticket model not loaded' }); const { status, priority, claimedBy, limit = 50 } = req.query; const filter = {}; if (status) filter.status = status; if (priority) filter.priority = priority; if (claimedBy !== undefined && claimedBy !== '') filter.claimedBy = claimedBy === 'null' ? null : claimedBy; const limitNum = Math.min(parseInt(limit, 10) || 50, 100); const tickets = await Ticket.find(filter) .sort({ lastActivity: -1, createdAt: -1 }) .limit(limitNum) .lean(); return res.json({ tickets }); } catch (err) { console.error('GET /api/tickets error:', err.message); console.error(err.stack); next(err); } }); /** GET /api/me/tickets — "my tickets" (claimed by staff). Query: X-Staff-Discord-Id or claimedBy */ router.get('/me/tickets', async (req, res) => { try { const claimedBy = req.headers['x-staff-discord-id'] || req.query.claimedBy; if (!claimedBy) { return res.status(400).json({ error: 'Provide X-Staff-Discord-Id header or claimedBy query' }); } const tickets = await Ticket.find({ claimedBy, status: 'open' }) .sort({ lastActivity: -1, createdAt: -1 }) .limit(100) .lean(); res.json({ tickets }); } catch (err) { console.error('GET /api/me/tickets:', err); res.status(500).json({ error: err.message }); } }); /** GET /api/tickets/:id — single ticket metadata */ router.get('/tickets/:id', async (req, res) => { try { const ticket = await resolveTicketId(req.params.id); if (!ticket) { return res.status(404).json({ error: 'Ticket not found' }); } const out = ticket.toObject ? ticket.toObject() : { ...ticket }; if (CONFIG.DISCORD_GUILD_ID) out.guildId = CONFIG.DISCORD_GUILD_ID; res.json(out); } catch (err) { console.error('GET /api/tickets/:id:', err); res.status(500).json({ error: err.message }); } }); /** GET /api/tickets/:id/messages — thread from Discord */ router.get('/tickets/:id/messages', async (req, res) => { try { const ticket = await resolveTicketId(req.params.id); if (!ticket) { return res.status(404).json({ error: 'Ticket not found' }); } if (!ticket.discordThreadId) { return res.json({ messages: [] }); } const client = getBot(); if (!client) { return res.status(503).json({ error: 'Discord client not ready' }); } const limit = Math.min(parseInt(req.query.limit, 10) || 100, 100); const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null); if (!channel) { return res.status(404).json({ error: 'Discord channel not found' }); } const messages = await channel.messages.fetch({ limit }); const list = messages .sort((a, b) => a.createdTimestamp - b.createdTimestamp) .map((m) => ({ id: m.id, author: m.author?.username || 'unknown', authorId: m.author?.id, content: m.content, timestamp: m.createdAt?.toISOString?.(), isBot: m.author?.bot ?? false })); res.json({ messages: list }); } catch (err) { console.error('GET /api/tickets/:id/messages:', err); res.status(500).json({ error: err.message }); } }); /** POST /api/tickets/:id/messages — send message to Discord; for email tickets, also send via Gmail */ router.post('/tickets/:id/messages', express.json(), async (req, res) => { try { const ticket = await resolveTicketId(req.params.id); if (!ticket) { return res.status(404).json({ error: 'Ticket not found' }); } if (!ticket.discordThreadId) { return res.status(400).json({ error: 'Ticket has no Discord channel' }); } const content = req.body?.content; if (!content || typeof content !== 'string') { return res.status(400).json({ error: 'Body must include content (string)' }); } const client = getBot(); if (!client) { return res.status(503).json({ error: 'Discord client not ready' }); } const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null); if (!channel) { return res.status(404).json({ error: 'Discord channel not found' }); } const discordUser = req.body.displayName || 'bOSScord'; await channel.send(content); if (!ticket.gmailThreadId.startsWith('discord-')) { try { const gmail = getGmailClient(); const thread = await gmail.users.threads.get({ userId: 'me', id: ticket.gmailThreadId }); const last = [...(thread.data.messages || [])].reverse().find((msg) => { const from = msg.payload?.headers?.find((h) => h.name === 'From')?.value || ''; return !from.toLowerCase().includes(CONFIG.MY_EMAIL); }); if (last?.payload?.headers) { let recipient = last.payload.headers.find((h) => h.name === 'From')?.value || ''; const replyTo = last.payload.headers.find((h) => h.name === 'Reply-To')?.value; if (replyTo) recipient = replyTo; const subject = last.payload.headers.find((h) => h.name === 'Subject')?.value || 'Support'; const msgId = last.payload.headers.find((h) => h.name === 'Message-ID')?.value; const recipientEmail = extractRawEmail(recipient).toLowerCase(); if (recipientEmail && recipientEmail !== CONFIG.MY_EMAIL) { await sendGmailReply( ticket.gmailThreadId, content, recipientEmail, subject, discordUser, msgId ); } } } catch (e) { console.error('bOSScord Gmail reply error:', e); } } await updateTicketActivity(ticket.gmailThreadId); res.status(201).json({ ok: true }); } catch (err) { console.error('POST /api/tickets/:id/messages:', err); res.status(500).json({ error: err.message }); } }); module.exports = router;