webspresso 0.0.96 → 0.0.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
|
@@ -329,6 +329,47 @@ function createAnalyticsApiHandlers(options) {
|
|
|
329
329
|
}
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
/**
|
|
333
|
+
* POST /purge - Purge old analytics data
|
|
334
|
+
*/
|
|
335
|
+
async function purge(req, res) {
|
|
336
|
+
try {
|
|
337
|
+
const rawDays = req.body?.days !== undefined ? req.body.days : req.query?.days;
|
|
338
|
+
let days = 90;
|
|
339
|
+
if (rawDays !== undefined && rawDays !== null && rawDays !== '') {
|
|
340
|
+
days = typeof rawDays === 'number' ? rawDays : Number(rawDays);
|
|
341
|
+
if (Number.isNaN(days) || days <= 0) {
|
|
342
|
+
return res.status(400).json({ error: 'days must be a positive number' });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const rawErrorDays = req.body?.errorDays !== undefined ? req.body.errorDays : req.query?.errorDays;
|
|
347
|
+
let errorDays = 30;
|
|
348
|
+
if (rawErrorDays !== undefined && rawErrorDays !== null && rawErrorDays !== '') {
|
|
349
|
+
errorDays = typeof rawErrorDays === 'number' ? rawErrorDays : Number(rawErrorDays);
|
|
350
|
+
if (Number.isNaN(errorDays) || errorDays <= 0) {
|
|
351
|
+
return res.status(400).json({ error: 'errorDays must be a positive number' });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { purgeAnalyticsData } = require('./purge');
|
|
356
|
+
const result = await purgeAnalyticsData(knex, {
|
|
357
|
+
tableName,
|
|
358
|
+
errorsTableName,
|
|
359
|
+
days,
|
|
360
|
+
errorDays,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
res.json({
|
|
364
|
+
ok: true,
|
|
365
|
+
success: true,
|
|
366
|
+
...result,
|
|
367
|
+
});
|
|
368
|
+
} catch (e) {
|
|
369
|
+
res.status(500).json({ error: e.message });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
332
373
|
return {
|
|
333
374
|
getStats,
|
|
334
375
|
getViewsOverTime,
|
|
@@ -338,6 +379,7 @@ function createAnalyticsApiHandlers(options) {
|
|
|
338
379
|
getCountries,
|
|
339
380
|
getClientErrors,
|
|
340
381
|
getRecent,
|
|
382
|
+
purge,
|
|
341
383
|
};
|
|
342
384
|
}
|
|
343
385
|
|
|
@@ -9,11 +9,14 @@ const { createAnalyticsApiHandlers } = require('./api-handlers');
|
|
|
9
9
|
const { generateAnalyticsComponent } = require('./admin-component');
|
|
10
10
|
const { generateErrorTrackerScript } = require('./client-error-tracker');
|
|
11
11
|
const { createErrorReportHandler } = require('./client-error-handler');
|
|
12
|
+
const { purgeAnalyticsData } = require('./purge');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Site Analytics Plugin Factory
|
|
15
16
|
* @param {Object} options
|
|
16
17
|
* @param {Object} options.db - Database instance (must expose .knex)
|
|
18
|
+
* @param {string} [options.title] - Admin page title
|
|
19
|
+
* @param {string} [options.adminTitle] - Admin page title alias
|
|
17
20
|
* @param {string[]} [options.excludePaths=[]] - Extra paths to exclude from tracking
|
|
18
21
|
* @param {boolean} [options.trackBots=true] - Record bot visits (still counted separately)
|
|
19
22
|
* @param {string} [options.tableName='analytics_page_views'] - DB table name
|
|
@@ -21,11 +24,22 @@ const { createErrorReportHandler } = require('./client-error-handler');
|
|
|
21
24
|
* @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
|
|
22
25
|
* @param {boolean} [options.trackClientErrors=true] - Capture and report client-side JS errors
|
|
23
26
|
* @param {string} [options.errorsTableName='analytics_client_errors'] - Client errors table
|
|
27
|
+
* @param {Object|boolean} [options.retention] - Retention policy configuration (set false to disable)
|
|
28
|
+
* @param {number} [options.retention.days=90] - Days to retain page views
|
|
29
|
+
* @param {number} [options.retention.errorDays=30] - Days to retain client errors
|
|
30
|
+
* @param {number} [options.retention.intervalMs=86400000] - Auto-cleanup timer frequency (24h)
|
|
31
|
+
* @param {boolean} [options.retention.runOnStart=true] - Run initial purge shortly after startup
|
|
32
|
+
* @param {number} [options.retentionDays=90] - Shorthand for retention.days
|
|
33
|
+
* @param {number} [options.errorRetentionDays=30] - Shorthand for retention.errorDays
|
|
34
|
+
* @param {number} [options.cleanupIntervalMs=86400000] - Shorthand for retention.intervalMs
|
|
35
|
+
* @param {boolean} [options.autoCleanup=true] - Enable/disable background periodic cleanup
|
|
24
36
|
* @returns {Object} Plugin definition
|
|
25
37
|
*/
|
|
26
38
|
function siteAnalyticsPlugin(options = {}) {
|
|
27
39
|
const {
|
|
28
40
|
db,
|
|
41
|
+
title,
|
|
42
|
+
adminTitle,
|
|
29
43
|
excludePaths = [],
|
|
30
44
|
trackBots = true,
|
|
31
45
|
tableName = 'analytics_page_views',
|
|
@@ -33,14 +47,103 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
33
47
|
errorsTableName = 'analytics_client_errors',
|
|
34
48
|
} = options;
|
|
35
49
|
|
|
50
|
+
const pageTitle = title || adminTitle || 'Analytics';
|
|
51
|
+
|
|
36
52
|
if (!db) {
|
|
37
53
|
throw new Error('site-analytics plugin requires a database instance. Pass `db` in options.');
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
// Retention configuration
|
|
57
|
+
const retentionOption = options.retention;
|
|
58
|
+
const isRetentionDisabled =
|
|
59
|
+
retentionOption === false ||
|
|
60
|
+
(typeof retentionOption === 'object' && retentionOption.enabled === false) ||
|
|
61
|
+
options.retentionDays === null ||
|
|
62
|
+
options.retentionDays === 0;
|
|
63
|
+
|
|
64
|
+
const autoCleanupEnabled =
|
|
65
|
+
!isRetentionDisabled &&
|
|
66
|
+
options.autoCleanup !== false &&
|
|
67
|
+
(typeof retentionOption !== 'object' || retentionOption.autoCleanup !== false);
|
|
68
|
+
|
|
69
|
+
const retentionConfig = {
|
|
70
|
+
enabled: !isRetentionDisabled,
|
|
71
|
+
autoCleanup: autoCleanupEnabled,
|
|
72
|
+
days: (typeof options.retentionDays === 'number' && options.retentionDays > 0)
|
|
73
|
+
? options.retentionDays
|
|
74
|
+
: (typeof retentionOption === 'object' && retentionOption?.days ? retentionOption.days : 90),
|
|
75
|
+
errorDays: (typeof options.errorRetentionDays === 'number' && options.errorRetentionDays > 0)
|
|
76
|
+
? options.errorRetentionDays
|
|
77
|
+
: (typeof retentionOption === 'object' && retentionOption?.errorDays ? retentionOption.errorDays : 30),
|
|
78
|
+
intervalMs: (typeof options.cleanupIntervalMs === 'number' && options.cleanupIntervalMs > 0)
|
|
79
|
+
? options.cleanupIntervalMs
|
|
80
|
+
: (typeof retentionOption === 'object' && retentionOption?.intervalMs ? retentionOption.intervalMs : 24 * 60 * 60 * 1000),
|
|
81
|
+
runOnStart: (typeof retentionOption === 'object' && retentionOption?.runOnStart !== undefined)
|
|
82
|
+
? !!retentionOption.runOnStart
|
|
83
|
+
: (options.cleanupOnStart !== undefined ? !!options.cleanupOnStart : true),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let cleanupInterval = null;
|
|
87
|
+
let cleanupStartTimeout = null;
|
|
88
|
+
|
|
89
|
+
async function runCleanup() {
|
|
90
|
+
try {
|
|
91
|
+
const knex = db.knex || db;
|
|
92
|
+
return await purgeAnalyticsData(knex, {
|
|
93
|
+
tableName,
|
|
94
|
+
errorsTableName,
|
|
95
|
+
days: retentionConfig.days,
|
|
96
|
+
errorDays: retentionConfig.errorDays,
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
100
|
+
console.warn('[site-analytics] Failed to run retention cleanup:', err.message);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function startCleanup(customIntervalMs) {
|
|
106
|
+
stopCleanup();
|
|
107
|
+
if (!retentionConfig.enabled) return;
|
|
108
|
+
if (typeof customIntervalMs === 'number' && customIntervalMs > 0) {
|
|
109
|
+
retentionConfig.intervalMs = customIntervalMs;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (retentionConfig.runOnStart) {
|
|
113
|
+
const delayMs = typeof options.cleanupStartDelayMs === 'number'
|
|
114
|
+
? options.cleanupStartDelayMs
|
|
115
|
+
: (process.env.NODE_ENV === 'test' ? 50 : 5000);
|
|
116
|
+
cleanupStartTimeout = setTimeout(() => {
|
|
117
|
+
runCleanup();
|
|
118
|
+
}, delayMs);
|
|
119
|
+
if (cleanupStartTimeout && cleanupStartTimeout.unref) {
|
|
120
|
+
cleanupStartTimeout.unref();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
cleanupInterval = setInterval(() => {
|
|
125
|
+
runCleanup();
|
|
126
|
+
}, retentionConfig.intervalMs);
|
|
127
|
+
if (cleanupInterval && cleanupInterval.unref) {
|
|
128
|
+
cleanupInterval.unref();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function stopCleanup() {
|
|
133
|
+
if (cleanupStartTimeout) {
|
|
134
|
+
clearTimeout(cleanupStartTimeout);
|
|
135
|
+
cleanupStartTimeout = null;
|
|
136
|
+
}
|
|
137
|
+
if (cleanupInterval) {
|
|
138
|
+
clearInterval(cleanupInterval);
|
|
139
|
+
cleanupInterval = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
40
143
|
return {
|
|
41
144
|
name: 'site-analytics',
|
|
42
145
|
version: '1.0.0',
|
|
43
|
-
description: 'Self-hosted page view analytics with admin dashboard',
|
|
146
|
+
description: 'Self-hosted page view analytics with admin dashboard and auto-retention cleanup',
|
|
44
147
|
dependencies: { 'admin-panel': '*' },
|
|
45
148
|
|
|
46
149
|
csp: {
|
|
@@ -48,6 +151,29 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
48
151
|
connectSrc: ['https://cdn.jsdelivr.net'],
|
|
49
152
|
},
|
|
50
153
|
|
|
154
|
+
api: {
|
|
155
|
+
purge(opts = {}) {
|
|
156
|
+
const knex = db.knex || db;
|
|
157
|
+
return purgeAnalyticsData(knex, {
|
|
158
|
+
tableName,
|
|
159
|
+
errorsTableName,
|
|
160
|
+
days: opts.days !== undefined ? opts.days : (opts.retentionDays !== undefined ? opts.retentionDays : retentionConfig.days),
|
|
161
|
+
errorDays: opts.errorDays !== undefined ? opts.errorDays : (opts.errorRetentionDays !== undefined ? opts.errorRetentionDays : retentionConfig.errorDays),
|
|
162
|
+
...opts,
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
purgeAnalyticsData: (kx, opts) => purgeAnalyticsData(kx || db.knex || db, {
|
|
166
|
+
tableName,
|
|
167
|
+
errorsTableName,
|
|
168
|
+
...opts,
|
|
169
|
+
}),
|
|
170
|
+
startCleanup,
|
|
171
|
+
stopCleanup,
|
|
172
|
+
getRetentionConfig() {
|
|
173
|
+
return { ...retentionConfig };
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
|
|
51
177
|
register(ctx) {
|
|
52
178
|
const knex = db.knex || db;
|
|
53
179
|
|
|
@@ -66,6 +192,22 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
66
192
|
const script = generateErrorTrackerScript({ endpoint: '/_analytics/report-error' });
|
|
67
193
|
ctx.injectBody(`<script>${script}</script>`, { id: 'site-analytics-error-tracker', priority: 5 });
|
|
68
194
|
}
|
|
195
|
+
|
|
196
|
+
if (retentionConfig.enabled && autoCleanupEnabled) {
|
|
197
|
+
startCleanup();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
stopCleanup();
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
dispose() {
|
|
206
|
+
stopCleanup();
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
shutdown() {
|
|
210
|
+
stopCleanup();
|
|
69
211
|
},
|
|
70
212
|
|
|
71
213
|
onRoutesReady(ctx) {
|
|
@@ -91,10 +233,11 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
91
233
|
|
|
92
234
|
adminApi.registerModule({
|
|
93
235
|
id: 'analytics',
|
|
236
|
+
title: pageTitle,
|
|
94
237
|
|
|
95
238
|
pages: [{
|
|
96
239
|
id: 'analytics',
|
|
97
|
-
title:
|
|
240
|
+
title: pageTitle,
|
|
98
241
|
path: '/analytics',
|
|
99
242
|
icon: 'chart',
|
|
100
243
|
component: generateAnalyticsComponent(),
|
|
@@ -119,6 +262,7 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
119
262
|
{ method: 'get', path: '/countries', handler: handlers.getCountries },
|
|
120
263
|
{ method: 'get', path: '/client-errors', handler: handlers.getClientErrors },
|
|
121
264
|
{ method: 'get', path: '/recent', handler: handlers.getRecent },
|
|
265
|
+
{ method: 'post', path: '/purge', handler: handlers.purge },
|
|
122
266
|
],
|
|
123
267
|
},
|
|
124
268
|
});
|
|
@@ -127,3 +271,4 @@ function siteAnalyticsPlugin(options = {}) {
|
|
|
127
271
|
}
|
|
128
272
|
|
|
129
273
|
module.exports = siteAnalyticsPlugin;
|
|
274
|
+
module.exports.purgeAnalyticsData = purgeAnalyticsData;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics Data Purge & Retention Service
|
|
3
|
+
* Deletes page views and client errors older than a retention cutoff to prevent database bloat.
|
|
4
|
+
* @module plugins/site-analytics/purge
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Purge old analytics data from database
|
|
11
|
+
* @param {import('knex').Knex} knex - Knex query builder instance
|
|
12
|
+
* @param {Object} [options={}] - Purge options
|
|
13
|
+
* @param {string} [options.tableName='analytics_page_views'] - Page views table name
|
|
14
|
+
* @param {string} [options.errorsTableName='analytics_client_errors'] - Client errors table name
|
|
15
|
+
* @param {Date|string|number} [options.olderThan] - Cutoff date for page views
|
|
16
|
+
* @param {Date|string|number} [options.errorsOlderThan] - Cutoff date for client errors
|
|
17
|
+
* @param {Date|string|number} [options.errorOlderThan] - Alias for errorsOlderThan
|
|
18
|
+
* @param {number} [options.days] - Days of page views to keep (if olderThan not specified)
|
|
19
|
+
* @param {number} [options.retentionDays=90] - Days of page views to keep (alias for days)
|
|
20
|
+
* @param {number} [options.errorDays] - Days of client errors to keep (if errorsOlderThan not specified)
|
|
21
|
+
* @param {number} [options.errorRetentionDays=30] - Days of client errors to keep (alias for errorDays)
|
|
22
|
+
* @param {boolean} [options.purgePageViews=true] - Whether to purge page views
|
|
23
|
+
* @param {boolean} [options.purgeErrors=true] - Whether to purge client errors
|
|
24
|
+
* @returns {Promise<{ ok: boolean, success: boolean, viewsDeleted: number, pageViewsDeleted: number, errorsDeleted: number, totalDeleted: number, olderThan: Date, errorOlderThan: Date, viewsCutoff: string, errorsCutoff: string }>}
|
|
25
|
+
*/
|
|
26
|
+
async function purgeAnalyticsData(knex, options = {}) {
|
|
27
|
+
if (!knex) {
|
|
28
|
+
throw new Error('purgeAnalyticsData requires a database (knex) instance');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tableName = options.tableName || 'analytics_page_views';
|
|
32
|
+
const errorsTableName = options.errorsTableName || 'analytics_client_errors';
|
|
33
|
+
const purgePageViews = options.purgePageViews !== false;
|
|
34
|
+
const purgeErrors = options.purgeErrors !== false;
|
|
35
|
+
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
|
|
38
|
+
// Validate days / retentionDays
|
|
39
|
+
if (options.days !== undefined) {
|
|
40
|
+
const d = typeof options.days === 'number' ? options.days : Number(options.days);
|
|
41
|
+
if (Number.isNaN(d) || d <= 0) {
|
|
42
|
+
throw new Error('purgeAnalyticsData: days must be a positive number');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (options.retentionDays !== undefined) {
|
|
46
|
+
const d = typeof options.retentionDays === 'number' ? options.retentionDays : Number(options.retentionDays);
|
|
47
|
+
if (Number.isNaN(d) || d <= 0) {
|
|
48
|
+
throw new Error('purgeAnalyticsData: retentionDays must be a positive number');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Validate errorDays / errorRetentionDays
|
|
53
|
+
if (options.errorDays !== undefined) {
|
|
54
|
+
const ed = typeof options.errorDays === 'number' ? options.errorDays : Number(options.errorDays);
|
|
55
|
+
if (Number.isNaN(ed) || ed <= 0) {
|
|
56
|
+
throw new Error('purgeAnalyticsData: errorDays must be a positive number');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (options.errorRetentionDays !== undefined) {
|
|
60
|
+
const ed = typeof options.errorRetentionDays === 'number' ? options.errorRetentionDays : Number(options.errorRetentionDays);
|
|
61
|
+
if (Number.isNaN(ed) || ed <= 0) {
|
|
62
|
+
throw new Error('purgeAnalyticsData: errorRetentionDays must be a positive number');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Calculate or validate page views cutoff
|
|
67
|
+
let olderThan;
|
|
68
|
+
if (options.olderThan !== undefined) {
|
|
69
|
+
olderThan = options.olderThan instanceof Date ? options.olderThan : new Date(options.olderThan);
|
|
70
|
+
if (Number.isNaN(olderThan.getTime())) {
|
|
71
|
+
throw new Error('purgeAnalyticsData: invalid olderThan date');
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
const daysRaw = options.days !== undefined ? options.days : (options.retentionDays !== undefined ? options.retentionDays : 90);
|
|
75
|
+
const days = Number(daysRaw);
|
|
76
|
+
olderThan = new Date(now - days * 24 * 60 * 60 * 1000);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Calculate or validate client errors cutoff
|
|
80
|
+
let errorOlderThan;
|
|
81
|
+
const rawErrorCutoff = options.errorsOlderThan !== undefined ? options.errorsOlderThan : options.errorOlderThan;
|
|
82
|
+
if (rawErrorCutoff !== undefined) {
|
|
83
|
+
errorOlderThan = rawErrorCutoff instanceof Date ? rawErrorCutoff : new Date(rawErrorCutoff);
|
|
84
|
+
if (Number.isNaN(errorOlderThan.getTime())) {
|
|
85
|
+
throw new Error('purgeAnalyticsData: invalid errorsOlderThan date');
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const errDaysRaw = options.errorDays !== undefined
|
|
89
|
+
? options.errorDays
|
|
90
|
+
: (options.errorRetentionDays !== undefined ? options.errorRetentionDays : 30);
|
|
91
|
+
const errDays = Number(errDaysRaw);
|
|
92
|
+
errorOlderThan = new Date(now - errDays * 24 * 60 * 60 * 1000);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function applyCutoffFilter(query, columnName, cutoffDate) {
|
|
96
|
+
const client = knex?.client?.config?.client;
|
|
97
|
+
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
|
|
98
|
+
if (isSqlite) {
|
|
99
|
+
const iso = cutoffDate.toISOString();
|
|
100
|
+
const formatted = iso.replace('T', ' ').replace(/\..+$/, '');
|
|
101
|
+
return query.where((builder) => {
|
|
102
|
+
builder
|
|
103
|
+
.where(columnName, '<', iso)
|
|
104
|
+
.orWhere(columnName, '<', formatted)
|
|
105
|
+
.orWhere(columnName, '<', cutoffDate.getTime());
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return query.where(columnName, '<', cutoffDate);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let pageViewsDeleted = 0;
|
|
112
|
+
let errorsDeleted = 0;
|
|
113
|
+
|
|
114
|
+
if (purgePageViews) {
|
|
115
|
+
let hasTable = true;
|
|
116
|
+
if (knex.schema && typeof knex.schema.hasTable === 'function') {
|
|
117
|
+
try {
|
|
118
|
+
hasTable = await knex.schema.hasTable(tableName);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
hasTable = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (hasTable) {
|
|
124
|
+
try {
|
|
125
|
+
const deleted = await applyCutoffFilter(knex(tableName), 'created_at', olderThan).delete();
|
|
126
|
+
pageViewsDeleted = Number(deleted) || 0;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// Table or deletion error handled safely
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (purgeErrors) {
|
|
134
|
+
let hasTable = true;
|
|
135
|
+
if (knex.schema && typeof knex.schema.hasTable === 'function') {
|
|
136
|
+
try {
|
|
137
|
+
hasTable = await knex.schema.hasTable(errorsTableName);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
hasTable = false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (hasTable) {
|
|
143
|
+
try {
|
|
144
|
+
const deleted = await applyCutoffFilter(knex(errorsTableName), 'created_at', errorOlderThan).delete();
|
|
145
|
+
errorsDeleted = Number(deleted) || 0;
|
|
146
|
+
} catch (e) {
|
|
147
|
+
// Table or deletion error handled safely
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
success: true,
|
|
155
|
+
viewsDeleted: pageViewsDeleted,
|
|
156
|
+
pageViewsDeleted,
|
|
157
|
+
errorsDeleted,
|
|
158
|
+
totalDeleted: pageViewsDeleted + errorsDeleted,
|
|
159
|
+
olderThan,
|
|
160
|
+
errorOlderThan,
|
|
161
|
+
viewsCutoff: olderThan.toISOString(),
|
|
162
|
+
errorsCutoff: errorOlderThan.toISOString(),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
purgeAnalyticsData,
|
|
168
|
+
};
|