/opt/canhelp/apps/api/src/routes
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