diff --git a/package-lock.json b/package-lock.json index 2f748bd..9e54ccb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "node-cron": "^4.2.1", "pg": "^8.16.3", "uuid": "^13.0.0", + "web-push": "^3.6.7", "xss-clean": "^0.1.4", "zod": "^4.1.12" }, @@ -2380,6 +2381,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2548,6 +2558,18 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2802,6 +2824,12 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -3434,7 +3462,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4787,6 +4814,15 @@ "dev": true, "license": "MIT" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4803,6 +4839,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -6316,6 +6365,12 @@ "node": ">=6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -8132,6 +8187,46 @@ "makeerror": "1.0.12" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/web-push/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 6454edb..ff4f2b9 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "node-cron": "^4.2.1", "pg": "^8.16.3", "uuid": "^13.0.0", + "web-push": "^3.6.7", "xss-clean": "^0.1.4", "zod": "^4.1.12" }, diff --git a/sql-fix.txt b/sql-fix.txt new file mode 100644 index 0000000..1800e1d --- /dev/null +++ b/sql-fix.txt @@ -0,0 +1,34 @@ +SQL príkazy pre Coolify: + + -- 1. NAJPRV: Pozri koľko dát sa vymaže + SELECT + (SELECT COUNT(*) FROM email_accounts) as email_accounts, + (SELECT COUNT(*) FROM user_email_accounts) as user_email_accounts, + (SELECT COUNT(*) FROM contacts) as contacts, + (SELECT COUNT(*) FROM emails) as emails; + + -- 2. VYMAŽ všetko (cascade sa postará o zvyšok) + DELETE FROM email_accounts; + + -- 3. OVER že je všetko prázdne + SELECT + (SELECT COUNT(*) FROM email_accounts) as email_accounts, + (SELECT COUNT(*) FROM user_email_accounts) as user_email_accounts, + (SELECT COUNT(*) FROM contacts) as contacts, + (SELECT COUNT(*) FROM emails) as emails; + + -- 4. SPUSTI INDEXY + CREATE INDEX IF NOT EXISTS idx_contacts_email_account_id ON contacts(email_account_id); + CREATE INDEX IF NOT EXISTS idx_contacts_company_id ON contacts(company_id); + CREATE INDEX IF NOT EXISTS idx_todos_project_id ON todos(project_id); + CREATE INDEX IF NOT EXISTS idx_todos_company_id ON todos(company_id); + CREATE INDEX IF NOT EXISTS idx_notes_company_id ON notes(company_id); + CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id); + CREATE INDEX IF NOT EXISTS idx_notes_todo_id ON notes(todo_id); + CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email); + CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(name); + CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name); + CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status); + CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status); + CREATE INDEX IF NOT EXISTS idx_todos_user_status ON todo_users(user_id, todo_id); + CREATE INDEX IF NOT EXISTS idx_time_entries_user_start ON time_entries(user_id, start_time); \ No newline at end of file diff --git a/src/app.js b/src/app.js index a9a1fce..b1d5162 100644 --- a/src/app.js +++ b/src/app.js @@ -26,6 +26,8 @@ import noteRoutes from './routes/note.routes.js'; import auditRoutes from './routes/audit.routes.js'; import eventRoutes from './routes/event.routes.js'; import messageRoutes from './routes/message.routes.js'; +import groupRoutes from './routes/group.routes.js'; +import pushRoutes from './routes/push.routes.js'; import userRoutes from './routes/user.routes.js'; import serviceRoutes from './routes/service.routes.js'; import emailSignatureRoutes from './routes/email-signature.routes.js'; @@ -123,6 +125,8 @@ app.use('/api/notes', noteRoutes); app.use('/api/audit-logs', auditRoutes); app.use('/api/events', eventRoutes); app.use('/api/messages', messageRoutes); +app.use('/api/groups', groupRoutes); +app.use('/api/push', pushRoutes); app.use('/api/users', userRoutes); app.use('/api/services', serviceRoutes); app.use('/api/email-signature', emailSignatureRoutes); diff --git a/src/controllers/group.controller.js b/src/controllers/group.controller.js new file mode 100644 index 0000000..81c5a64 --- /dev/null +++ b/src/controllers/group.controller.js @@ -0,0 +1,149 @@ +import * as groupService from '../services/group.service.js'; +import { logger } from '../utils/logger.js'; + +export const createGroup = async (req, res, next) => { + try { + const { name, memberIds } = req.body; + const userId = req.user.id; + + const group = await groupService.createGroup(name, userId, memberIds); + + res.status(201).json({ + success: true, + data: group, + message: 'Skupina bola vytvorená', + }); + } catch (error) { + logger.error('Create group error', error); + next(error); + } +}; + +export const getUserGroups = async (req, res, next) => { + try { + const groups = await groupService.getUserGroups(req.user.id); + + res.json({ + success: true, + data: groups, + }); + } catch (error) { + logger.error('Get groups error', error); + next(error); + } +}; + +export const getGroupDetails = async (req, res, next) => { + try { + const { groupId } = req.params; + const group = await groupService.getGroupDetails(groupId, req.user.id); + + res.json({ + success: true, + data: group, + }); + } catch (error) { + logger.error('Get group details error', error); + next(error); + } +}; + +export const getGroupMessages = async (req, res, next) => { + try { + const { groupId } = req.params; + const messages = await groupService.getGroupMessages(groupId, req.user.id); + + res.json({ + success: true, + data: messages, + }); + } catch (error) { + logger.error('Get group messages error', error); + next(error); + } +}; + +export const sendGroupMessage = async (req, res, next) => { + try { + const { groupId } = req.params; + const { content } = req.body; + + const message = await groupService.sendGroupMessage(groupId, req.user.id, content); + + res.status(201).json({ + success: true, + data: message, + message: 'Správa odoslaná', + }); + } catch (error) { + logger.error('Send group message error', error); + next(error); + } +}; + +export const addGroupMember = async (req, res, next) => { + try { + const { groupId } = req.params; + const { userId } = req.body; + + await groupService.addGroupMember(groupId, userId, req.user.id); + + res.json({ + success: true, + message: 'Člen bol pridaný', + }); + } catch (error) { + logger.error('Add member error', error); + next(error); + } +}; + +export const removeGroupMember = async (req, res, next) => { + try { + const { groupId, userId } = req.params; + + await groupService.removeGroupMember(groupId, userId, req.user.id); + + res.json({ + success: true, + message: 'Člen bol odstránený', + }); + } catch (error) { + logger.error('Remove member error', error); + next(error); + } +}; + +export const updateGroupName = async (req, res, next) => { + try { + const { groupId } = req.params; + const { name } = req.body; + + const group = await groupService.updateGroupName(groupId, name, req.user.id); + + res.json({ + success: true, + data: group, + message: 'Názov skupiny bol aktualizovaný', + }); + } catch (error) { + logger.error('Update group name error', error); + next(error); + } +}; + +export const deleteGroup = async (req, res, next) => { + try { + const { groupId } = req.params; + + await groupService.deleteGroup(groupId, req.user.id); + + res.json({ + success: true, + message: 'Skupina bola odstránená', + }); + } catch (error) { + logger.error('Delete group error', error); + next(error); + } +}; diff --git a/src/controllers/push.controller.js b/src/controllers/push.controller.js new file mode 100644 index 0000000..07b3677 --- /dev/null +++ b/src/controllers/push.controller.js @@ -0,0 +1,117 @@ +import * as pushService from '../services/push.service.js'; +import { logger } from '../utils/logger.js'; + +/** + * Get VAPID public key + */ +export const getVapidPublicKey = (req, res) => { + const publicKey = pushService.getVapidPublicKey(); + + if (!publicKey) { + return res.status(503).json({ + success: false, + message: 'Push notifikácie nie sú nakonfigurované', + }); + } + + res.json({ + success: true, + data: { publicKey }, + }); +}; + +/** + * Subscribe to push notifications + */ +export const subscribe = async (req, res, next) => { + try { + const { subscription } = req.body; + const userId = req.user.id; + + if (!subscription || !subscription.endpoint || !subscription.keys) { + return res.status(400).json({ + success: false, + message: 'Neplatná subscription', + }); + } + + await pushService.saveSubscription(userId, subscription); + + res.json({ + success: true, + message: 'Push notifikácie aktivované', + }); + } catch (error) { + logger.error('Subscribe error', error); + next(error); + } +}; + +/** + * Unsubscribe from push notifications + */ +export const unsubscribe = async (req, res, next) => { + try { + const { endpoint } = req.body; + const userId = req.user.id; + + if (endpoint) { + await pushService.removeSubscription(userId, endpoint); + } else { + await pushService.removeAllSubscriptions(userId); + } + + res.json({ + success: true, + message: 'Push notifikácie deaktivované', + }); + } catch (error) { + logger.error('Unsubscribe error', error); + next(error); + } +}; + +/** + * Check subscription status + */ +export const getStatus = async (req, res, next) => { + try { + const userId = req.user.id; + const hasSubscription = await pushService.hasActiveSubscription(userId); + + res.json({ + success: true, + data: { + enabled: hasSubscription, + supported: !!pushService.getVapidPublicKey(), + }, + }); + } catch (error) { + logger.error('Get status error', error); + next(error); + } +}; + +/** + * Test push notification (for debugging) + */ +export const testPush = async (req, res, next) => { + try { + const userId = req.user.id; + + const result = await pushService.sendPushNotification(userId, { + title: 'Test notifikácie', + body: 'Toto je testovacia push notifikácia z CRM', + icon: '/icon-192.png', + data: { url: '/' }, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + logger.error('Test push error', error); + next(error); + } +}; diff --git a/src/controllers/todo.controller.js b/src/controllers/todo.controller.js index d38420a..14481d7 100644 --- a/src/controllers/todo.controller.js +++ b/src/controllers/todo.controller.js @@ -148,7 +148,7 @@ export const updateTodo = async (req, res, next) => { // Get old todo for audit const oldTodo = await todoService.getTodoById(todoId); - const todo = await todoService.updateTodo(todoId, data); + const todo = await todoService.updateTodo(todoId, data, userId); // Log audit event await logTodoUpdated( diff --git a/src/db/schema.js b/src/db/schema.js index 880f1dd..7d16bf5 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -337,3 +337,43 @@ export const messages = pgTable('messages', { createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); + +// Chat Groups table - skupinové chaty +export const chatGroups = pgTable('chat_groups', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + createdById: uuid('created_by_id').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}); + +// Chat Group Members - členovia skupiny +export const chatGroupMembers = pgTable('chat_group_members', { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').references(() => chatGroups.id, { onDelete: 'cascade' }).notNull(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + joinedAt: timestamp('joined_at').defaultNow().notNull(), +}, (table) => ({ + uniqueMember: unique('chat_group_member_unique').on(table.groupId, table.userId), +})); + +// Group Messages - správy v skupinách +export const groupMessages = pgTable('group_messages', { + id: uuid('id').primaryKey().defaultRandom(), + groupId: uuid('group_id').references(() => chatGroups.id, { onDelete: 'cascade' }).notNull(), + senderId: uuid('sender_id').references(() => users.id, { onDelete: 'set null' }), + content: text('content').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); + +// Push Subscriptions - web push notifikácie +export const pushSubscriptions = pgTable('push_subscriptions', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + endpoint: text('endpoint').notNull(), + p256dh: text('p256dh').notNull(), + auth: text('auth').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, (table) => ({ + uniqueEndpoint: unique('push_subscription_endpoint_unique').on(table.userId, table.endpoint), +})); diff --git a/src/routes/group.routes.js b/src/routes/group.routes.js new file mode 100644 index 0000000..e18bf08 --- /dev/null +++ b/src/routes/group.routes.js @@ -0,0 +1,88 @@ +import express from 'express'; +import * as groupController from '../controllers/group.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// All group routes require authentication +router.use(authenticate); + +// Create a new group +router.post( + '/', + validateBody( + z.object({ + name: z.string().min(1, 'Názov skupiny je povinný').max(100, 'Názov je príliš dlhý'), + memberIds: z.array(z.string().uuid()).min(1, 'Vyberte aspoň jedného člena'), + }) + ), + groupController.createGroup +); + +// Get all groups for current user +router.get('/', groupController.getUserGroups); + +// Get group details +router.get( + '/:groupId', + validateParams(z.object({ groupId: z.string().uuid() })), + groupController.getGroupDetails +); + +// Update group name +router.patch( + '/:groupId', + validateParams(z.object({ groupId: z.string().uuid() })), + validateBody(z.object({ name: z.string().min(1).max(100) })), + groupController.updateGroupName +); + +// Delete group +router.delete( + '/:groupId', + validateParams(z.object({ groupId: z.string().uuid() })), + groupController.deleteGroup +); + +// Get group messages +router.get( + '/:groupId/messages', + validateParams(z.object({ groupId: z.string().uuid() })), + groupController.getGroupMessages +); + +// Send message to group +router.post( + '/:groupId/messages', + validateParams(z.object({ groupId: z.string().uuid() })), + validateBody( + z.object({ + content: z.string().min(1, 'Správa nemôže byť prázdna').max(5000, 'Správa je príliš dlhá'), + }) + ), + groupController.sendGroupMessage +); + +// Add member to group +router.post( + '/:groupId/members', + validateParams(z.object({ groupId: z.string().uuid() })), + validateBody(z.object({ userId: z.string().uuid() })), + groupController.addGroupMember +); + +// Remove member from group +router.delete( + '/:groupId/members/:userId', + validateParams( + z.object({ + groupId: z.string().uuid(), + userId: z.string().uuid(), + }) + ), + groupController.removeGroupMember +); + +export default router; diff --git a/src/routes/push.routes.js b/src/routes/push.routes.js new file mode 100644 index 0000000..af4fa0d --- /dev/null +++ b/src/routes/push.routes.js @@ -0,0 +1,49 @@ +import express from 'express'; +import * as pushController from '../controllers/push.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { validateBody } from '../middlewares/security/validateInput.js'; +import { z } from 'zod'; + +const router = express.Router(); + +// Get VAPID public key (no auth required) +router.get('/vapid-public-key', pushController.getVapidPublicKey); + +// All other routes require authentication +router.use(authenticate); + +// Subscribe to push notifications +router.post( + '/subscribe', + validateBody( + z.object({ + subscription: z.object({ + endpoint: z.string().url(), + keys: z.object({ + p256dh: z.string(), + auth: z.string(), + }), + }), + }) + ), + pushController.subscribe +); + +// Unsubscribe from push notifications +router.post( + '/unsubscribe', + validateBody( + z.object({ + endpoint: z.string().url().optional(), + }).optional() + ), + pushController.unsubscribe +); + +// Get subscription status +router.get('/status', pushController.getStatus); + +// Test push notification +router.post('/test', pushController.testPush); + +export default router; diff --git a/src/services/group.service.js b/src/services/group.service.js new file mode 100644 index 0000000..71198b3 --- /dev/null +++ b/src/services/group.service.js @@ -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 }; +}; diff --git a/src/services/message.service.js b/src/services/message.service.js index 6728ed0..6249a06 100644 --- a/src/services/message.service.js +++ b/src/services/message.service.js @@ -2,6 +2,8 @@ import { db } from '../config/database.js'; import { messages, users } from '../db/schema.js'; import { eq, and, or, desc, ne, sql } from 'drizzle-orm'; import { NotFoundError } from '../utils/errors.js'; +import { sendPushNotification } from './push.service.js'; +import { logger } from '../utils/logger.js'; /** * Get all conversations for a user @@ -188,6 +190,27 @@ export const sendMessage = async (senderId, receiverId, content) => { }) .returning(); + // Send push notification to receiver + try { + const [sender] = await db + .select({ firstName: users.firstName, username: users.username }) + .from(users) + .where(eq(users.id, senderId)) + .limit(1); + + const senderName = sender?.firstName || sender?.username || 'Niekto'; + + await sendPushNotification(receiverId, { + title: `Nová správa od ${senderName}`, + body: content.trim().substring(0, 100) + (content.length > 100 ? '...' : ''), + icon: '/icon-192.png', + badge: '/badge-72.png', + data: { url: `/chat/${senderId}` }, + }); + } catch (error) { + logger.error('Failed to send push notification for direct message', error); + } + return { id: newMessage.id, content: newMessage.content, diff --git a/src/services/push.service.js b/src/services/push.service.js new file mode 100644 index 0000000..773fb9e --- /dev/null +++ b/src/services/push.service.js @@ -0,0 +1,172 @@ +import webpush from 'web-push'; +import { db } from '../config/database.js'; +import { pushSubscriptions, users } from '../db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import { logger } from '../utils/logger.js'; + +// Configure web-push with VAPID keys +if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) { + webpush.setVapidDetails( + process.env.VAPID_SUBJECT || 'mailto:admin@example.com', + process.env.VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY + ); + logger.info('Web Push configured with VAPID keys'); +} else { + logger.warn('VAPID keys not configured - push notifications disabled'); +} + +/** + * Save a push subscription for a user + */ +export const saveSubscription = async (userId, subscription) => { + const { endpoint, keys } = subscription; + + // Check if subscription already exists + const existing = await db + .select() + .from(pushSubscriptions) + .where( + and( + eq(pushSubscriptions.userId, userId), + eq(pushSubscriptions.endpoint, endpoint) + ) + ) + .limit(1); + + if (existing.length > 0) { + // Update existing subscription + await db + .update(pushSubscriptions) + .set({ + p256dh: keys.p256dh, + auth: keys.auth, + }) + .where(eq(pushSubscriptions.id, existing[0].id)); + } else { + // Create new subscription + await db.insert(pushSubscriptions).values({ + userId, + endpoint, + p256dh: keys.p256dh, + auth: keys.auth, + }); + } + + return { success: true }; +}; + +/** + * Remove a push subscription + */ +export const removeSubscription = async (userId, endpoint) => { + await db + .delete(pushSubscriptions) + .where( + and( + eq(pushSubscriptions.userId, userId), + eq(pushSubscriptions.endpoint, endpoint) + ) + ); + + return { success: true }; +}; + +/** + * Remove all subscriptions for a user + */ +export const removeAllSubscriptions = async (userId) => { + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)); + + return { success: true }; +}; + +/** + * Send a push notification to a specific user + */ +export const sendPushNotification = async (userId, payload) => { + if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) { + logger.warn('Push notification skipped - VAPID keys not configured'); + return { success: false, reason: 'not_configured' }; + } + + const subscriptions = await db + .select() + .from(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)); + + if (subscriptions.length === 0) { + return { success: false, reason: 'no_subscriptions' }; + } + + const results = await Promise.allSettled( + subscriptions.map(async (sub) => { + const pushSubscription = { + endpoint: sub.endpoint, + keys: { + p256dh: sub.p256dh, + auth: sub.auth, + }, + }; + + try { + await webpush.sendNotification( + pushSubscription, + JSON.stringify(payload) + ); + return { success: true, endpoint: sub.endpoint }; + } catch (error) { + // 410 Gone means subscription expired + if (error.statusCode === 410 || error.statusCode === 404) { + logger.info(`Removing expired subscription for user ${userId}`); + await db + .delete(pushSubscriptions) + .where(eq(pushSubscriptions.id, sub.id)); + } else { + logger.error('Push notification error', error); + } + return { success: false, endpoint: sub.endpoint, error: error.message }; + } + }) + ); + + const successful = results.filter(r => r.status === 'fulfilled' && r.value.success).length; + return { success: successful > 0, sent: successful, total: subscriptions.length }; +}; + +/** + * Send push notification to multiple users + */ +export const sendPushNotificationToUsers = async (userIds, payload, excludeUserId = null) => { + const targetUserIds = excludeUserId + ? userIds.filter(id => id !== excludeUserId) + : userIds; + + const results = await Promise.allSettled( + targetUserIds.map(userId => sendPushNotification(userId, payload)) + ); + + return results; +}; + +/** + * Check if user has push notifications enabled + */ +export const hasActiveSubscription = async (userId) => { + const subscriptions = await db + .select({ id: pushSubscriptions.id }) + .from(pushSubscriptions) + .where(eq(pushSubscriptions.userId, userId)) + .limit(1); + + return subscriptions.length > 0; +}; + +/** + * Get VAPID public key for client + */ +export const getVapidPublicKey = () => { + return process.env.VAPID_PUBLIC_KEY || null; +}; diff --git a/src/services/todo.service.js b/src/services/todo.service.js index b8f2454..c592418 100644 --- a/src/services/todo.service.js +++ b/src/services/todo.service.js @@ -3,6 +3,8 @@ import { todos, todoUsers, notes, projects, companies, users } from '../db/schem import { eq, desc, ilike, or, and, inArray } from 'drizzle-orm'; import { NotFoundError } from '../utils/errors.js'; import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js'; +import { sendPushNotificationToUsers } from './push.service.js'; +import { logger } from '../utils/logger.js'; /** * Get all todos @@ -217,6 +219,25 @@ export const createTodo = async (userId, data) => { }); } + // Send push notifications to assigned users (excluding creator) + if (assignedUserIds && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) { + try { + await sendPushNotificationToUsers( + assignedUserIds, + { + title: 'Nová úloha', + body: `Bola vám priradená úloha: ${title}`, + icon: '/icon-192.png', + badge: '/badge-72.png', + data: { url: '/todos', todoId: newTodo.id }, + }, + userId // exclude creator + ); + } catch (error) { + logger.error('Failed to send push notifications for new todo', error); + } + } + return newTodo; }; @@ -224,8 +245,9 @@ export const createTodo = async (userId, data) => { * Update todo * @param {string} todoId - ID of todo to update * @param {object} data - Updated data including assignedUserIds array + * @param {string} updatedByUserId - ID of user making the update (for notifications) */ -export const updateTodo = async (todoId, data) => { +export const updateTodo = async (todoId, data, updatedByUserId = null) => { const todo = await getTodoById(todoId); const { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } = data; @@ -294,6 +316,13 @@ export const updateTodo = async (todoId, data) => { // Update assigned users if provided if (assignedUserIds !== undefined) { + // Get existing assigned users before deleting + const existingAssignments = await db + .select({ userId: todoUsers.userId }) + .from(todoUsers) + .where(eq(todoUsers.todoId, todoId)); + const existingUserIds = existingAssignments.map(a => a.userId); + // Delete existing assignments await db.delete(todoUsers).where(eq(todoUsers.todoId, todoId)); @@ -302,10 +331,34 @@ export const updateTodo = async (todoId, data) => { const todoUserInserts = assignedUserIds.map((userId) => ({ todoId: todoId, userId: userId, - assignedBy: null, // We don't track who made the update + assignedBy: updatedByUserId, })); await db.insert(todoUsers).values(todoUserInserts); + + // Find newly assigned users (not in existing list) + const newlyAssignedUserIds = assignedUserIds.filter( + id => !existingUserIds.includes(id) + ); + + // Send push notifications to newly assigned users + if (newlyAssignedUserIds.length > 0) { + try { + await sendPushNotificationToUsers( + newlyAssignedUserIds, + { + title: 'Priradená úloha', + body: `Bola vám priradená úloha: ${updated.title}`, + icon: '/icon-192.png', + badge: '/badge-72.png', + data: { url: '/todos', todoId: todoId }, + }, + updatedByUserId // exclude user making the change + ); + } catch (error) { + logger.error('Failed to send push notifications for updated todo', error); + } + } } } diff --git a/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2026/1/company-timesheet-inbox_sk-2026-01-1768802742128.xlsx b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2026/1/company-timesheet-inbox_sk-2026-01-1768802742128.xlsx new file mode 100644 index 0000000..2ac3225 Binary files /dev/null and b/uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2026/1/company-timesheet-inbox_sk-2026-01-1768802742128.xlsx differ