- Add AI Kurzy module with courses, participants, and registrations management - Add project documents and service documents features - Add service folders for document organization - Add SQL import queries for services from firmy.slovensko.ai - Update todo notifications and group messaging - Various API improvements and bug fixes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
473 lines
12 KiB
JavaScript
473 lines
12 KiB
JavaScript
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;
|
|
};
|