diff --git a/conf/config.json b/conf/config.json index 3f8d0547..c77a267a 100644 --- a/conf/config.json +++ b/conf/config.json @@ -1 +1 @@ -{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":true,"sqlitepath":"/db"} \ No newline at end of file +{"sqlitepath":"/db"} \ No newline at end of file diff --git a/index.js b/index.js index 5211b08d..b24062d2 100755 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { checkIfConfigIsAccessible, config, getProviders, refreshConfig } from './lib/utils.js'; +import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js'; import * as similarityCache from './lib/services/similarity-check/similarityCache.js'; import * as jobStorage from './lib/services/storage/jobStorage.js'; import FredyPipeline from './lib/FredyPipeline.js'; @@ -12,28 +12,34 @@ import { initTrackerCron } from './lib/services/crons/tracker-cron.js'; import logger from './lib/services/logger.js'; import { bus } from './lib/services/events/event-bus.js'; import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js'; +import { getSettings } from './lib/services/storage/settingsStorage.js'; +import SqliteConnection from './lib/services/storage/SqliteConnection.js'; + +//in the config, we store the path of the sqlite file, thus we must check if it is available +const isConfigAccessible = await checkIfConfigIsAccessible(); +await SqliteConnection.init(); // Load configuration before any other startup steps await refreshConfig(); -const isConfigAccessible = await checkIfConfigIsAccessible(); - if (!isConfigAccessible) { logger.error('Configuration exists, but is not accessible. Please check the file permission'); process.exit(1); } +// Run DB migrations once at startup and block until finished +await runMigrations(); + +const settings = await getSettings(); + // Ensure sqlite directory exists before loading anything else (based on config.sqlitepath) -const rawDir = config.sqlitepath || '/db'; +const rawDir = settings.sqlitepath || '/db'; const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir; const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir); if (!fs.existsSync(absDir)) { fs.mkdirSync(absDir, { recursive: true }); } -// Run DB migrations once at startup and block until finished -await runMigrations(); - // Load provider modules once at startup const providers = await getProviders(); @@ -41,17 +47,17 @@ similarityCache.initSimilarityCache(); similarityCache.startSimilarityCacheReloader(); //assuming interval is always in minutes -const INTERVAL = config.interval * 60 * 1000; +const INTERVAL = settings.interval * 60 * 1000; // Initialize API only after migrations completed await import('./lib/api/api.js'); -if (config.demoMode) { +if (settings.demoMode) { logger.info('Running in demo mode'); cleanupDemoAtMidnight(); } -logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`); +logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`); ensureAdminUserExists(); ensureDemoUserExists(); @@ -65,10 +71,10 @@ bus.on('jobs:runAll', () => { }); const execute = () => { - const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now()); - if (!config.demoMode) { + const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now()); + if (!settings.demoMode) { if (isDuringWorkingHoursOrNotSet) { - config.lastRun = Date.now(); + settings.lastRun = Date.now(); jobStorage .getJobs() .filter((job) => job.enabled) diff --git a/lib/api/api.js b/lib/api/api.js index 348efd22..da65b16e 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -7,7 +7,6 @@ import { versionRouter } from './routes/versionRouter.js'; import { loginRouter } from './routes/loginRoute.js'; import { userRouter } from './routes/userRoute.js'; import { jobRouter } from './routes/jobRouter.js'; -import { config } from '../utils.js'; import bodyParser from 'body-parser'; import restana from 'restana'; import files from 'serve-static'; @@ -16,9 +15,11 @@ import { getDirName } from '../utils.js'; import { demoRouter } from './routes/demoRouter.js'; import logger from '../services/logger.js'; import { listingsRouter } from './routes/listingsRouter.js'; +import { getSettings } from '../services/storage/settingsStorage.js'; +import { featureRouter } from './routes/featureRouter.js'; const service = restana(); const staticService = files(path.join(getDirName(), '../ui/public')); -const PORT = config.port || 9998; +const PORT = (await getSettings()).port || 9998; service.use(bodyParser.json()); service.use(cookieSession()); @@ -39,6 +40,7 @@ service.use('/api/version', versionRouter); service.use('/api/jobs', jobRouter); service.use('/api/login', loginRouter); service.use('/api/listings', listingsRouter); +service.use('/api/features', featureRouter); //this route is unsecured intentionally as it is being queried from the login page service.use('/api/demo', demoRouter); diff --git a/lib/api/routes/demoRouter.js b/lib/api/routes/demoRouter.js index 0bd5dfbf..80e85b1f 100644 --- a/lib/api/routes/demoRouter.js +++ b/lib/api/routes/demoRouter.js @@ -1,10 +1,11 @@ import restana from 'restana'; -import { config } from '../../utils.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const demoRouter = service.newRouter(); demoRouter.get('/', async (req, res) => { - res.body = Object.assign({}, { demoMode: config.demoMode }); + const settings = await getSettings(); + res.body = Object.assign({}, { demoMode: settings.demoMode }); res.send(); }); diff --git a/lib/api/routes/featureRouter.js b/lib/api/routes/featureRouter.js new file mode 100644 index 00000000..2dbeba2b --- /dev/null +++ b/lib/api/routes/featureRouter.js @@ -0,0 +1,12 @@ +import restana from 'restana'; +import getFeatures from '../../features.js'; +const service = restana(); +const featureRouter = service.newRouter(); + +featureRouter.get('/', async (req, res) => { + const features = getFeatures(); + res.body = Object.assign({}, { features }); + res.send(); +}); + +export { featureRouter }; diff --git a/lib/api/routes/generalSettingsRoute.js b/lib/api/routes/generalSettingsRoute.js index 1462d642..ef41115f 100644 --- a/lib/api/routes/generalSettingsRoute.js +++ b/lib/api/routes/generalSettingsRoute.js @@ -1,24 +1,30 @@ import restana from 'restana'; -import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js'; +import { getDirName } from '../../utils.js'; import fs from 'fs'; import { ensureDemoUserExists } from '../../services/storage/userStorage.js'; import logger from '../../services/logger.js'; +import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const generalSettingsRouter = service.newRouter(); + generalSettingsRouter.get('/', async (req, res) => { - res.body = Object.assign({}, config); + res.body = Object.assign({}, await getSettings()); res.send(); }); generalSettingsRouter.post('/', async (req, res) => { - const settings = req.body; + const { sqlitepath, ...appSettings } = req.body || {}; + const localSettings = await getSettings(); + + if (localSettings.demoMode) { + res.send(new Error('In demo mode, it is not allowed to change these settings.')); + return; + } + try { - if (config.demoMode) { - res.send(new Error('In demo mode, it is not allowed to change these settings.')); - return; + if (typeof sqlitepath !== 'undefined') { + fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ sqlitepath })); } - const currentConfig = await readConfigFromStorage(); - fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...currentConfig, ...settings })); - await refreshConfig(); + upsertSettings(appSettings); ensureDemoUserExists(); } catch (err) { logger.error(err); diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index 3b17c099..98e9bd79 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -1,10 +1,10 @@ import restana from 'restana'; import * as jobStorage from '../../services/storage/jobStorage.js'; import * as userStorage from '../../services/storage/userStorage.js'; -import { config } from '../../utils.js'; import { isAdmin } from '../security.js'; import logger from '../../services/logger.js'; import { bus } from '../../services/events/event-bus.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const jobRouter = service.newRouter(); @@ -44,9 +44,10 @@ jobRouter.get('/', async (req, res) => { }); jobRouter.get('/processingTimes', async (req, res) => { + const settings = await getSettings(); res.body = { - interval: config.interval, - lastRun: config.lastRun || null, + interval: settings.interval, + lastRun: settings.lastRun || null, }; res.send(); }); diff --git a/lib/api/routes/loginRoute.js b/lib/api/routes/loginRoute.js index 1eb28cc6..16293ccf 100644 --- a/lib/api/routes/loginRoute.js +++ b/lib/api/routes/loginRoute.js @@ -1,9 +1,9 @@ import restana from 'restana'; import * as userStorage from '../../services/storage/userStorage.js'; import * as hasher from '../../services/security/hash.js'; -import { config } from '../../utils.js'; import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; import logger from '../../services/logger.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const loginRouter = service.newRouter(); loginRouter.get('/user', async (req, res) => { @@ -20,6 +20,7 @@ loginRouter.get('/user', async (req, res) => { res.send(); }); loginRouter.post('/', async (req, res) => { + const settings = await getSettings(); const { username, password } = req.body; const user = userStorage.getUsers(true).find((user) => user.username === username); if (user == null) { @@ -27,7 +28,7 @@ loginRouter.post('/', async (req, res) => { return; } if (user.password === hasher.hash(password)) { - if (config.demoMode) { + if (settings.demoMode) { await trackDemoAccessed(); } diff --git a/lib/api/routes/userRoute.js b/lib/api/routes/userRoute.js index ac8110e2..c73d928c 100644 --- a/lib/api/routes/userRoute.js +++ b/lib/api/routes/userRoute.js @@ -1,7 +1,7 @@ import restana from 'restana'; import * as userStorage from '../../services/storage/userStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js'; -import { config } from '../../utils.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const userRouter = service.newRouter(); function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) { @@ -23,7 +23,8 @@ userRouter.get('/:userId', async (req, res) => { res.send(); }); userRouter.delete('/', async (req, res) => { - if (config.demoMode) { + const settings = await getSettings(); + if (settings.demoMode) { res.send(new Error('In demo mode, it is not allowed to remove user.')); return; } @@ -44,7 +45,8 @@ userRouter.delete('/', async (req, res) => { res.send(); }); userRouter.post('/', async (req, res) => { - if (config.demoMode) { + const settings = await getSettings(); + if (settings.demoMode) { res.send(new Error('In demo mode, it is not allowed to change or add user.')); return; } diff --git a/lib/features.js b/lib/features.js new file mode 100644 index 00000000..fa3785ec --- /dev/null +++ b/lib/features.js @@ -0,0 +1,9 @@ +const FEATURES = { + WATCHLIST_MANAGEMENT: false, +}; + +export default function getFeatures() { + return { + ...FEATURES, + }; +} diff --git a/lib/services/crons/demoCleanup-cron.js b/lib/services/crons/demoCleanup-cron.js index 79b5752e..46e77968 100644 --- a/lib/services/crons/demoCleanup-cron.js +++ b/lib/services/crons/demoCleanup-cron.js @@ -1,8 +1,8 @@ import { removeJobsByUserId } from '../storage/jobStorage.js'; -import { config } from '../../utils.js'; import { getUsers } from '../storage/userStorage.js'; import logger from '../logger.js'; import cron from 'node-cron'; +import { getSettings } from '../storage/settingsStorage.js'; /** * if we are running in demo environment, we have to cleanup the db files (specifically the jobs table) @@ -11,12 +11,13 @@ export function cleanupDemoAtMidnight() { cron.schedule('0 0 * * *', cleanup); } -function cleanup() { - if (config.demoMode) { +async function cleanup() { + const settings = await getSettings(); + if (settings.demoMode) { const demoUser = getUsers(false).find((user) => user.username === 'demo'); if (demoUser == null) { logger.error('Demo user not found, cannot remove Jobs'); - return; + return Promise.resolve(); } removeJobsByUserId(demoUser.id); } diff --git a/lib/services/crons/tracker-cron.js b/lib/services/crons/tracker-cron.js index 7cfe8ccf..4200df7d 100644 --- a/lib/services/crons/tracker-cron.js +++ b/lib/services/crons/tracker-cron.js @@ -1,10 +1,12 @@ import cron from 'node-cron'; -import { config, inDevMode } from '../../utils.js'; +import { inDevMode } from '../../utils.js'; import { trackMainEvent } from '../tracking/Tracker.js'; +import { getSettings } from '../storage/settingsStorage.js'; async function runTask() { + const settings = await getSettings(); //make sure to only send tracking events if the user gave us the green light and we are not in dev mode - if (config.analyticsEnabled && !inDevMode()) { + if (settings.analyticsEnabled && !inDevMode()) { await trackMainEvent(); } } diff --git a/lib/services/storage/SqliteConnection.js b/lib/services/storage/SqliteConnection.js index eb4305fa..1f6c9cbd 100644 --- a/lib/services/storage/SqliteConnection.js +++ b/lib/services/storage/SqliteConnection.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import Database from 'better-sqlite3'; import logger from '../../services/logger.js'; -import { config } from '../../utils.js'; +import { readConfigFromStorage } from '../../utils.js'; /** * SqliteConnection @@ -25,6 +25,15 @@ import { config } from '../../utils.js'; class SqliteConnection { static #db = null; + static #sqlLiteCfg = null; + + static async init() { + if (this.#sqlLiteCfg == null) { + readConfigFromStorage().then((c) => { + this.#sqlLiteCfg = c.sqlitepath; + }); + } + } /** * Returns a singleton instance of better-sqlite3 Database. * Respects env var SQLITE_DB_PATH and defaults to db/listings.db. @@ -32,9 +41,12 @@ class SqliteConnection { static getConnection() { if (this.#db) return this.#db; + if (this.#sqlLiteCfg == null) { + logger.warn('No sqlitepath configured. Using default db/listings.db'); + } + // Interpret config.sqlitepath as a directory relative to project root when it starts with '/' - const cfg = typeof config === 'object' && config ? config.sqlitepath : undefined; - const rawDir = cfg && cfg.length > 0 ? cfg : '/db'; + const rawDir = this.#sqlLiteCfg && this.#sqlLiteCfg.length > 0 ? this.#sqlLiteCfg : '/db'; const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir; const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir); const dbPath = path.join(absDir, 'listings.db'); diff --git a/lib/services/storage/migrations/sql/6.settings.js b/lib/services/storage/migrations/sql/6.settings.js new file mode 100644 index 00000000..e562530b --- /dev/null +++ b/lib/services/storage/migrations/sql/6.settings.js @@ -0,0 +1,73 @@ +// Migration: Adding a settings table to store important (config) settings instead of using config file +import fs from 'fs'; +import path from 'path'; +import { nanoid } from 'nanoid'; +import logger from '../../../logger.js'; + +export function up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS settings + ( + id TEXT PRIMARY KEY, + create_date INTEGER NOT NULL, + user_id TEXT, + name TEXT NOT NULL, + value jsonb NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_settings_name ON settings (name); + `); + + // Helper to insert one setting row + const insertSetting = (name, rawValue) => { + try { + const id = nanoid(); + const createDate = Date.now(); + const value = JSON.stringify(rawValue); + db.prepare( + `INSERT INTO settings (id, create_date, name, value) + VALUES (@id, @create_date, @name, @value)`, + ).run({ id, create_date: createDate, name, value }); + } catch { + // Ignore duplicate inserts if any (unique by name) + } + }; + + // Migrate currently existing config.json into settings + try { + const configPath = path.resolve(process.cwd(), 'conf', 'config.json'); + + // Defaults + const defaults = { + interval: '60', + port: 9998, + workingHours: { from: '', to: '' }, + demoMode: false, + analyticsEnabled: true, + }; + + let config = {}; + if (fs.existsSync(configPath)) { + const file = fs.readFileSync(configPath, 'utf8'); + try { + config = JSON.parse(file) || {}; + } catch (parseErr) { + // If parsing fails, still proceed with defaults + logger.error(parseErr); + config = {}; + } + } + + // Insert each known setting, using the value from config when present, otherwise default + insertSetting('interval', config.interval != null ? config.interval : defaults.interval); + insertSetting('port', config.port != null ? config.port : defaults.port); + insertSetting('workingHours', config.workingHours != null ? config.workingHours : defaults.workingHours); + insertSetting('demoMode', config.demoMode != null ? config.demoMode : defaults.demoMode); + insertSetting( + 'analyticsEnabled', + config.analyticsEnabled != null ? config.analyticsEnabled : defaults.analyticsEnabled, + ); + } catch (e) { + logger.error(e); + } +} diff --git a/lib/services/storage/settingsStorage.js b/lib/services/storage/settingsStorage.js new file mode 100644 index 00000000..be081e74 --- /dev/null +++ b/lib/services/storage/settingsStorage.js @@ -0,0 +1,87 @@ +import { nanoid } from 'nanoid'; +import SqliteConnection from './SqliteConnection.js'; +import { fromJson, readConfigFromStorage, toJson } from '../../utils.js'; + +// In-memory cache for compiled settings config +/** @type {Record|null} */ +let cachedSettingsConfig = null; + +/** + * Build a config object from DB rows of settings. + * - Unwraps stored shape { value: any } into raw values. + * - Add additional config values from file config. E.g. sqlite part cannot be stored in db for obvious reasons ;) + * @param {{name:string, value:string|null}[]} rows + * @param {{name:value}} configValues + * @returns {Record} + */ +function compileSettings(rows, configValues) { + const config = {}; + for (const r of rows) { + const parsed = fromJson(r.value, null); + // unwrap { value: any } if present + config[r.name] = parsed && typeof parsed === 'object' && 'value' in parsed ? parsed.value : parsed; + } + return { + ...config, + ...configValues, + }; +} + +/** + * Force reload the settings config cache from DB and return it. + * @returns {Record} + */ +export async function refreshSettingsCache() { + const rows = SqliteConnection.query(`SELECT name, value FROM settings`); + const configValues = await readConfigFromStorage(); + cachedSettingsConfig = compileSettings(rows, configValues); + return cachedSettingsConfig; +} + +/** + * Get the compiled settings config. Loads it once and caches the result. + * @returns {Record} + */ +export async function getSettings() { + if (cachedSettingsConfig == null) { + return refreshSettingsCache(); + } + return cachedSettingsConfig; +} + +/** + * Upsert settings rows. + * - Accepts an object map of name -> value, or an entry {name, value}. + * - id: random string (nanoid) when inserting + * - create_date: epoch ms when inserting + * - name: unique key + * - value: JSON string of the raw value (no wrapper) + * @param {Record|{name:string, value:any}|[string, any][]} settingsMapOrEntry + * @returns {void} + */ +// Upsert one or more settings by name. Accepts either a single pair or an object map. +// Preferred usage: upsertSettings({ settingName: any, another: any }) +export function upsertSettings(settingsMapOrEntry, userId = null) { + const entries = Array.isArray(settingsMapOrEntry) + ? settingsMapOrEntry + : typeof settingsMapOrEntry === 'object' && + settingsMapOrEntry != null && + 'name' in settingsMapOrEntry && + 'value' in settingsMapOrEntry + ? [[settingsMapOrEntry.name, settingsMapOrEntry.value]] + : Object.entries(settingsMapOrEntry || {}); + + for (const [name, rawValue] of entries) { + const id = nanoid(); + const create_date = Date.now(); + const json = toJson(rawValue); + SqliteConnection.execute( + `INSERT INTO settings (id, create_date, name, value, user_id) + VALUES (@id, @create_date, @name, @value, @userId) + ON CONFLICT(name) DO UPDATE SET value = excluded.value`, + { id, create_date, name, value: json, userId }, + ); + } + // keep cache in sync + refreshSettingsCache(); +} diff --git a/lib/services/storage/userStorage.js b/lib/services/storage/userStorage.js index 1c977202..626eae37 100644 --- a/lib/services/storage/userStorage.js +++ b/lib/services/storage/userStorage.js @@ -1,7 +1,7 @@ -import { config } from '../../utils.js'; import * as hasher from '../security/hash.js'; import { nanoid } from 'nanoid'; import SqliteConnection from './SqliteConnection.js'; +import { getSettings } from './settingsStorage.js'; /** * Get all users. @@ -129,8 +129,9 @@ export const removeUser = (userId) => { * Security: The demo user's password is set to a known value ('demo') and should only be enabled in demoMode. * @returns {void} */ -export const ensureDemoUserExists = () => { - if (!config.demoMode) { +export const ensureDemoUserExists = async () => { + const settings = await getSettings(); + if (!settings.demoMode) { // Remove demo user (and cascade delete their jobs/listings) SqliteConnection.execute(`DELETE FROM users WHERE username = 'demo'`); return; diff --git a/lib/services/tracking/Tracker.js b/lib/services/tracking/Tracker.js index a3e2f291..bc4bb59a 100644 --- a/lib/services/tracking/Tracker.js +++ b/lib/services/tracking/Tracker.js @@ -1,9 +1,10 @@ import { getJobs } from '../storage/jobStorage.js'; import { getUniqueId } from './uniqueId.js'; -import { config, getPackageVersion, inDevMode } from '../../utils.js'; +import { getPackageVersion, inDevMode } from '../../utils.js'; import os from 'os'; import fetch from 'node-fetch'; import logger from '../logger.js'; +import { getSettings } from '../storage/settingsStorage.js'; const deviceId = getUniqueId() || 'N/A'; const version = await getPackageVersion(); @@ -11,7 +12,8 @@ const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking'; export const trackMainEvent = async () => { try { - if (config.analyticsEnabled && !inDevMode()) { + const settings = await getSettings(); + if (settings.analyticsEnabled && !inDevMode()) { const activeProvider = new Set(); const activeAdapter = new Set(); @@ -44,7 +46,8 @@ export const trackMainEvent = async () => { * Note, this will only be used when Fredy runs in demo mode */ export async function trackDemoAccessed() { - if (config.analyticsEnabled && !inDevMode() && config.demoMode) { + const settings = await getSettings(); + if (settings.analyticsEnabled && !inDevMode() && settings.demoMode) { try { await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, { method: 'POST', @@ -56,7 +59,8 @@ export async function trackDemoAccessed() { } } -function enrichTrackingObject(trackingObject) { +async function enrichTrackingObject(trackingObject) { + const settings = await getSettings(); const operatingSystem = os.platform(); const osVersion = os.release(); const arch = process.arch; @@ -65,7 +69,7 @@ function enrichTrackingObject(trackingObject) { return { ...trackingObject, - isDemo: config.demoMode, + isDemo: settings.demoMode, operatingSystem, osVersion, arch, diff --git a/lib/utils.js b/lib/utils.js index b2d86896..f6d1fca2 100755 --- a/lib/utils.js +++ b/lib/utils.js @@ -215,10 +215,6 @@ export async function refreshConfig() { try { config = await readConfigFromStorage(); - //backwards compatibility... - config.analyticsEnabled ??= null; - config.demoMode ??= false; - // default sqlitepath when missing in older configs config.sqlitepath ??= '/db'; } catch (error) { config = { ...DEFAULT_CONFIG }; @@ -306,7 +302,6 @@ export { getDirName, sleep, randomBetween, - config, buildHash, getPackageVersion, toJson, diff --git a/package.json b/package.json index 03e081e2..8118d2d6 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "14.4.0", + "version": "15.0.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", diff --git a/ui/src/App.jsx b/ui/src/App.jsx index b0b1a10b..423433d1 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -21,6 +21,7 @@ import Navigation from './components/navigation/Navigation.jsx'; import { Layout } from '@douyinfe/semi-ui'; import FredyFooter from './components/footer/FredyFooter.jsx'; import ProcessingTimes from './views/jobs/ProcessingTimes.jsx'; +import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx'; export default function FredyApp() { const actions = useActions(); @@ -34,6 +35,7 @@ export default function FredyApp() { async function init() { await actions.user.getCurrentUser(); if (!needsLogin()) { + await actions.features.getFeatures(); await actions.provider.getProvider(); await actions.jobs.getJobs(); await actions.jobs.getProcessingTimes(); @@ -91,6 +93,7 @@ export default function FredyApp() { } /> } /> } /> + } /> {/* Permission-aware routes */} }, - { itemKey: '/listings', text: 'Found Listings', icon: }, + { itemKey: '/listings', text: 'Listings', icon: }, ]; if (isAdmin) { - items.push({ itemKey: '/users', text: 'User Management', icon: }); - items.push({ itemKey: '/generalSettings', text: 'General Settings', icon: }); + const settingsItems = [ + { itemKey: '/users', text: 'User Management' }, + { itemKey: '/generalSettings', text: 'General Settings' }, + ]; + if (watchlistFeature) { + settingsItems.push({ itemKey: '/watchlistManagement', text: 'Watchlist Management' }); + } + + items.push({ + itemKey: 'settings', + text: 'Settings', + icon: , + items: settingsItems, + }); } function parsePathName(name) { @@ -32,7 +46,7 @@ export default function Navigation({ isAdmin }) { return (