feat: Group chat and push notifications
- Add group chat tables (chat_groups, chat_group_members, group_messages) - Add push subscriptions table for web push notifications - Add group service, controller, routes - Add push service, controller, routes - Integrate push notifications with todos, messages, group messages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
97
package-lock.json
generated
97
package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"xss-clean": "^0.1.4",
|
"xss-clean": "^0.1.4",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
@@ -2380,6 +2381,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -2548,6 +2558,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -2802,6 +2824,12 @@
|
|||||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -3434,7 +3462,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -4787,6 +4814,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -4803,6 +4839,19 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
@@ -6316,6 +6365,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -8132,6 +8187,46 @@
|
|||||||
"makeerror": "1.0.12"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"xss-clean": "^0.1.4",
|
"xss-clean": "^0.1.4",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
|
|||||||
34
sql-fix.txt
Normal file
34
sql-fix.txt
Normal file
@@ -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);
|
||||||
@@ -26,6 +26,8 @@ import noteRoutes from './routes/note.routes.js';
|
|||||||
import auditRoutes from './routes/audit.routes.js';
|
import auditRoutes from './routes/audit.routes.js';
|
||||||
import eventRoutes from './routes/event.routes.js';
|
import eventRoutes from './routes/event.routes.js';
|
||||||
import messageRoutes from './routes/message.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 userRoutes from './routes/user.routes.js';
|
||||||
import serviceRoutes from './routes/service.routes.js';
|
import serviceRoutes from './routes/service.routes.js';
|
||||||
import emailSignatureRoutes from './routes/email-signature.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/audit-logs', auditRoutes);
|
||||||
app.use('/api/events', eventRoutes);
|
app.use('/api/events', eventRoutes);
|
||||||
app.use('/api/messages', messageRoutes);
|
app.use('/api/messages', messageRoutes);
|
||||||
|
app.use('/api/groups', groupRoutes);
|
||||||
|
app.use('/api/push', pushRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/services', serviceRoutes);
|
app.use('/api/services', serviceRoutes);
|
||||||
app.use('/api/email-signature', emailSignatureRoutes);
|
app.use('/api/email-signature', emailSignatureRoutes);
|
||||||
|
|||||||
149
src/controllers/group.controller.js
Normal file
149
src/controllers/group.controller.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
117
src/controllers/push.controller.js
Normal file
117
src/controllers/push.controller.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -148,7 +148,7 @@ export const updateTodo = async (req, res, next) => {
|
|||||||
// Get old todo for audit
|
// Get old todo for audit
|
||||||
const oldTodo = await todoService.getTodoById(todoId);
|
const oldTodo = await todoService.getTodoById(todoId);
|
||||||
|
|
||||||
const todo = await todoService.updateTodo(todoId, data);
|
const todo = await todoService.updateTodo(todoId, data, userId);
|
||||||
|
|
||||||
// Log audit event
|
// Log audit event
|
||||||
await logTodoUpdated(
|
await logTodoUpdated(
|
||||||
|
|||||||
@@ -337,3 +337,43 @@ export const messages = pgTable('messages', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_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),
|
||||||
|
}));
|
||||||
|
|||||||
88
src/routes/group.routes.js
Normal file
88
src/routes/group.routes.js
Normal file
@@ -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;
|
||||||
49
src/routes/push.routes.js
Normal file
49
src/routes/push.routes.js
Normal file
@@ -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;
|
||||||
404
src/services/group.service.js
Normal file
404
src/services/group.service.js
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { db } from '../config/database.js';
|
||||||
|
import { chatGroups, chatGroupMembers, groupMessages, users } from '../db/schema.js';
|
||||||
|
import { eq, and, desc, inArray, sql } from 'drizzle-orm';
|
||||||
|
import { NotFoundError, ForbiddenError } from '../utils/errors.js';
|
||||||
|
import { sendPushNotificationToUsers } from './push.service.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new group chat
|
||||||
|
*/
|
||||||
|
export const createGroup = async (name, creatorId, memberIds) => {
|
||||||
|
const [group] = await db
|
||||||
|
.insert(chatGroups)
|
||||||
|
.values({
|
||||||
|
name: name.trim(),
|
||||||
|
createdById: creatorId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Add creator and all members (ensure unique)
|
||||||
|
const allMemberIds = [...new Set([creatorId, ...memberIds])];
|
||||||
|
|
||||||
|
await db.insert(chatGroupMembers).values(
|
||||||
|
allMemberIds.map((userId) => ({
|
||||||
|
groupId: group.id,
|
||||||
|
userId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all groups for a user
|
||||||
|
*/
|
||||||
|
export const getUserGroups = async (userId) => {
|
||||||
|
// Get groups where user is a member
|
||||||
|
const memberOf = await db
|
||||||
|
.select({ groupId: chatGroupMembers.groupId })
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(eq(chatGroupMembers.userId, userId));
|
||||||
|
|
||||||
|
if (memberOf.length === 0) return [];
|
||||||
|
|
||||||
|
const groupIds = memberOf.map((m) => m.groupId);
|
||||||
|
|
||||||
|
const groups = await db
|
||||||
|
.select({
|
||||||
|
id: chatGroups.id,
|
||||||
|
name: chatGroups.name,
|
||||||
|
createdById: chatGroups.createdById,
|
||||||
|
createdAt: chatGroups.createdAt,
|
||||||
|
updatedAt: chatGroups.updatedAt,
|
||||||
|
})
|
||||||
|
.from(chatGroups)
|
||||||
|
.where(inArray(chatGroups.id, groupIds))
|
||||||
|
.orderBy(desc(chatGroups.updatedAt));
|
||||||
|
|
||||||
|
// Get last message and member count for each group
|
||||||
|
const result = await Promise.all(
|
||||||
|
groups.map(async (group) => {
|
||||||
|
const lastMessage = await db
|
||||||
|
.select({
|
||||||
|
content: groupMessages.content,
|
||||||
|
createdAt: groupMessages.createdAt,
|
||||||
|
senderId: groupMessages.senderId,
|
||||||
|
senderFirstName: users.firstName,
|
||||||
|
senderUsername: users.username,
|
||||||
|
})
|
||||||
|
.from(groupMessages)
|
||||||
|
.leftJoin(users, eq(groupMessages.senderId, users.id))
|
||||||
|
.where(eq(groupMessages.groupId, group.id))
|
||||||
|
.orderBy(desc(groupMessages.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const members = await db
|
||||||
|
.select({ id: chatGroupMembers.id })
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(eq(chatGroupMembers.groupId, group.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
lastMessage: lastMessage[0] ? {
|
||||||
|
content: lastMessage[0].content,
|
||||||
|
createdAt: lastMessage[0].createdAt,
|
||||||
|
senderName: lastMessage[0].senderFirstName || lastMessage[0].senderUsername,
|
||||||
|
isMine: lastMessage[0].senderId === userId,
|
||||||
|
} : null,
|
||||||
|
memberCount: members.length,
|
||||||
|
type: 'group',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get group details with members
|
||||||
|
*/
|
||||||
|
export const getGroupDetails = async (groupId, userId) => {
|
||||||
|
// Verify user is member
|
||||||
|
const [isMember] = await db
|
||||||
|
.select()
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatGroupMembers.groupId, groupId),
|
||||||
|
eq(chatGroupMembers.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!isMember) {
|
||||||
|
throw new ForbiddenError('Nie ste členom tejto skupiny');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.select()
|
||||||
|
.from(chatGroups)
|
||||||
|
.where(eq(chatGroups.id, groupId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundError('Skupina nenájdená');
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
firstName: users.firstName,
|
||||||
|
lastName: users.lastName,
|
||||||
|
role: users.role,
|
||||||
|
})
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.innerJoin(users, eq(chatGroupMembers.userId, users.id))
|
||||||
|
.where(eq(chatGroupMembers.groupId, groupId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages for a group
|
||||||
|
*/
|
||||||
|
export const getGroupMessages = async (groupId, userId) => {
|
||||||
|
// Verify user is member
|
||||||
|
const [isMember] = await db
|
||||||
|
.select()
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatGroupMembers.groupId, groupId),
|
||||||
|
eq(chatGroupMembers.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!isMember) {
|
||||||
|
throw new ForbiddenError('Nie ste členom tejto skupiny');
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await db
|
||||||
|
.select({
|
||||||
|
id: groupMessages.id,
|
||||||
|
content: groupMessages.content,
|
||||||
|
createdAt: groupMessages.createdAt,
|
||||||
|
senderId: groupMessages.senderId,
|
||||||
|
senderUsername: users.username,
|
||||||
|
senderFirstName: users.firstName,
|
||||||
|
senderLastName: users.lastName,
|
||||||
|
})
|
||||||
|
.from(groupMessages)
|
||||||
|
.leftJoin(users, eq(groupMessages.senderId, users.id))
|
||||||
|
.where(eq(groupMessages.groupId, groupId))
|
||||||
|
.orderBy(groupMessages.createdAt);
|
||||||
|
|
||||||
|
return messages.map((msg) => ({
|
||||||
|
id: msg.id,
|
||||||
|
content: msg.content,
|
||||||
|
createdAt: msg.createdAt,
|
||||||
|
senderId: msg.senderId,
|
||||||
|
senderName: msg.senderFirstName || msg.senderUsername || 'Neznámy',
|
||||||
|
isMine: msg.senderId === userId,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to group
|
||||||
|
*/
|
||||||
|
export const sendGroupMessage = async (groupId, senderId, content) => {
|
||||||
|
// Verify user is member
|
||||||
|
const [isMember] = await db
|
||||||
|
.select()
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatGroupMembers.groupId, groupId),
|
||||||
|
eq(chatGroupMembers.userId, senderId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!isMember) {
|
||||||
|
throw new ForbiddenError('Nie ste členom tejto skupiny');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [message] = await db
|
||||||
|
.insert(groupMessages)
|
||||||
|
.values({
|
||||||
|
groupId,
|
||||||
|
senderId,
|
||||||
|
content: content.trim(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Update group's updatedAt
|
||||||
|
await db
|
||||||
|
.update(chatGroups)
|
||||||
|
.set({ updatedAt: new Date() })
|
||||||
|
.where(eq(chatGroups.id, groupId));
|
||||||
|
|
||||||
|
// Send push notifications to all group members (except sender)
|
||||||
|
try {
|
||||||
|
const [group] = await db
|
||||||
|
.select({ name: chatGroups.name })
|
||||||
|
.from(chatGroups)
|
||||||
|
.where(eq(chatGroups.id, groupId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [sender] = await db
|
||||||
|
.select({ firstName: users.firstName, username: users.username })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, senderId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const members = await db
|
||||||
|
.select({ userId: chatGroupMembers.userId })
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(eq(chatGroupMembers.groupId, groupId));
|
||||||
|
|
||||||
|
const memberIds = members.map(m => m.userId);
|
||||||
|
const senderName = sender?.firstName || sender?.username || 'Niekto';
|
||||||
|
const groupName = group?.name || 'Skupina';
|
||||||
|
|
||||||
|
await sendPushNotificationToUsers(
|
||||||
|
memberIds,
|
||||||
|
{
|
||||||
|
title: `${groupName}`,
|
||||||
|
body: `${senderName}: ${content.trim().substring(0, 80)}${content.length > 80 ? '...' : ''}`,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/badge-72.png',
|
||||||
|
data: { url: `/chat/group/${groupId}` },
|
||||||
|
},
|
||||||
|
senderId // exclude sender
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send push notifications for group message', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
content: message.content,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
senderId: message.senderId,
|
||||||
|
isMine: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add member to group
|
||||||
|
*/
|
||||||
|
export const addGroupMember = async (groupId, userId, requesterId) => {
|
||||||
|
// Verify requester is member
|
||||||
|
const [isMember] = await db
|
||||||
|
.select()
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatGroupMembers.groupId, groupId),
|
||||||
|
eq(chatGroupMembers.userId, requesterId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!isMember) {
|
||||||
|
throw new ForbiddenError('Nie ste členom tejto skupiny');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already member
|
||||||
|
const [alreadyMember] = await db
|
||||||
|
.select()
|
||||||
|
.from(chatGroupMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatGroupMembers.groupId, groupId),
|
||||||
|
eq(chatGroupMembers.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (alreadyMember) {
|
||||||
|
throw new Error('Používateľ je už členom skupiny');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user exists
|
||||||
|
const [userExists] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userExists) {
|
||||||
|
throw new NotFoundError('Používateľ nenájdený');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(chatGroupMembers).values({ groupId, userId });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove member from group (or leave)
|
||||||
|
*/
|
||||||
|
export const removeGroupMember = async (groupId, userId, requesterId) => {
|
||||||
|
// User can remove themselves
|
||||||
|
if (userId !== requesterId) {
|
||||||
|
// Check if requester is the creator
|
||||||
|
const [group] = await db
|
||||||
|
.select({ createdById: chatGroups.createdById })
|
||||||
|
.from(chatGroups)
|
||||||
|
.where(eq(chatGroups.id, groupId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!group || group.createdById !== requesterId) {
|
||||||
|
throw new ForbiddenError('Nemáte oprávnenie odstrániť tohto člena');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(chatGroupMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(chatGroupMembers.groupId, groupId),
|
||||||
|
eq(chatGroupMembers.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update group name
|
||||||
|
*/
|
||||||
|
export const updateGroupName = async (groupId, name, requesterId) => {
|
||||||
|
// Verify requester is the creator
|
||||||
|
const [group] = await db
|
||||||
|
.select({ createdById: chatGroups.createdById })
|
||||||
|
.from(chatGroups)
|
||||||
|
.where(eq(chatGroups.id, groupId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundError('Skupina nenájdená');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.createdById !== requesterId) {
|
||||||
|
throw new ForbiddenError('Nemáte oprávnenie upraviť názov skupiny');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(chatGroups)
|
||||||
|
.set({ name: name.trim(), updatedAt: new Date() })
|
||||||
|
.where(eq(chatGroups.id, groupId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete group (only creator can delete)
|
||||||
|
*/
|
||||||
|
export const deleteGroup = async (groupId, requesterId) => {
|
||||||
|
const [group] = await db
|
||||||
|
.select({ createdById: chatGroups.createdById })
|
||||||
|
.from(chatGroups)
|
||||||
|
.where(eq(chatGroups.id, groupId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw new NotFoundError('Skupina nenájdená');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.createdById !== requesterId) {
|
||||||
|
throw new ForbiddenError('Nemáte oprávnenie odstrániť túto skupinu');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(chatGroups).where(eq(chatGroups.id, groupId));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
@@ -2,6 +2,8 @@ import { db } from '../config/database.js';
|
|||||||
import { messages, users } from '../db/schema.js';
|
import { messages, users } from '../db/schema.js';
|
||||||
import { eq, and, or, desc, ne, sql } from 'drizzle-orm';
|
import { eq, and, or, desc, ne, sql } from 'drizzle-orm';
|
||||||
import { NotFoundError } from '../utils/errors.js';
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
|
import { sendPushNotification } from './push.service.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all conversations for a user
|
* Get all conversations for a user
|
||||||
@@ -188,6 +190,27 @@ export const sendMessage = async (senderId, receiverId, content) => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.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 {
|
return {
|
||||||
id: newMessage.id,
|
id: newMessage.id,
|
||||||
content: newMessage.content,
|
content: newMessage.content,
|
||||||
|
|||||||
172
src/services/push.service.js
Normal file
172
src/services/push.service.js
Normal file
@@ -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;
|
||||||
|
};
|
||||||
@@ -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 { eq, desc, ilike, or, and, inArray } from 'drizzle-orm';
|
||||||
import { NotFoundError } from '../utils/errors.js';
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js';
|
import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js';
|
||||||
|
import { sendPushNotificationToUsers } from './push.service.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all todos
|
* 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;
|
return newTodo;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,8 +245,9 @@ export const createTodo = async (userId, data) => {
|
|||||||
* Update todo
|
* Update todo
|
||||||
* @param {string} todoId - ID of todo to update
|
* @param {string} todoId - ID of todo to update
|
||||||
* @param {object} data - Updated data including assignedUserIds array
|
* @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 todo = await getTodoById(todoId);
|
||||||
|
|
||||||
const { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } = data;
|
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
|
// Update assigned users if provided
|
||||||
if (assignedUserIds !== undefined) {
|
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
|
// Delete existing assignments
|
||||||
await db.delete(todoUsers).where(eq(todoUsers.todoId, todoId));
|
await db.delete(todoUsers).where(eq(todoUsers.todoId, todoId));
|
||||||
|
|
||||||
@@ -302,10 +331,34 @@ export const updateTodo = async (todoId, data) => {
|
|||||||
const todoUserInserts = assignedUserIds.map((userId) => ({
|
const todoUserInserts = assignedUserIds.map((userId) => ({
|
||||||
todoId: todoId,
|
todoId: todoId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
assignedBy: null, // We don't track who made the update
|
assignedBy: updatedByUserId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await db.insert(todoUsers).values(todoUserInserts);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user