feat: Group chat and push notifications
- Add group chat tables (chat_groups, chat_group_members, group_messages) - Add push subscriptions table for web push notifications - Add group service, controller, routes - Add push service, controller, routes - Integrate push notifications with todos, messages, group messages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
404
src/services/group.service.js
Normal file
404
src/services/group.service.js
Normal file
@@ -0,0 +1,404 @@
|
||||
import { db } from '../config/database.js';
|
||||
import { chatGroups, chatGroupMembers, groupMessages, users } from '../db/schema.js';
|
||||
import { eq, and, desc, inArray, sql } 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
|
||||
const memberOf = await db
|
||||
.select({ groupId: chatGroupMembers.groupId })
|
||||
.from(chatGroupMembers)
|
||||
.where(eq(chatGroupMembers.userId, userId));
|
||||
|
||||
if (memberOf.length === 0) return [];
|
||||
|
||||
const groupIds = memberOf.map((m) => m.groupId);
|
||||
|
||||
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 and member count for each group
|
||||
const result = await Promise.all(
|
||||
groups.map(async (group) => {
|
||||
const lastMessage = await 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);
|
||||
|
||||
const members = await db
|
||||
.select({ id: chatGroupMembers.id })
|
||||
.from(chatGroupMembers)
|
||||
.where(eq(chatGroupMembers.groupId, group.id));
|
||||
|
||||
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,
|
||||
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');
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user