From d9f16ad0a6a3dba816a04b5ed9b06f6cbaa19229 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Tue, 20 Jan 2026 07:27:13 +0100 Subject: [PATCH] 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 --- package-lock.json | 97 ++++- package.json | 1 + sql-fix.txt | 34 ++ src/app.js | 4 + src/controllers/group.controller.js | 149 +++++++ src/controllers/push.controller.js | 117 +++++ src/controllers/todo.controller.js | 2 +- src/db/schema.js | 40 ++ src/routes/group.routes.js | 88 ++++ src/routes/push.routes.js | 49 +++ src/services/group.service.js | 404 ++++++++++++++++++ src/services/message.service.js | 23 + src/services/push.service.js | 172 ++++++++ src/services/todo.service.js | 57 ++- ...esheet-inbox_sk-2026-01-1768802742128.xlsx | Bin 0 -> 7233 bytes 15 files changed, 1233 insertions(+), 4 deletions(-) create mode 100644 sql-fix.txt create mode 100644 src/controllers/group.controller.js create mode 100644 src/controllers/push.controller.js create mode 100644 src/routes/group.routes.js create mode 100644 src/routes/push.routes.js create mode 100644 src/services/group.service.js create mode 100644 src/services/push.service.js create mode 100644 uploads/timesheets/68927352-725c-4e95-adb6-d4002b22bef5/2026/1/company-timesheet-inbox_sk-2026-01-1768802742128.xlsx 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 0000000000000000000000000000000000000000..2ac322562cf87d4d09e8debd059be772e5dcc991 GIT binary patch literal 7233 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 X1Eh@hGuH@_jYtP_M?x;de8B$!2bdAl literal 0 HcmV?d00001