/opt/canhelp/apps/api/src/routes
Edit: /opt/canhelp/apps/api/src/routes/support.ts (8452B)
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { eq, desc, count, and, or, ilike } from 'drizzle-orm'
import { db } from '../db.js'
import { supportTickets, users } from '@canhelp/db'
import { requireAuth, requireAdmin, type AuthVariables } from '../middleware/auth.js'
import { translateString } from '../translate.js'
import { notifyAdmin } from '../lib/telegram.js'
import { notifySupportTicketReply } from '../lib/notif.js'
import { verifyTurnstile } from '../lib/turnstile.js'
const app = new Hono<{ Variables: AuthVariables }>()
function escapeHtml(input: string): string {
return input
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
}
// POST /public — public support form with Turnstile verification
app.post(
'/public',
zValidator(
'json',
z.object({
name: z.string().min(2).max(100),
email: z.string().email().max(200),
subject: z.string().min(3).max(200),
body: z.string().min(10).max(5000),
captchaToken: z.string().min(1),
}),
),
async (c) => {
const { name, email, subject, body, captchaToken } = c.req.valid('json')
const ip = c.req.header('CF-Connecting-IP') || c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
const ok = await verifyTurnstile(captchaToken, ip)
if (!ok) {
return c.json({ error: 'CAPTCHA verification failed', code: 'CAPTCHA_FAILED' }, 400)
}
const adminText = [
'🆘
Public support message',
`From: ${escapeHtml(name)} <${escapeHtml(email)}>`,
`Subject: ${escapeHtml(subject)}`,
'',
escapeHtml(body.length > 500 ? `${body.slice(0, 500)}...` : body),
].join('\n')
await notifyAdmin(adminText)
return c.json({ ok: true })
},
)
// POST / — create ticket (any authenticated user)
app.post(
'/',
requireAuth,
zValidator(
'json',
z.object({
type: z.enum(['bug', 'suggestion', 'other', 'delete_account']),
subject: z.string().min(3).max(200),
body: z.string().min(10).max(5000),
}),
),
async (c) => {
const user = c.get('user')
const { type, subject, body } = c.req.valid('json')
const [ticket] = await db
.insert(supportTickets)
.values({ userId: user.id, type, subject, body })
.returning()
// Translate subject+body to all locales in background so admins can read in their language
const userLocale = (user as any).locale ?? 'el'
Promise.all(
['el', 'en', 'ru', 'uk']
.filter((l) => l !== userLocale)
.map(async (l) => {
const [ts, tb] = await Promise.all([
translateString(subject, userLocale, l),
translateString(body, userLocale, l),
])
return [l, ts, tb] as [string, string | null, string | null]
}),
).then(async (results) => {
const bodyTranslations: Record
= { [userLocale]: subject + '\n\n' + body }
for (const [l, ts, tb] of results) {
if (ts || tb) bodyTranslations[l] = (ts ?? subject) + '\n\n' + (tb ?? body)
}
await db.update(supportTickets)
.set({ bodyTranslations })
.where(eq(supportTickets.id, ticket.id))
.catch(() => {})
}).catch(() => {})
const shortBody = body.length > 300 ? `${body.slice(0, 300)}...` : body
const adminText = [
'🆘 New support ticket',
`#${escapeHtml(ticket.id)}`,
`Type: ${escapeHtml(type)}`,
`From: ${escapeHtml((user as any).name || (user as any).email || user.id)}`,
`Subject: ${escapeHtml(subject)}`,
'',
escapeHtml(shortBody),
].join('\n')
notifyAdmin(adminText).catch((err) => {
console.error('[support] telegram notify error:', err)
})
return c.json(ticket, 201)
},
)
// GET /my — user's own tickets
app.get('/my', requireAuth, async (c) => {
const user = c.get('user')
const page = Math.max(1, Number(c.req.query('page') || 1))
const limit = Math.min(Number(c.req.query('limit') || 20), 50)
const offset = (page - 1) * limit
const rows = await db
.select()
.from(supportTickets)
.where(eq(supportTickets.userId, user.id))
.orderBy(desc(supportTickets.createdAt))
.limit(limit)
.offset(offset)
const [{ total }] = await db
.select({ total: count() })
.from(supportTickets)
.where(eq(supportTickets.userId, user.id))
return c.json({ data: rows, total, page, limit })
})
// GET / — admin: all tickets with user info
app.get('/', requireAdmin, async (c) => {
const page = Math.max(1, Number(c.req.query('page') || 1))
const limit = Math.min(Number(c.req.query('limit') || 30), 100)
const offset = (page - 1) * limit
const status = c.req.query('status')
const type = c.req.query('type')
const q = c.req.query('q')
const conditions = []
if (status) conditions.push(eq(supportTickets.status, status))
if (type) conditions.push(eq(supportTickets.type, type))
if (q) conditions.push(or(ilike(supportTickets.subject, `%${q}%`), ilike(supportTickets.body, `%${q}%`)))
const where = conditions.length > 0 ? and(...conditions) : undefined
const rows = await db
.select({
id: supportTickets.id,
type: supportTickets.type,
subject: supportTickets.subject,
body: supportTickets.body,
status: supportTickets.status,
adminReply: supportTickets.adminReply,
repliedAt: supportTickets.repliedAt,
createdAt: supportTickets.createdAt,
updatedAt: supportTickets.updatedAt,
userId: supportTickets.userId,
userName: users.name,
userEmail: users.email,
})
.from(supportTickets)
.leftJoin(users, eq(supportTickets.userId, users.id))
.where(where)
.orderBy(desc(supportTickets.createdAt))
.limit(limit)
.offset(offset)
const [{ total }] = await db
.select({ total: count() })
.from(supportTickets)
.where(where)
return c.json({ data: rows, total, page, limit })
})
// PATCH /:id/reply — admin replies
app.patch(
'/:id/reply',
requireAdmin,
zValidator(
'json',
z.object({
adminReply: z.string().min(1).max(5000),
status: z.enum(['open', 'in_progress', 'resolved', 'closed']).optional(),
}),
),
async (c) => {
const admin = c.get('user')
const { adminReply, status } = c.req.valid('json')
const [updated] = await db
.update(supportTickets)
.set({
adminReply,
status: status ?? 'resolved',
adminId: admin.id,
repliedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(supportTickets.id, c.req.param('id')))
.returning()
if (!updated) return c.json({ error: 'Not found' }, 404)
// Translate admin reply to the ticket owner's locale in background
const [ticketUser] = await db.select({ locale: users.locale }).from(users).where(eq(users.id, updated.userId))
const adminLocale = (admin as any).locale ?? 'el'
const userLocale = ticketUser?.locale ?? 'el'
Promise.all(
['el', 'en', 'ru', 'uk']
.filter((l) => l !== adminLocale)
.map(async (l) => {
const translated = await translateString(adminReply, adminLocale, l)
return [l, translated] as [string, string | null]
}),
).then(async (results) => {
const adminReplyTranslations: Record = { [adminLocale]: adminReply }
for (const [l, t] of results) {
if (t) adminReplyTranslations[l] = t
}
await db.update(supportTickets)
.set({ adminReplyTranslations })
.where(eq(supportTickets.id, updated.id))
.catch(() => {})
}).catch(() => {})
notifySupportTicketReply(updated.userId, updated.subject, updated.id).catch((err) => {
console.error('[support] reply notify error:', err)
})
return c.json(updated)
},
)
// PATCH /:id/status — admin changes status only
app.patch(
'/:id/status',
requireAdmin,
zValidator('json', z.object({ status: z.enum(['open', 'in_progress', 'resolved', 'closed']) })),
async (c) => {
const { status } = c.req.valid('json')
const [updated] = await db
.update(supportTickets)
.set({ status, updatedAt: new Date() })
.where(eq(supportTickets.id, c.req.param('id')))
.returning()
if (!updated) return c.json({ error: 'Not found' }, 404)
return c.json(updated)
},
)
export default app