/opt/canhelp/apps/api/src/routes
NameSizeModeActions
admin.ts950570644editdlrm
auto-match.ts74060644editdlrm
auto-response.ts81110644editdlrm
availability.ts72940644editdlrm
categories.ts18670644editdlrm
chat.ts93770644editdlrm
dashboard.ts51420644editdlrm
favorites.ts44410644editdlrm
fomo.ts68440644editdlrm
google.ts48720644editdlrm
health.ts12140644editdlrm
i18n.ts1670860644editdlrm
locations.ts17810644editdlrm
notifications.ts28620644editdlrm
offers.ts100470644editdlrm
payments.ts211670644editdlrm
plans.ts13520644editdlrm
portfolio.ts66580644editdlrm
price-list.ts144170644editdlrm
reports.ts20070644editdlrm
reviews.ts27830644editdlrm
skill-suggestions.ts10350644editdlrm
specialist-cards.ts186410644editdlrm
stats.ts10930644editdlrm
statuses.ts37310644editdlrm
support.ts84520644editdlrm
tasks.ts300180644editdlrm
uploads.ts15260644editdlrm
users.ts371570644editdlrm
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