From 33120ebecaf915bac422b83c5cf58f244dece6b8 Mon Sep 17 00:00:00 2001 From: orangecoding Date: Tue, 7 Oct 2025 21:06:59 +0200 Subject: [PATCH] ability to share jobs with users --- lib/api/routes/jobRouter.js | 41 +++++++++++- lib/api/routes/userRoute.js | 2 + lib/services/storage/jobStorage.js | 22 +++++-- .../storage/migrations/sql/5.job-sharing.js | 7 ++ package.json | 8 +-- ui/src/App.jsx | 1 + ui/src/components/table/JobTable.jsx | 62 +++++++++++++++--- .../table/listings/ListingsFilter.jsx | 12 +++- ui/src/services/state/store.js | 10 ++- ui/src/views/jobs/mutation/JobMutation.jsx | 42 ++++++++++-- yarn.lock | 65 ++++++++++--------- 11 files changed, 215 insertions(+), 57 deletions(-) create mode 100644 lib/services/storage/migrations/sql/5.job-sharing.js diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 9cc4a2c..3b17c09 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -24,9 +24,25 @@ function doesJobBelongsToUser(job, req) { jobRouter.get('/', async (req, res) => { const isUserAdmin = isAdmin(req); //show only the jobs which belongs to the user (or all of the user is an admin) - res.body = jobStorage.getJobs().filter((job) => isUserAdmin || job.userId === req.session.currentUser); + res.body = jobStorage + .getJobs() + .filter( + (job) => + isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser), + ) + .map((job) => { + return { + ...job, + isOnlyShared: + !isUserAdmin && + job.userId !== req.session.currentUser && + job.shared_with_user.includes(req.session.currentUser), + }; + }); + res.send(); }); + jobRouter.get('/processingTimes', async (req, res) => { res.body = { interval: config.interval, @@ -41,8 +57,15 @@ jobRouter.post('/startAll', async (req, res) => { }); jobRouter.post('/', async (req, res) => { - const { provider, notificationAdapter, name, blacklist = [], jobId, enabled } = req.body; + const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body; try { + let jobFromDb = jobStorage.getJob(jobId); + + if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) { + res.send(new Error('You are trying to change a job that is not associated to your user.')); + return; + } + jobStorage.upsertJob({ userId: req.session.currentUser, jobId, @@ -51,6 +74,7 @@ jobRouter.post('/', async (req, res) => { blacklist, provider, notificationAdapter, + shareWithUsers, }); } catch (error) { res.send(new Error(error)); @@ -58,6 +82,7 @@ jobRouter.post('/', async (req, res) => { } res.send(); }); + jobRouter.delete('', async (req, res) => { const { jobId } = req.body; try { @@ -92,4 +117,16 @@ jobRouter.put('/:jobId/status', async (req, res) => { } res.send(); }); + +jobRouter.get('/shareableUserList', async (req, res) => { + const currentUser = req.session.currentUser; + const users = userStorage.getUsers(false); + res.body = users + .filter((user) => !user.isAdmin && user.id !== currentUser) + .map((user) => ({ + id: user.id, + name: user.username, + })); + res.send(); +}); export { jobRouter }; diff --git a/lib/api/routes/userRoute.js b/lib/api/routes/userRoute.js index b2718d8..ac8110e 100644 --- a/lib/api/routes/userRoute.js +++ b/lib/api/routes/userRoute.js @@ -11,10 +11,12 @@ function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) { return req.session.currentUser === userIdToBeRemoved; } const nullOrEmpty = (str) => str == null || str.length === 0; + userRouter.get('/', async (req, res) => { res.body = userStorage.getUsers(false); res.send(); }); + userRouter.get('/:userId', async (req, res) => { const { userId } = req.params; res.body = userStorage.getUser(userId); diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js index 3248638..cfd6549 100644 --- a/lib/services/storage/jobStorage.js +++ b/lib/services/storage/jobStorage.js @@ -16,7 +16,16 @@ import { toJson, fromJson } from '../../utils.js'; * @param {string} params.userId - Owner user id for inserts; preserved on updates. * @returns {void} */ -export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => { +export const upsertJob = ({ + jobId, + name, + blacklist = [], + enabled = true, + provider, + notificationAdapter, + userId, + shareWithUsers = [], +}) => { const id = jobId || nanoid(); const existing = SqliteConnection.query(`SELECT id, user_id FROM jobs WHERE id = @id LIMIT 1`, { id })[0]; const ownerId = existing ? existing.user_id : userId; @@ -27,21 +36,23 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide name = @name, blacklist = @blacklist, provider = @provider, - notification_adapter = @notification_adapter + notification_adapter = @notification_adapter, + shared_with_user = @shareWithUsers WHERE id = @id`, { id, enabled: enabled ? 1 : 0, name: name ?? null, blacklist: toJson(blacklist ?? []), + shareWithUsers: toJson(shareWithUsers ?? []), provider: toJson(provider ?? []), notification_adapter: toJson(notificationAdapter ?? []), }, ); } else { SqliteConnection.execute( - `INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter) - VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter)`, + `INSERT INTO jobs (id, user_id, enabled, name, blacklist, provider, notification_adapter, shared_with_user) + VALUES (@id, @user_id, @enabled, @name, @blacklist, @provider, @notification_adapter, @shareWithUsers)`, { id, user_id: ownerId, @@ -49,6 +60,7 @@ export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provide name: name ?? null, blacklist: toJson(blacklist ?? []), provider: toJson(provider ?? []), + shareWithUsers: toJson(shareWithUsers ?? []), notification_adapter: toJson(notificationAdapter ?? []), }, ); @@ -129,6 +141,7 @@ export const getJobs = () => { j.name, j.blacklist, j.provider, + j.shared_with_user, j.notification_adapter AS notificationAdapter, (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings FROM jobs j @@ -139,6 +152,7 @@ export const getJobs = () => { enabled: !!row.enabled, blacklist: fromJson(row.blacklist, []), provider: fromJson(row.provider, []), + shared_with_user: fromJson(row.shared_with_user, []), notificationAdapter: fromJson(row.notificationAdapter, []), })); }; diff --git a/lib/services/storage/migrations/sql/5.job-sharing.js b/lib/services/storage/migrations/sql/5.job-sharing.js new file mode 100644 index 0000000..2a581ff --- /dev/null +++ b/lib/services/storage/migrations/sql/5.job-sharing.js @@ -0,0 +1,7 @@ +// Migration: Adding a new table to store if somebody "watches" (a.k.a favorite) a listing + +export function up(db) { + db.exec(` + ALTER TABLE jobs ADD COLUMN shared_with_user jsonb DEFAULT '[]' + `); +} diff --git a/package.json b/package.json index 351ff14..81196e9 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "14.1.1", + "version": "14.2.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", @@ -85,7 +85,7 @@ "react-router": "7.9.3", "react-router-dom": "7.9.3", "restana": "5.1.0", - "semver": "^7.7.2", + "semver": "^7.7.3", "serve-static": "2.2.0", "slack": "11.0.2", "vite": "7.1.9", @@ -98,13 +98,13 @@ "@babel/preset-env": "7.28.3", "@babel/preset-react": "7.27.1", "chai": "6.2.0", - "eslint": "9.36.0", + "eslint": "9.37.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", "esmock": "2.7.3", "history": "5.3.0", "husky": "9.1.7", - "less": "4.4.1", + "less": "4.4.2", "lint-staged": "16.2.3", "mocha": "11.7.4", "nodemon": "^3.1.10", diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 8efd5fa..b0b1a10 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -37,6 +37,7 @@ export default function FredyApp() { await actions.provider.getProvider(); await actions.jobs.getJobs(); await actions.jobs.getProcessingTimes(); + await actions.jobs.getSharableUserList(); await actions.notificationAdapter.getAdapter(); await actions.generalSettings.getGeneralSettings(); await actions.versionUpdate.getVersionUpdate(); diff --git a/ui/src/components/table/JobTable.jsx b/ui/src/components/table/JobTable.jsx index 28fcde4..df57539 100644 --- a/ui/src/components/table/JobTable.jsx +++ b/ui/src/components/table/JobTable.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui'; -import { IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons'; +import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } from '@douyinfe/semi-icons'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import './JobTable.less'; @@ -33,12 +33,38 @@ export default function JobTable({ title: '', dataIndex: '', render: (job) => { - return onJobStatusChanged(job.id, checked)} checked={job.enabled} />; + return ( + onJobStatusChanged(job.id, checked)} + checked={job.enabled} + disabled={job.isOnlyShared} + /> + ); }, }, { title: 'Name', dataIndex: 'name', + render: (name, job) => { + if (job.isOnlyShared) { + return ( + +
+
+ +
+ {name} +
+
+ ); + } else { + return name; + } + }, }, { title: 'Listings', @@ -48,14 +74,14 @@ export default function JobTable({ }, }, { - title: 'Providers', + title: 'Provider', dataIndex: 'provider', render: (value) => { return value.length || 0; }, }, { - title: 'Notification adapters', + title: 'Notification Adapter', dataIndex: 'notificationAdapter', render: (value) => { return value.length || 0; @@ -68,16 +94,36 @@ export default function JobTable({ return (
-
); diff --git a/ui/src/components/table/listings/ListingsFilter.jsx b/ui/src/components/table/listings/ListingsFilter.jsx index 8c36bff..77004e7 100644 --- a/ui/src/components/table/listings/ListingsFilter.jsx +++ b/ui/src/components/table/listings/ListingsFilter.jsx @@ -26,7 +26,11 @@ export default function ListingsFilter({ onWatchListFilter, onActivityFilter, on {jobs != null && jobs.length > 0 && jobs.map((job) => { - return {job.name}; + return ( + + {job.name} + + ); })} @@ -35,7 +39,11 @@ export default function ListingsFilter({ onWatchListFilter, onActivityFilter, on {provider != null && provider.length > 0 && provider.map((prov) => { - return {prov.name}; + return ( + + {prov.name} + + ); })} diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 211249a..9ee44a4 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -67,6 +67,14 @@ export const useFredyState = create( console.error(`Error while trying to get resource for api/jobs. Error:`, Exception); } }, + async getSharableUserList() { + try { + const response = await xhrGet('/api/jobs/shareableUserList'); + set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } })); + } catch (Exception) { + console.error(`Error while trying to get resource for api/jobs. Error:`, Exception); + } + }, async getProcessingTimes() { try { const response = await xhrGet('/api/jobs/processingTimes'); @@ -172,7 +180,7 @@ export const useFredyState = create( demoMode: { demoMode: false }, versionUpdate: {}, provider: [], - jobs: { jobs: [], insights: {}, processingTimes: {} }, + jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] }, user: { users: [], currentUser: null }, }; diff --git a/ui/src/views/jobs/mutation/JobMutation.jsx b/ui/src/views/jobs/mutation/JobMutation.jsx index a9bdba2..a008076 100644 --- a/ui/src/views/jobs/mutation/JobMutation.jsx +++ b/ui/src/views/jobs/mutation/JobMutation.jsx @@ -8,13 +8,14 @@ import Headline from '../../../components/headline/Headline'; import { useActions, useSelector } from '../../../services/state/store'; import { xhrPost } from '../../../services/xhr'; import { useNavigate, useParams } from 'react-router-dom'; -import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui'; +import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyinfe/semi-ui'; import './JobMutation.less'; import { SegmentPart } from '../../../components/segment/SegmentPart'; -import { IconPlusCircle } from '@douyinfe/semi-icons'; +import { IconBell, IconBriefcase, IconPaperclip, IconPlayCircle, IconPlusCircle, IconUser } from '@douyinfe/semi-icons'; export default function JobMutator() { const jobs = useSelector((state) => state.jobs.jobs); + const shareableUserList = useSelector((state) => state.jobs.shareableUserList); const params = useParams(); const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId); @@ -32,6 +33,7 @@ export default function JobMutator() { const [name, setName] = useState(defaultName); const [blacklist, setBlacklist] = useState(defaultBlacklist); const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter); + const [shareWithUsers, setShareWithUsers] = useState(jobToBeEdit?.shared_with_user ?? []); const [enabled, setEnabled] = useState(defaultEnabled); const navigate = useNavigate(); const actions = useActions(); @@ -45,6 +47,7 @@ export default function JobMutator() { await xhrPost('/api/jobs', { provider: providerData, notificationAdapter: notificationAdapterData, + shareWithUsers, name, blacklist, enabled, @@ -91,7 +94,7 @@ export default function JobMutator() {
- + @@ -157,7 +160,7 @@ export default function JobMutator() { @@ -169,7 +172,32 @@ export default function JobMutator() { + {shareableUserList.length === 0 ? ( +
No users found to share this Job to. Please create additional non-admin user.
+ ) : ( + + )} +
+ + diff --git a/yarn.lock b/yarn.lock index 8bfa876..0db6348 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1176,15 +1176,17 @@ debug "^4.3.1" minimatch "^3.1.2" -"@eslint/config-helpers@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617" - integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA== +"@eslint/config-helpers@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.0.tgz#e9f94ba3b5b875e32205cb83fece18e64486e9e6" + integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog== + dependencies: + "@eslint/core" "^0.16.0" -"@eslint/core@^0.15.2": - version "0.15.2" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f" - integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg== +"@eslint/core@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.16.0.tgz#490254f275ba9667ddbab344f4f0a6b7a7bd7209" + integrity sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q== dependencies: "@types/json-schema" "^7.0.15" @@ -1203,22 +1205,22 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.36.0": - version "9.36.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.36.0.tgz#b1a3893dd6ce2defed5fd49de805ba40368e8fef" - integrity sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw== +"@eslint/js@9.37.0": + version "9.37.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b" + integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg== "@eslint/object-schema@^2.1.6": version "2.1.6" resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== -"@eslint/plugin-kit@^0.3.5": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5" - integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w== +"@eslint/plugin-kit@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz#f6a245b42886abf6fc9c7ab7744a932250335ab2" + integrity sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A== dependencies: - "@eslint/core" "^0.15.2" + "@eslint/core" "^0.16.0" levn "^0.4.1" "@humanfs/core@^0.19.1": @@ -3276,19 +3278,19 @@ eslint-visitor-keys@^4.2.1: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== -eslint@9.36.0: - version "9.36.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.36.0.tgz#9cc5cbbfb9c01070425d9bfed81b4e79a1c09088" - integrity sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ== +eslint@9.37.0: + version "9.37.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.37.0.tgz#ac0222127f76b09c0db63036f4fe289562072d74" + integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig== dependencies: "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.1" "@eslint/config-array" "^0.21.0" - "@eslint/config-helpers" "^0.3.1" - "@eslint/core" "^0.15.2" + "@eslint/config-helpers" "^0.4.0" + "@eslint/core" "^0.16.0" "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.36.0" - "@eslint/plugin-kit" "^0.3.5" + "@eslint/js" "9.37.0" + "@eslint/plugin-kit" "^0.4.0" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" @@ -4534,10 +4536,10 @@ lazy-cache@^1.0.3: resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ== -less@4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/less/-/less-4.4.1.tgz#2f97168bf887ca6a9957ee69e16cc34f8b007cc7" - integrity sha512-X9HKyiXPi0f/ed0XhgUlBeFfxrlDP3xR4M7768Zl+WXLUViuL9AOPPJP4nCV0tgRWvTYvpNmN0SFhZOQzy16PA== +less@4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/less/-/less-4.4.2.tgz#fa4291fdb0334de91163622cc038f4bd3eb6b8d7" + integrity sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g== dependencies: copy-anything "^2.0.1" parse-node-version "^1.0.1" @@ -6538,6 +6540,11 @@ semver@^7.3.5, semver@^7.5.3, semver@^7.7.2: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + send@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/send/-/send-1.2.0.tgz#32a7554fb777b831dfa828370f773a3808d37212"