import { db } from '../config/database.js'; import { chatGroups, chatGroupMembers, groupMessages, users } from '../db/schema.js'; import { eq, and, desc, inArray, sql, gt } from 'drizzle-orm'; import { NotFoundError, ForbiddenError } from '../utils/errors.js'; import { sendPushNotificationToUsers } from './push.service.js'; import { logger } from '../utils/logger.js'; /** * Create a new group chat */ export const createGroup = async (name, creatorId, memberIds) => { const [group] = await db .insert(chatGroups) .values({ name: name.trim(), createdById: creatorId, }) .returning(); // Add creator and all members (ensure unique) const allMemberIds = [...new Set([creatorId, ...memberIds])]; await db.insert(chatGroupMembers).values( allMemberIds.map((userId) => ({ groupId: group.id, userId, })) ); return group; }; /** * Get all groups for a user */ export const getUserGroups = async (userId) => { // Get groups where user is a member with lastReadAt const memberOf = await db .select({ groupId: chatGroupMembers.groupId, lastReadAt: chatGroupMembers.lastReadAt, }) .from(chatGroupMembers) .where(eq(chatGroupMembers.userId, userId)); if (memberOf.length === 0) return []; const groupIds = memberOf.map((m) => m.groupId); const lastReadMap = memberOf.reduce((acc, m) => { acc[m.groupId] = m.lastReadAt; return acc; }, {}); const groups = await db .select({ id: chatGroups.id, name: chatGroups.name, createdById: chatGroups.createdById, createdAt: chatGroups.createdAt, updatedAt: chatGroups.updatedAt, }) .from(chatGroups) .where(inArray(chatGroups.id, groupIds)) .orderBy(desc(chatGroups.updatedAt)); // Get last message, member count, and unread count for each group const result = await Promise.all( groups.map(async (group) => { const lastReadAt = lastReadMap[group.id]; const [lastMessage, members, unreadResult] = await Promise.all([ db .select({ content: groupMessages.content, createdAt: groupMessages.createdAt, senderId: groupMessages.senderId, senderFirstName: users.firstName, senderUsername: users.username, }) .from(groupMessages) .leftJoin(users, eq(groupMessages.senderId, users.id)) .where(eq(groupMessages.groupId, group.id)) .orderBy(desc(groupMessages.createdAt)) .limit(1), db .select({ id: chatGroupMembers.id }) .from(chatGroupMembers) .where(eq(chatGroupMembers.groupId, group.id)), // Count unread messages (messages after lastReadAt, not sent by current user) db .select({ count: sql`count(*)::int` }) .from(groupMessages) .where( and( eq(groupMessages.groupId, group.id), gt(groupMessages.createdAt, lastReadAt), sql`${groupMessages.senderId} != ${userId}` ) ), ]); return { ...group, lastMessage: lastMessage[0] ? { content: lastMessage[0].content, createdAt: lastMessage[0].createdAt, senderName: lastMessage[0].senderFirstName || lastMessage[0].senderUsername, isMine: lastMessage[0].senderId === userId, } : null, memberCount: members.length, unreadCount: unreadResult[0]?.count || 0, type: 'group', }; }) ); return result; }; /** * Get group details with members */ export const getGroupDetails = async (groupId, userId) => { // Verify user is member const [isMember] = await db .select() .from(chatGroupMembers) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, userId) ) ) .limit(1); if (!isMember) { throw new ForbiddenError('Nie ste členom tejto skupiny'); } const [group] = await db .select() .from(chatGroups) .where(eq(chatGroups.id, groupId)) .limit(1); if (!group) { throw new NotFoundError('Skupina nenájdená'); } const members = await db .select({ id: users.id, username: users.username, firstName: users.firstName, lastName: users.lastName, role: users.role, }) .from(chatGroupMembers) .innerJoin(users, eq(chatGroupMembers.userId, users.id)) .where(eq(chatGroupMembers.groupId, groupId)); return { ...group, members, }; }; /** * Get messages for a group */ export const getGroupMessages = async (groupId, userId) => { // Verify user is member const [isMember] = await db .select() .from(chatGroupMembers) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, userId) ) ) .limit(1); if (!isMember) { throw new ForbiddenError('Nie ste členom tejto skupiny'); } // Update lastReadAt for this user in this group await db .update(chatGroupMembers) .set({ lastReadAt: new Date() }) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, userId) ) ); const messages = await db .select({ id: groupMessages.id, content: groupMessages.content, createdAt: groupMessages.createdAt, senderId: groupMessages.senderId, senderUsername: users.username, senderFirstName: users.firstName, senderLastName: users.lastName, }) .from(groupMessages) .leftJoin(users, eq(groupMessages.senderId, users.id)) .where(eq(groupMessages.groupId, groupId)) .orderBy(groupMessages.createdAt); return messages.map((msg) => ({ id: msg.id, content: msg.content, createdAt: msg.createdAt, senderId: msg.senderId, senderName: msg.senderFirstName || msg.senderUsername || 'Neznámy', isMine: msg.senderId === userId, })); }; /** * Send message to group */ export const sendGroupMessage = async (groupId, senderId, content) => { // Verify user is member const [isMember] = await db .select() .from(chatGroupMembers) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, senderId) ) ) .limit(1); if (!isMember) { throw new ForbiddenError('Nie ste členom tejto skupiny'); } const [message] = await db .insert(groupMessages) .values({ groupId, senderId, content: content.trim(), }) .returning(); // Update group's updatedAt await db .update(chatGroups) .set({ updatedAt: new Date() }) .where(eq(chatGroups.id, groupId)); // Send push notifications to all group members (except sender) try { const [group] = await db .select({ name: chatGroups.name }) .from(chatGroups) .where(eq(chatGroups.id, groupId)) .limit(1); const [sender] = await db .select({ firstName: users.firstName, username: users.username }) .from(users) .where(eq(users.id, senderId)) .limit(1); const members = await db .select({ userId: chatGroupMembers.userId }) .from(chatGroupMembers) .where(eq(chatGroupMembers.groupId, groupId)); const memberIds = members.map(m => m.userId); const senderName = sender?.firstName || sender?.username || 'Niekto'; const groupName = group?.name || 'Skupina'; await sendPushNotificationToUsers( memberIds, { title: `${groupName}`, body: `${senderName}: ${content.trim().substring(0, 80)}${content.length > 80 ? '...' : ''}`, icon: '/icon-192.png', badge: '/badge-72.png', data: { url: `/chat/group/${groupId}` }, }, senderId // exclude sender ); } catch (error) { logger.error('Failed to send push notifications for group message', error); } return { id: message.id, content: message.content, createdAt: message.createdAt, senderId: message.senderId, isMine: true, }; }; /** * Add member to group */ export const addGroupMember = async (groupId, userId, requesterId) => { // Verify requester is member const [isMember] = await db .select() .from(chatGroupMembers) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, requesterId) ) ) .limit(1); if (!isMember) { throw new ForbiddenError('Nie ste členom tejto skupiny'); } // Check if already member const [alreadyMember] = await db .select() .from(chatGroupMembers) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, userId) ) ) .limit(1); if (alreadyMember) { throw new Error('Používateľ je už členom skupiny'); } // Verify user exists const [userExists] = await db .select({ id: users.id }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!userExists) { throw new NotFoundError('Používateľ nenájdený'); } await db.insert(chatGroupMembers).values({ groupId, userId }); return { success: true }; }; /** * Remove member from group (or leave) */ export const removeGroupMember = async (groupId, userId, requesterId) => { // User can remove themselves if (userId !== requesterId) { // Check if requester is the creator const [group] = await db .select({ createdById: chatGroups.createdById }) .from(chatGroups) .where(eq(chatGroups.id, groupId)) .limit(1); if (!group || group.createdById !== requesterId) { throw new ForbiddenError('Nemáte oprávnenie odstrániť tohto člena'); } } await db .delete(chatGroupMembers) .where( and( eq(chatGroupMembers.groupId, groupId), eq(chatGroupMembers.userId, userId) ) ); return { success: true }; }; /** * Update group name */ export const updateGroupName = async (groupId, name, requesterId) => { // Verify requester is the creator const [group] = await db .select({ createdById: chatGroups.createdById }) .from(chatGroups) .where(eq(chatGroups.id, groupId)) .limit(1); if (!group) { throw new NotFoundError('Skupina nenájdená'); } if (group.createdById !== requesterId) { throw new ForbiddenError('Nemáte oprávnenie upraviť názov skupiny'); } const [updated] = await db .update(chatGroups) .set({ name: name.trim(), updatedAt: new Date() }) .where(eq(chatGroups.id, groupId)) .returning(); return updated; }; /** * Delete group (only creator can delete) */ export const deleteGroup = async (groupId, requesterId) => { const [group] = await db .select({ createdById: chatGroups.createdById }) .from(chatGroups) .where(eq(chatGroups.id, groupId)) .limit(1); if (!group) { throw new NotFoundError('Skupina nenájdená'); } if (group.createdById !== requesterId) { throw new ForbiddenError('Nemáte oprávnenie odstrániť túto skupinu'); } await db.delete(chatGroups).where(eq(chatGroups.id, groupId)); return { success: true }; }; /** * Get total unread group messages count for a user */ export const getGroupUnreadCount = async (userId) => { // Get all groups user is a member of with lastReadAt const memberOf = await db .select({ groupId: chatGroupMembers.groupId, lastReadAt: chatGroupMembers.lastReadAt, }) .from(chatGroupMembers) .where(eq(chatGroupMembers.userId, userId)); if (memberOf.length === 0) return 0; // Count unread messages across all groups let totalUnread = 0; for (const membership of memberOf) { const result = await db .select({ count: sql`count(*)::int` }) .from(groupMessages) .where( and( eq(groupMessages.groupId, membership.groupId), gt(groupMessages.createdAt, membership.lastReadAt), sql`${groupMessages.senderId} != ${userId}` ) ); totalUnread += result[0]?.count || 0; } return totalUnread; };