storing last run in database

This commit is contained in:
orangecoding
2026-06-09 16:52:37 +02:00
parent 6bef907416
commit bc9c56a224
9 changed files with 287 additions and 85 deletions

View File

@@ -20,6 +20,28 @@ function cap(val) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
}
/**
* Compute the most recent job trigger timestamp across the given jobs.
*
* Returns `null` when none of the jobs has ever been triggered. The value is
* persisted per-job via `jobs.last_run_at`, so the dashboard reflects the
* scope visible to the current user (own + shared, or all for admins) rather
* than a process-wide in-memory value.
*
* @param {Array<{lastRunAt?: number|null}>} jobs
* @returns {number|null}
*/
function computeLastRun(jobs) {
let lastRun = null;
for (const job of jobs) {
const ts = job.lastRunAt;
if (typeof ts === 'number' && (lastRun == null || ts > lastRun)) {
lastRun = ts;
}
}
return lastRun;
}
/**
* @param {import('fastify').FastifyInstance} fastify
*/
@@ -46,11 +68,13 @@ export default async function dashboardPlugin(fastify) {
}
: { labels: [], values: [] };
const lastRun = computeLastRun(jobs);
return {
general: {
interval: settings.interval,
lastRun: settings.lastRun || null,
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
lastRun,
nextRun: lastRun == null ? 0 : lastRun + settings.interval * 60000,
},
kpis: {
totalJobs,

View File

@@ -104,7 +104,6 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
logger.debug('Working hours set. Skipping as outside of working hours.');
return;
}
settings.lastRun = now;
const jobs = jobStorage.getJobs().filter((job) => {
if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all
@@ -150,6 +149,13 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
}
const acquired = markRunning(job.id);
if (!acquired) return;
// Persist the trigger time so the dashboard "last search" KPI can be
// derived per accessible user without an in-memory cache.
try {
jobStorage.updateJobLastRunAt(job.id, Date.now());
} catch (err) {
logger.warn('Failed to persist last_run_at for job', job.id, err);
}
// notify listeners (SSE) that the job started
try {
bus.emit('jobs:status', { jobId: job.id, running: true });

View File

@@ -97,6 +97,7 @@ export const getJob = (jobId) => {
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.id = @id
@@ -116,6 +117,24 @@ export const getJob = (jobId) => {
};
};
/**
* Record the timestamp at which a job was last triggered.
*
* Called from the job execution service when a job starts running. The value
* is persisted so that the dashboard "last search" KPI survives restarts and
* can be computed per accessible user.
*
* @param {string} jobId - Job primary key.
* @param {number} timestamp - Epoch milliseconds.
* @returns {void}
*/
export const updateJobLastRunAt = (jobId, timestamp) => {
SqliteConnection.execute(`UPDATE jobs SET last_run_at = @timestamp WHERE id = @id`, {
id: jobId,
timestamp,
});
};
/**
* Update job enabled status.
* @param {{jobId: string, status: boolean}} params - Parameters.
@@ -164,6 +183,7 @@ export const getJobs = () => {
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
WHERE j.enabled = 1
@@ -269,6 +289,7 @@ export const queryJobs = ({
j.notification_adapter AS notificationAdapter,
j.spatial_filter AS spatialFilter,
j.spec_filter AS specFilter,
j.last_run_at AS lastRunAt,
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
FROM jobs j
${whereSql}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Migration: add `last_run_at` to the `jobs` table.
*
* Stores the epoch-ms timestamp at which a job was last triggered. Used by the
* dashboard "last search" KPI so the value survives restarts and reflects the
* actual jobs the requesting user can see (own, shared, or all for admins),
* replacing the previous in-memory `settings.lastRun` value.
*
* NULL means the job has not yet been triggered since this column was added.
*/
export function up(db) {
db.exec(`
ALTER TABLE jobs ADD COLUMN last_run_at INTEGER
`);
}

View File

@@ -18,6 +18,7 @@
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
* @property {SpecFilter | null} [specFilter] Optional listing specifications.
* @property {number} [numberOfFoundListings] Count of active listings for this job.
* @property {number | null} [lastRunAt] Epoch ms at which the job was last triggered, or null if never triggered.
*/
export {};

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "22.5.0",
"version": "22.6.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -62,9 +62,9 @@
"Firefox ESR"
],
"dependencies": {
"@douyinfe/semi-icons": "^2.99.3",
"@douyinfe/semi-ui": "2.99.3",
"@douyinfe/semi-ui-19": "^2.99.3",
"@douyinfe/semi-icons": "^2.100.0",
"@douyinfe/semi-ui": "2.100.0",
"@douyinfe/semi-ui-19": "^2.100.0",
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/session": "^11.1.1",
@@ -95,10 +95,10 @@
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.7",
"react-range-slider-input": "^3.3.5",
"react-router": "7.16.0",
"react-router-dom": "7.16.0",
"react-router": "7.17.0",
"react-router-dom": "7.17.0",
"resend": "^6.12.4",
"semver": "^7.8.1",
"semver": "^7.8.3",
"slack": "11.0.2",
"vite": "8.0.16",
"x-var": "^3.0.1",
@@ -120,7 +120,7 @@
"less": "4.6.4",
"lint-staged": "17.0.7",
"nodemon": "^3.1.14",
"prettier": "3.8.3",
"prettier": "3.8.4",
"vitest": "^4.1.8"
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'node:path';
import Fastify from 'fastify';
describe('api/routes/dashboardRouter.js', () => {
let app;
let state;
async function buildApp() {
const ROOT = path.resolve('.');
const jobStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'jobStorage.js');
const listingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'listingsStorage.js');
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
const securityPath = path.join(ROOT, 'lib', 'api', 'security.js');
vi.resetModules();
vi.doMock(jobStoragePath, () => ({
getJobs: () => state.jobs.slice(),
}));
vi.doMock(listingsStoragePath, () => ({
getListingsKpisForJobIds: () => ({ numberOfActiveListings: 0, medianPriceOfListings: 0 }),
getProviderDistributionForJobIds: () => [],
}));
vi.doMock(settingsStoragePath, () => ({
getSettings: async () => ({ interval: 30 }),
}));
vi.doMock(securityPath, () => ({
isAdmin: () => state.admin,
}));
const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'dashboardRouter.js'));
const plugin = mod.default;
const instance = Fastify({ logger: false });
instance.addHook('onRequest', async (request) => {
request.session = { currentUser: state.currentUser, createdAt: Date.now() };
});
await instance.register(plugin, { prefix: '/api/dashboard' });
await instance.ready();
return instance;
}
beforeEach(() => {
state = {
currentUser: 'u1',
admin: false,
jobs: [],
};
});
afterEach(async () => {
if (app) await app.close();
app = null;
});
it('derives lastRun from the most recent accessible job for a regular user', async () => {
state.jobs = [
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
{ id: 'b', userId: 'u1', shared_with_user: [], lastRunAt: 5000 },
{ id: 'c', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.general.lastRun).toBe(5000);
expect(body.general.nextRun).toBe(5000 + 30 * 60000);
});
it('includes shared jobs in the lastRun calculation', async () => {
state.jobs = [
{ id: 'mine', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
{ id: 'shared', userId: 'someone-else', shared_with_user: ['u1'], lastRunAt: 4000 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
expect(res.json().general.lastRun).toBe(4000);
});
it('admins see lastRun across all jobs', async () => {
state.admin = true;
state.jobs = [
{ id: 'a', userId: 'someone', shared_with_user: [], lastRunAt: 1000 },
{ id: 'b', userId: 'another', shared_with_user: [], lastRunAt: 7000 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
expect(res.json().general.lastRun).toBe(7000);
});
it('returns null lastRun and 0 nextRun when no accessible job has ever run', async () => {
state.jobs = [
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: null },
{ id: 'b', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
];
app = await buildApp();
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
const body = res.json();
expect(body.general.lastRun).toBeNull();
expect(body.general.nextRun).toBe(0);
});
});

View File

@@ -29,6 +29,7 @@ describe('services/jobs/jobExecutionService', () => {
vi.doMock(jobStoragePath, () => ({
getJob: (id) => state.jobsById[id] || null,
getJobs: () => state.jobsList.slice(),
updateJobLastRunAt: (id, timestamp) => calls.lastRunUpdates.push({ id, timestamp }),
}));
vi.doMock(userStoragePath, () => ({
getUsers: () => state.users.slice(),
@@ -65,7 +66,7 @@ describe('services/jobs/jobExecutionService', () => {
beforeEach(() => {
bus = new EventEmitter();
calls = { sent: [], markRunning: [] };
calls = { sent: [], markRunning: [], lastRunUpdates: [] };
state = {
jobsById: {},
jobsList: [],
@@ -119,4 +120,23 @@ describe('services/jobs/jobExecutionService', () => {
await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
});
it('persists last_run_at when a job is executed', async () => {
state.jobsById['j1'] = { id: 'j1', enabled: true, userId: 'u1', provider: [] };
state.jobsList = [state.jobsById['j1']];
state.users = [{ id: 'u1', isAdmin: false }];
await initService();
const before = Date.now();
bus.emit('jobs:runOne', { jobId: 'j1' });
await new Promise((r) => setTimeout(r, 0));
const after = Date.now();
expect(calls.lastRunUpdates.length).toBe(1);
const [update] = calls.lastRunUpdates;
expect(update.id).toBe('j1');
expect(update.timestamp).toBeGreaterThanOrEqual(before);
expect(update.timestamp).toBeLessThanOrEqual(after);
});
});

146
yarn.lock
View File

@@ -950,34 +950,34 @@
dependencies:
tslib "^2.0.0"
"@douyinfe/semi-animation-react@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.99.3.tgz#504e45896db45761d173be8a68cb2aa43157e8cd"
integrity sha512-0iUWQRO1t838Q1VaPE7DwOnYWeAuuu98MrNnaFkbD8JncYsct2K/2A5TDfa56DwSZ5iVz53jz2En8dMi7oF8sw==
"@douyinfe/semi-animation-react@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.100.0.tgz#f53cb41a259f4dfefafd68cab76964635a215736"
integrity sha512-zp224kBejXu+28z56uxLNasaijDJN55w0Ll+/JN+NaksTeKoUteEa93hx2SZVt6GGwZAM3H3mfDwF1UcE+fvLA==
dependencies:
"@douyinfe/semi-animation" "2.99.3"
"@douyinfe/semi-animation-styled" "2.99.3"
"@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-animation-styled" "2.100.0"
classnames "^2.2.6"
"@douyinfe/semi-animation-styled@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.99.3.tgz#d810fd4fb1e2fa6c617b479b3bcbf6ef91d1e4d8"
integrity sha512-38/ui6SoIJFWRs2jHv1IiNV2CKHaQKhYB4WftCVXCaYYQGL24+0oQ3iLo6qUeaHEWiQK3EcK2Rt7pxtJCJxVOA==
"@douyinfe/semi-animation-styled@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.100.0.tgz#20a3fde32b94feb4d1fdf7eba037b19ad74c99ba"
integrity sha512-UHluoWLAHPSVYK2OpdreaSHQI3bh300rrp/dP0UCjsl3FngTUHhsOHVqdWPJ3flTWnc3Mg1Flqr2gUmFjHplhw==
"@douyinfe/semi-animation@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.99.3.tgz#3544687b8bc1f287c60a0f116494ce23adb42893"
integrity sha512-Uva9MLF+EjC+m6eBYnX9PFZIQKLxD+iKV6ps/nX/P1FWy17DCDxIsga/cByF0PIsVRLzrSdkCsddj3XETcDw9A==
"@douyinfe/semi-animation@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.100.0.tgz#2f96f57d5c60d732eae5bd02a90ee1e6ec4d23b4"
integrity sha512-X9AxxUrrHWhgxxLkM4oJw8ZM/VAXsu7/fkr4dyIkkZHDhQcnMfMc2YtughqaVqkaicm3SV9zRx9npjYe/S5nVw==
dependencies:
bezier-easing "^2.1.0"
"@douyinfe/semi-foundation@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.99.3.tgz#ac7f9afd4d141711a5aca14b0a1b6e4ffba70417"
integrity sha512-HKzrcdNGYoEZD81CKI6fj8jU2MWNrZx8HZ0NDHym+smBxSyhpoE/b0FrVo0PmLjCzbCDnySDdJ31GsK5GScmuw==
"@douyinfe/semi-foundation@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.100.0.tgz#e503dbc31bbc18c2f8862653bcbdc1d5a330fd65"
integrity sha512-D2pjhpqOMOpjgw4M4Hg0Pj8KSnxl/jVsfynrIji5TwW7V2bGgt/aWOnBqdTXlrTLk4CHDmfAXKyr+rxY9aihhw==
dependencies:
"@douyinfe/semi-animation" "2.99.3"
"@douyinfe/semi-json-viewer-core" "2.99.3"
"@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-json-viewer-core" "2.100.0"
"@mdx-js/mdx" "^3.0.1"
async-validator "^3.5.0"
classnames "^2.2.6"
@@ -991,44 +991,44 @@
remark-gfm "^4.0.0"
scroll-into-view-if-needed "^2.2.24"
"@douyinfe/semi-icons@2.99.3", "@douyinfe/semi-icons@^2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.99.3.tgz#295d4fd79b2bf987bbcd34c0a4e3fb364f96e509"
integrity sha512-Pm5H3Ua/PDumUCCsnJWwN+znVoKiyFCqag6DJy9/cuF6OOdd1+QUnvi0NHNg6+0fx/LHH088UwKFoOiZRkbaSw==
"@douyinfe/semi-icons@2.100.0", "@douyinfe/semi-icons@^2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.100.0.tgz#b0853f230bfa993acbf90a1c2e9fcbb97321819b"
integrity sha512-S/UZAOgzhbk2Dpwn0mUz/SrjswRpSTjSupzluLO0QmM8mCVuLSetmJ0Y/HO4MGM1eY9rEUrXON/FV3+SukFzxQ==
dependencies:
classnames "^2.2.6"
"@douyinfe/semi-illustrations@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.99.3.tgz#e97d6c30830d44b7ec299d7e05f1a6e3c4938bf7"
integrity sha512-z1rQPgWOV2xtZS8NkmL8JCK1DltQ8FGiL1qYlXbSHjEs1XkNYruq4W3dKv0IJEpTVLIlPsbDg4VmPAuuwLCCkQ==
"@douyinfe/semi-illustrations@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.100.0.tgz#4ca6623eedd1944817f1b7c8eba0095a6a7d2985"
integrity sha512-SN7plpE328WGBohLHOVpYe6FwWSO6RLS7Xf6LhqEdtarwK52ircr4C/b+OyRqIwcLOzRYMgIoqcWnAQGmowcUw==
"@douyinfe/semi-json-viewer-core@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.99.3.tgz#a8dee4ea6cbf1bcac85c723696a9430b4faf3152"
integrity sha512-KEbZEyyM2qqGv9K+Yw/ZvAn4CEgcY2lQfL6a2ASEt80FlPoDAIWA7tGjpYxxM9/NcX9omNtsM/HLgDmrCjjBXQ==
"@douyinfe/semi-json-viewer-core@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.100.0.tgz#c0c3bf50f722aa51008a8e6acf17ac7842baceeb"
integrity sha512-iQ6rX04YBngrsMz7Eds8zBI+W0MXb0mAICvfTaiX8RpoAwau9yFwbyHiCPKOVPSzI0hS8GwdMLSIYxdCOQPNqQ==
dependencies:
jsonc-parser "^3.3.1"
"@douyinfe/semi-theme-default@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.99.3.tgz#e5ee0e4a8eec413ea3f58ed12f93415729c47251"
integrity sha512-r0IIjrN6vQE1bqbky7FIRi4HQ03x4ykzSIRMf4Za04BFp76IFV6CclyYyUg6cLJ6GjWCnEPMFtwTLKP+b8dAYA==
"@douyinfe/semi-theme-default@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.100.0.tgz#919bb12307f6b3258016cf36e320c607717eb8c2"
integrity sha512-7tJjg5NiuUYtChWr/E5rQ4Kcko3izz8rTxlNDWSS4YR3RQg3S+lQTgG5bD7LMnBqX399erf3wgE35KLwQZKWTg==
"@douyinfe/semi-ui-19@^2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.99.3.tgz#236a8894ea38ac3cd9d4a4d9c784dc9712b2105a"
integrity sha512-HrXK1xIXfzS7OYzkrS+3PQKlMnx6J5HEw7wfYtDvGSIN/riSbjeD8vciHeIvP1tvhEAubFY8DMFwT07ZdmqfxA==
"@douyinfe/semi-ui-19@^2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui-19/-/semi-ui-19-2.100.0.tgz#bee76e0a0eec57b49b64f8dd2d73b9039b7a2c1b"
integrity sha512-eL4DTJm4CPopWgr4d278dXIa2UwNgUundRJ37ksQ7Ev1TZnWr8SxCWLcmi4exl8kymZurAWV7j2w1sv7BHqtAA==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.99.3"
"@douyinfe/semi-animation-react" "2.99.3"
"@douyinfe/semi-foundation" "2.99.3"
"@douyinfe/semi-icons" "2.99.3"
"@douyinfe/semi-illustrations" "2.99.3"
"@douyinfe/semi-theme-default" "2.99.3"
"@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-animation-react" "2.100.0"
"@douyinfe/semi-foundation" "2.100.0"
"@douyinfe/semi-icons" "2.100.0"
"@douyinfe/semi-illustrations" "2.100.0"
"@douyinfe/semi-theme-default" "2.100.0"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
@@ -1057,20 +1057,20 @@
scroll-into-view-if-needed "^2.2.24"
utility-types "^3.10.0"
"@douyinfe/semi-ui@2.99.3":
version "2.99.3"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.99.3.tgz#a183ecc4db0e96c48c714d8733a6d30e9395bc4a"
integrity sha512-6NkeijjZZWzD31omteNVLz+oZuuMKQm3nEcwLI8+44Vv+VUSJPb87WnSFSD3F6eUIt/hZp2vJbCXHWW9SbCpDw==
"@douyinfe/semi-ui@2.100.0":
version "2.100.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.100.0.tgz#2964299a4c4da2501c4ba1fd2699fbfd2daef106"
integrity sha512-fTaqS6B1gHLjwMKgcWTcJWdMk9gY96h94I71Y3z9ee6qIXJyjAO8XiE8G6bihEIeVO3vTKXp1DOKiGhlgMVJKQ==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.99.3"
"@douyinfe/semi-animation-react" "2.99.3"
"@douyinfe/semi-foundation" "2.99.3"
"@douyinfe/semi-icons" "2.99.3"
"@douyinfe/semi-illustrations" "2.99.3"
"@douyinfe/semi-theme-default" "2.99.3"
"@douyinfe/semi-animation" "2.100.0"
"@douyinfe/semi-animation-react" "2.100.0"
"@douyinfe/semi-foundation" "2.100.0"
"@douyinfe/semi-icons" "2.100.0"
"@douyinfe/semi-illustrations" "2.100.0"
"@douyinfe/semi-theme-default" "2.100.0"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
@@ -6180,10 +6180,10 @@ prelude-ls@^1.2.1:
resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@3.8.3:
version "3.8.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0"
integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==
prettier@3.8.4:
version "3.8.4"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411"
integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==
prismjs@^1.29.0:
version "1.30.0"
@@ -6525,17 +6525,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.16.0.tgz#284a7cd021052aa7d0a9240dca4a02eec24eceb5"
integrity sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==
react-router-dom@7.17.0:
version "7.17.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.17.0.tgz#e77527b4b7862f7b47ff26dd5b9315fb897b82a7"
integrity sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==
dependencies:
react-router "7.16.0"
react-router "7.17.0"
react-router@7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.16.0.tgz#fb41536aef2ccc2c7be12ea6be819a1e56eb6343"
integrity sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==
react-router@7.17.0:
version "7.17.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.17.0.tgz#88bbe817c6e37ab36faf140623b5d4678bf81e41"
integrity sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -6974,10 +6974,10 @@ semver@^7.6.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
semver@^7.8.1:
version "7.8.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e"
integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==
semver@^7.8.3:
version "7.8.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.3.tgz#c350de61c2a1ddbc96d7fae3e5b6fcf92d477fbe"
integrity sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==
send@^1.1.0:
version "1.2.1"