/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/chat.ts (9377B)
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { eq, or, and, desc, isNull } from 'drizzle-orm' import { db } from '../db.js' import { chatRooms, messages, tasks, users } from '@canhelp/db' import { notifyNewMessage } from '../lib/notif.js' import { requireAuth, type AuthVariables } from '../middleware/auth.js' import { checkMessageLimit, incrementMessages } from '../lib/daily-limits.js' import { canSendMessage } from '../lib/messaging-rules.js' import { translateMessageAllLocales } from '../translate.js' const app = new Hono<{ Variables: AuthVariables }>() // GET /chat/rooms — user's chat rooms with other participant info app.get('/rooms', requireAuth, async (c) => { const user = c.get('user') const otherUser = { id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, } const rows = await db .select({ room: chatRooms, task: { id: tasks.id, title: tasks.title }, customer: otherUser, }) .from(chatRooms) .leftJoin(tasks, eq(chatRooms.taskId, tasks.id)) .leftJoin(users, eq(chatRooms.customerId, users.id)) .where( or(eq(chatRooms.customerId, user.id), eq(chatRooms.specialistId, user.id)), ) .orderBy(desc(chatRooms.createdAt)) // Replace customer join with the OTHER participant + last message const enrichedRaw = await Promise.all( rows.map(async ({ room, task, customer }) => { const otherId = room.customerId === user.id ? room.specialistId : room.customerId const [other] = await db .select({ id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image }) .from(users) .where(eq(users.id, otherId)) const [lastMsg] = await db .select({ body: messages.body, createdAt: messages.createdAt, translations: messages.translations, imageUrl: messages.imageUrl }) .from(messages) .where(eq(messages.roomId, room.id)) .orderBy(desc(messages.createdAt)) .limit(1) return { room, task, otherUser: other ?? null, lastMessage: lastMsg ?? null } }), ) // Sort by last message time desc (fallback to room creation) const enriched = enrichedRaw.sort((a, b) => { const aTime = a.lastMessage?.createdAt ? new Date(a.lastMessage.createdAt).getTime() : new Date(a.room.createdAt).getTime() const bTime = b.lastMessage?.createdAt ? new Date(b.lastMessage.createdAt).getTime() : new Date(b.room.createdAt).getTime() return bTime - aTime }) return c.json(enriched) }) // GET /chat/direct/:userId — get-or-create a direct room between current user and target app.get('/direct/:userId', requireAuth, async (c) => { const me = c.get('user') const otherId = c.req.param('userId') if (otherId === me.id) return c.json({ error: 'Cannot chat with yourself' }, 400) const [other] = await db.select().from(users).where(eq(users.id, otherId)) if (!other) return c.json({ error: 'User not found' }, 404) // Check messaging rules (plan-based) const msgRule = await canSendMessage(me.id, otherId) if (!msgRule.allowed) { return c.json({ error: msgRule.reason, code: msgRule.code, senderTier: msgRule.senderTier, recipientTier: msgRule.recipientTier, }, 403) } // Look for existing direct room (taskId IS NULL, both participants match in either direction) const [existing] = await db .select() .from(chatRooms) .where( and( isNull(chatRooms.taskId), or( and(eq(chatRooms.customerId, me.id), eq(chatRooms.specialistId, otherId)), and(eq(chatRooms.customerId, otherId), eq(chatRooms.specialistId, me.id)), ), ), ) .limit(1) if (existing) return c.json({ room: existing, created: false }) // Create new direct room (customerId = initiator, specialistId = target) const [room] = await db .insert(chatRooms) .values({ taskId: null, customerId: me.id, specialistId: otherId }) .returning() return c.json({ room, created: true }, 201) }) // POST /chat/rooms — create room linked to a task (offer accepted flow) app.post( '/rooms', requireAuth, zValidator('json', z.object({ taskId: z.string().uuid().optional(), specialistId: z.string() })), async (c) => { const user = c.get('user') const { taskId, specialistId } = c.req.valid('json') // Check messaging rules (plan-based) const msgRule = await canSendMessage(user.id, specialistId) if (!msgRule.allowed) { return c.json({ error: msgRule.reason, code: msgRule.code, senderTier: msgRule.senderTier, recipientTier: msgRule.recipientTier, }, 403) } if (taskId) { const [task] = await db.select().from(tasks).where(eq(tasks.id, taskId)) if (!task) return c.json({ error: 'Task not found' }, 404) if (task.customerId !== user.id) return c.json({ error: 'Forbidden' }, 403) // Check if room already exists for this task const [existing] = await db .select() .from(chatRooms) .where(and(eq(chatRooms.taskId, taskId), eq(chatRooms.specialistId, specialistId))) if (existing) return c.json(existing) } const [room] = await db .insert(chatRooms) .values({ taskId: taskId ?? null, customerId: user.id, specialistId }) .returning() return c.json(room, 201) }, ) // GET /chat/rooms/:roomId/messages app.get('/rooms/:roomId/messages', requireAuth, async (c) => { const user = c.get('user') const roomId = c.req.param('roomId') const limit = Math.min(Number(c.req.query('limit') || 50), 200) const [room] = await db.select().from(chatRooms).where(eq(chatRooms.id, roomId)) if (!room) return c.json({ error: 'Not found' }, 404) if (room.customerId !== user.id && room.specialistId !== user.id) { return c.json({ error: 'Forbidden' }, 403) } const msgs = await db .select({ message: messages, sender: { id: users.id, name: users.name, firstName: users.firstName, lastName: users.lastName, image: users.image, }, }) .from(messages) .leftJoin(users, eq(messages.senderId, users.id)) .where(eq(messages.roomId, roomId)) .orderBy(desc(messages.createdAt)) .limit(limit) return c.json(msgs.reverse()) }) // POST /chat/messages — REST fallback (WebSocket is preferred) app.post( '/messages', requireAuth, zValidator('json', z.object({ roomId: z.string(), body: z.string().max(5000).default(''), imageUrl: z.string().max(500).optional(), locale: z.string().optional(), })), async (c) => { const user = c.get('user') const { roomId, body: msgBody, imageUrl, locale } = c.req.valid('json') if (!msgBody.trim() && !imageUrl) return c.json({ error: 'body or imageUrl required' }, 400) // Validate imageUrl is an internal upload path (prevent arbitrary URLs) if (imageUrl && !imageUrl.startsWith('/api/uploads/')) return c.json({ error: 'Invalid imageUrl' }, 400) const [room] = await db.select().from(chatRooms).where(eq(chatRooms.id, roomId)) if (!room) return c.json({ error: 'Not found' }, 404) if (room.customerId !== user.id && room.specialistId !== user.id) { return c.json({ error: 'Forbidden' }, 403) } // Check messaging rules (plan-based) const recipientId = room.customerId === user.id ? room.specialistId : room.customerId const msgRule = await canSendMessage(user.id, recipientId) if (!msgRule.allowed) { return c.json({ error: msgRule.reason, code: msgRule.code, senderTier: msgRule.senderTier, recipientTier: msgRule.recipientTier, }, 403) } // Check daily message limit const msgCheck = await checkMessageLimit(user.id) if (!msgCheck.allowed) { return c.json({ error: 'Daily message limit reached', code: 'DAILY_LIMIT_REACHED', limit: msgCheck.limit, current: msgCheck.current, }, 429) } const [msg] = await db .insert(messages) .values({ roomId, senderId: user.id, body: msgBody, imageUrl: imageUrl ?? null }) .returning() // Increment daily message counter incrementMessages(user.id).catch(() => {}) // Translate text body (skip for image-only messages) if (msgBody.trim()) { translateMessageAllLocales(msgBody, locale ?? user.locale ?? 'el').then(async (translations) => { await db.update(messages).set({ translations }).where(eq(messages.id, msg.id)).catch(() => {}) }).catch(() => {}) } const notifPreview = imageUrl ? '📷 Photo' : msgBody await notifyNewMessage(recipientId, notifPreview, roomId) return c.json(msg, 201) }, ) // GET /chat/can-message/:userId — check if current user can message target user app.get('/can-message/:userId', requireAuth, async (c) => { const me = c.get('user') const targetId = c.req.param('userId') if (targetId === me.id) return c.json({ allowed: true }) const result = await canSendMessage(me.id, targetId) return c.json({ allowed: result.allowed, reason: result.reason, code: result.code, senderTier: result.senderTier, recipientTier: result.recipientTier, }) }) export default app