/opt/canhelp/apps/api/src
Edit: /opt/canhelp/apps/api/src/socket.ts (5754B)
import type { Server as HttpServer } from 'http'
import { Server } from 'socket.io'
import { db } from './db.js'
import { messages, chatRooms, users } from '@canhelp/db'
import { notifyNewMessage } from './lib/notif.js'
import { eq } from 'drizzle-orm'
import { auth } from './auth.js'
import { emailNewMessage } from './lib/email.js'
import { abbrevName } from './lib/nameUtils.js'
import { translateMessageAllLocales } from './translate.js'
import { checkMessageLimit, incrementMessages } from './lib/daily-limits.js'
import { canSendMessage } from './lib/messaging-rules.js'
export function setupSocket(httpServer: HttpServer) {
const io = new Server(httpServer, {
cors: {
origin: process.env.WEB_URL || 'http://localhost:3000',
credentials: true,
},
})
// Auth middleware — supports Bearer token (Flutter) and cookies (web)
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth?.token || socket.handshake.headers?.authorization?.replace('Bearer ', '')
const cookieHeader = socket.handshake.headers?.cookie
let session = null
if (token) {
const headers = new Headers({ authorization: `Bearer ${token}` })
session = await auth.api.getSession({ headers })
} else if (cookieHeader) {
const headers = new Headers({ cookie: cookieHeader })
session = await auth.api.getSession({ headers })
}
if (!session) return next(new Error('Unauthorized'))
socket.data.userId = session.user.id
next()
} catch {
next(new Error('Unauthorized'))
}
})
io.on('connection', (socket) => {
const userId: string = socket.data.userId
console.log(`[Socket] User connected: ${userId}`)
// Join user's personal room for notifications
socket.join(`user:${userId}`)
// Join a chat room
socket.on('join_room', async (roomId: string) => {
const [room] = await db.select().from(chatRooms).where(eq(chatRooms.id, roomId))
if (!room) return
if (room.customerId !== userId && room.specialistId !== userId) return
socket.join(`room:${roomId}`)
})
// Send a message
socket.on('send_message', async (data: { roomId: string; body: string; locale?: string; imageUrl?: string }) => {
if (!data.roomId || (!data.body?.trim() && !data.imageUrl)) return
// Validate imageUrl is an internal upload path
if (data.imageUrl && !data.imageUrl.startsWith('/api/uploads/')) return
const [room] = await db.select().from(chatRooms).where(eq(chatRooms.id, data.roomId))
if (!room) return
if (room.customerId !== userId && room.specialistId !== userId) return
// Check messaging rules (plan-based)
const recipientId = room.customerId === userId ? room.specialistId : room.customerId
const msgRule = await canSendMessage(userId, recipientId)
if (!msgRule.allowed) {
socket.emit('error_message', {
error: msgRule.reason,
code: msgRule.code,
senderTier: msgRule.senderTier,
recipientTier: msgRule.recipientTier,
})
return
}
// Check daily message limit
const msgCheck = await checkMessageLimit(userId)
if (!msgCheck.allowed) {
socket.emit('error_message', {
error: 'Daily message limit reached',
code: 'DAILY_LIMIT_REACHED',
limit: msgCheck.limit,
current: msgCheck.current,
})
return
}
const [msg] = await db
.insert(messages)
.values({ roomId: data.roomId, senderId: userId, body: data.body?.trim() ?? '', imageUrl: data.imageUrl ?? null })
.returning()
// Increment daily message counter
incrementMessages(userId).catch(() => {})
io.to(`room:${data.roomId}`).emit('new_message', msg)
// Translate text body (skip for image-only messages)
if (data.body?.trim()) {
translateMessageAllLocales(data.body.trim(), data.locale ?? 'el').then(async (translations) => {
await db.update(messages).set({ translations }).where(eq(messages.id, msg.id)).catch(() => {})
io.to(`room:${data.roomId}`).emit('message_translated', { id: msg.id, roomId: data.roomId, translations })
}).catch(() => {})
}
// Notify recipient
const notifPreview = data.imageUrl ? '📷 Photo' : data.body
const notif = await notifyNewMessage(recipientId, notifPreview, data.roomId)
if (notif) io.to(`user:${recipientId}`).emit('notification', notif)
// Email recipient (fire-and-forget, only if they're not currently connected)
const recipientSockets = await io.in(`user:${recipientId}`).fetchSockets()
if (recipientSockets.length === 0) {
const [sender] = await db.select().from(users).where(eq(users.id, userId))
const [recipient] = await db.select().from(users).where(eq(users.id, recipientId))
if (recipient?.email && sender) {
emailNewMessage({
to: recipient.email,
recipientName: recipient.firstName || recipient.name || 'Χρήστη',
senderName: abbrevName(sender.firstName, sender.lastName, sender.name),
preview: data.imageUrl ? '📷 Photo' : data.body.slice(0, 120),
roomId: data.roomId,
locale: (recipient.locale as 'el' | 'en' | 'ru') || 'el',
}).catch(() => {})
}
}
})
// Mark messages as read
socket.on('mark_read', async (roomId: string) => {
await db
.update(messages)
.set({ isRead: true })
.where(eq(messages.roomId, roomId))
})
socket.on('disconnect', () => {
console.log(`[Socket] User disconnected: ${userId}`)
})
})
return io
}