@beechcms/api 0.4.0 → 0.4.2
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/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +80 -80
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +22 -10
- package/src/features/content/handlers/delete.ts +21 -10
- package/src/features/content/handlers/update.ts +21 -9
- package/src/features/draft/draft.handler.ts +51 -153
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +64 -176
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/index.ts +40 -8
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +72 -5
- package/src/middleware.ts +15 -35
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -166
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +59 -216
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/automations.repository.d1.ts +146 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.test.ts +76 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/execution-context-scheduler.ts +9 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage-utils.ts +3 -3
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +24 -3
- package/src/upload.ts +17 -9
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-CewtCjom.css +0 -1
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { D1SystemStatsRepository } from './system-stats.repository.d1'
|
|
3
|
+
|
|
4
|
+
function makeMockDb(firstResult: unknown = null) {
|
|
5
|
+
const runMock = vi.fn().mockResolvedValue({ success: true })
|
|
6
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
7
|
+
// bind() returns the same statement interface for chained calls.
|
|
8
|
+
// The statement also exposes first() directly for queries without bind (e.g. getStorageUsage).
|
|
9
|
+
const bindMock = vi.fn(() => ({ run: runMock, first: firstMock }))
|
|
10
|
+
const stmt = { bind: bindMock, run: runMock, first: firstMock }
|
|
11
|
+
const prepareMock = vi.fn(() => stmt)
|
|
12
|
+
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('D1SystemStatsRepository', () => {
|
|
16
|
+
describe('incrementStorage', () => {
|
|
17
|
+
it('calls UPDATE system_stats with the byte count', async () => {
|
|
18
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
19
|
+
await new D1SystemStatsRepository(db).incrementStorage(512)
|
|
20
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE system_stats'))
|
|
21
|
+
expect(bindMock).toHaveBeenCalledWith(512)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('decrementStorage', () => {
|
|
26
|
+
it('calls UPDATE system_stats with MAX(0, ...) and the byte count', async () => {
|
|
27
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
28
|
+
await new D1SystemStatsRepository(db).decrementStorage(256)
|
|
29
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('MAX(0'))
|
|
30
|
+
expect(bindMock).toHaveBeenCalledWith(256)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('setStorage', () => {
|
|
35
|
+
it('calls UPDATE system_stats with the stringified byte count', async () => {
|
|
36
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
37
|
+
await new D1SystemStatsRepository(db).setStorage(1024)
|
|
38
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE system_stats'))
|
|
39
|
+
expect(bindMock).toHaveBeenCalledWith('1024')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('getStorageUsage', () => {
|
|
44
|
+
it('parses and returns the integer value from D1', async () => {
|
|
45
|
+
const { db } = makeMockDb({ value: '2048' })
|
|
46
|
+
expect(await new D1SystemStatsRepository(db).getStorageUsage()).toBe(2048)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns 0 when D1 returns null (stat not initialised)', async () => {
|
|
50
|
+
const { db } = makeMockDb(null)
|
|
51
|
+
expect(await new D1SystemStatsRepository(db).getStorageUsage()).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
|
|
2
|
+
import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository, IHashProvider, ITokenService, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogger, IActivityLogRepository, INotificationRepository, INotificationService, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, ISeedRegistry, IClock, IIdGenerator, IAutomationRunner, IAutomationRepository, IScheduler } from '@beechcms/core'
|
|
3
|
+
import type { IRateLimiterRegistry } from './middleware/rate-limit.middleware'
|
|
3
4
|
|
|
4
5
|
export interface Env {
|
|
5
6
|
DB: D1Database
|
|
@@ -22,6 +23,7 @@ export interface Env {
|
|
|
22
23
|
MEDIA_BASE_URL?: string
|
|
23
24
|
MEDIA_CDN_URL?: string
|
|
24
25
|
RESEND_API_KEY?: string
|
|
26
|
+
EMAIL_API_KEY?: string
|
|
25
27
|
APP_URL?: string
|
|
26
28
|
EMAIL_FROM?: string
|
|
27
29
|
FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
|
|
@@ -33,14 +35,33 @@ export interface Env {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface Variables {
|
|
36
|
-
jwtPayload: { sub: string; email?: string }
|
|
38
|
+
jwtPayload: { sub: string; email?: string; name?: string | null }
|
|
37
39
|
getSeed: (slug: string) => Seed | null
|
|
38
|
-
seedRegistry:
|
|
40
|
+
seedRegistry: ISeedRegistry
|
|
39
41
|
repository: ContentRepository
|
|
40
42
|
idempotencyRepository: IdempotencyRepository
|
|
41
43
|
bucket: BeechBucket
|
|
42
44
|
mediaRepository: MediaRepository
|
|
43
45
|
systemStatsRepository: SystemStatsRepository
|
|
46
|
+
hashProvider: IHashProvider
|
|
47
|
+
tokenService: ITokenService
|
|
48
|
+
userRepository: IUserRepository
|
|
49
|
+
sessionRepository: ISessionRepository
|
|
50
|
+
passwordResetTokenRepository: IPasswordResetTokenRepository
|
|
51
|
+
rateLimiters: IRateLimiterRegistry
|
|
52
|
+
activityLogger: IActivityLogger
|
|
53
|
+
activityLogRepository: IActivityLogRepository
|
|
54
|
+
notificationRepository: INotificationRepository
|
|
55
|
+
notificationService: INotificationService
|
|
56
|
+
widgetRepository: IWidgetRepository
|
|
57
|
+
searchRepository: ISearchRepository
|
|
58
|
+
analyticsRepository: IAnalyticsRepository
|
|
59
|
+
contentScanRepository: IContentScanRepository
|
|
60
|
+
clock: IClock
|
|
61
|
+
idGenerator: IIdGenerator
|
|
62
|
+
automationRepository: IAutomationRepository
|
|
63
|
+
automationRunner: IAutomationRunner
|
|
64
|
+
scheduler: IScheduler
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
export type AppEnv = { Bindings: Env; Variables: Variables }
|
package/src/upload.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* e facilità di sviluppo locale.
|
|
6
6
|
*/
|
|
7
7
|
import { Hono } from 'hono'
|
|
8
|
-
import { logActivity } from './shared/activity-logger'
|
|
9
8
|
import { AppEnv } from './types'
|
|
10
9
|
|
|
11
10
|
/** Prefissi MIME consentiti (immagini e PDF) */
|
|
@@ -133,12 +132,20 @@ uploadRoutes.post('/upload', async (c) => {
|
|
|
133
132
|
|
|
134
133
|
const publicUrl = bucket.getUrl(objectKey)
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
const jwtPayload = c.get('jwtPayload')
|
|
136
|
+
if (jwtPayload) {
|
|
137
|
+
c.get('activityLogger').log({
|
|
138
|
+
action: 'upload',
|
|
139
|
+
entityType: 'media',
|
|
140
|
+
entityId: objectKey,
|
|
141
|
+
details: { name: file.name, size: file.size, type: file.type },
|
|
142
|
+
actor: {
|
|
143
|
+
id: jwtPayload.sub,
|
|
144
|
+
email: jwtPayload.email ?? 'unknown',
|
|
145
|
+
name: jwtPayload.name ?? null,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
142
149
|
|
|
143
150
|
return c.json({ url: publicUrl }, 200)
|
|
144
151
|
} catch (err) {
|
|
@@ -151,7 +158,7 @@ uploadRoutes.post('/upload', async (c) => {
|
|
|
151
158
|
uploadRoutes.delete('/upload/:key', async (c) => {
|
|
152
159
|
const key = c.req.param('key')
|
|
153
160
|
if (!key) return c.json({ error: 'Missing key' }, 400)
|
|
154
|
-
|
|
161
|
+
|
|
155
162
|
await deleteR2Objects(c, [decodeURIComponent(key)])
|
|
156
163
|
return c.json({ success: true }, 200)
|
|
157
164
|
})
|
|
@@ -171,9 +178,10 @@ export async function serveMediaHandler(c: any): Promise<Response> {
|
|
|
171
178
|
const headers = new Headers()
|
|
172
179
|
headers.set('Content-Type', object.contentType ?? 'application/octet-stream')
|
|
173
180
|
headers.set('Cache-Control', 'public, max-age=31536000, immutable')
|
|
174
|
-
|
|
181
|
+
|
|
175
182
|
return new Response(object.body, { status: 200, headers })
|
|
176
183
|
} catch (err) {
|
|
184
|
+
console.error(`[serveMediaHandler] Error serving file ${key}:`, err)
|
|
177
185
|
return new Response('Internal error', { status: 500 })
|
|
178
186
|
}
|
|
179
187
|
}
|
package/src/widget.ts
CHANGED
|
@@ -1,91 +1,15 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
3
|
import { deserializeFromDb } from '@beechcms/core'
|
|
4
|
-
import type {
|
|
4
|
+
import type { AggregateFormula, TimeWindow } from '@beechcms/core'
|
|
5
5
|
import type { Env, Variables } from './types'
|
|
6
6
|
|
|
7
7
|
const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
| { op: 'sum'; column: string }
|
|
14
|
-
| { op: 'avg'; column: string }
|
|
15
|
-
| { op: 'min'; column: string }
|
|
16
|
-
| { op: 'max'; column: string }
|
|
17
|
-
| { op: 'countWhere'; column: string; value: unknown }
|
|
18
|
-
| { op: 'percentageOf'; numeratorColumn: string; denominatorColumn: string }
|
|
19
|
-
|
|
20
|
-
type TimeWindow = 'week' | 'month' | 'year' | 'all'
|
|
21
|
-
|
|
22
|
-
function timeWindowSql(window: TimeWindow): string {
|
|
23
|
-
switch (window) {
|
|
24
|
-
case 'week': return "created_at >= unixepoch('now', '-7 days')"
|
|
25
|
-
case 'month': return "created_at >= unixepoch('now', '-1 month')"
|
|
26
|
-
case 'year': return "created_at >= unixepoch('now', '-1 year')"
|
|
27
|
-
case 'all': return '1=1'
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function previousWindowSql(window: TimeWindow): { current: string; previous: string } {
|
|
32
|
-
switch (window) {
|
|
33
|
-
case 'week':
|
|
34
|
-
return {
|
|
35
|
-
current: "created_at >= unixepoch('now', '-7 days')",
|
|
36
|
-
previous: "created_at >= unixepoch('now', '-14 days') AND created_at < unixepoch('now', '-7 days')",
|
|
37
|
-
}
|
|
38
|
-
case 'month':
|
|
39
|
-
return {
|
|
40
|
-
current: "created_at >= unixepoch('now', '-1 month')",
|
|
41
|
-
previous: "created_at >= unixepoch('now', '-2 months') AND created_at < unixepoch('now', '-1 month')",
|
|
42
|
-
}
|
|
43
|
-
case 'year':
|
|
44
|
-
return {
|
|
45
|
-
current: "created_at >= unixepoch('now', '-1 year')",
|
|
46
|
-
previous: "created_at >= unixepoch('now', '-2 years') AND created_at < unixepoch('now', '-1 year')",
|
|
47
|
-
}
|
|
48
|
-
case 'all':
|
|
49
|
-
return { current: '1=1', previous: '1=0' }
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const SYSTEM_COLUMNS = new Set(['created_at', 'updated_at', 'status', 'id', 'slug'])
|
|
54
|
-
|
|
55
|
-
// In v0.4.0 alias = column name. Validate against seed to prevent injection.
|
|
56
|
-
function resolveColumnExpr(seed: Seed, alias: string): string {
|
|
57
|
-
if (SYSTEM_COLUMNS.has(alias)) return alias
|
|
58
|
-
const branch = seed.branches.find(b => b.alias === alias)
|
|
59
|
-
return branch ? branch.alias : 'id'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function buildAggregateExpr(seed: Seed, formula: AggregateFormula): string {
|
|
63
|
-
switch (formula.op) {
|
|
64
|
-
case 'count':
|
|
65
|
-
return 'COUNT(*)'
|
|
66
|
-
case 'sum':
|
|
67
|
-
return `SUM(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
68
|
-
case 'avg':
|
|
69
|
-
return `AVG(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
70
|
-
case 'min':
|
|
71
|
-
return `MIN(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
72
|
-
case 'max':
|
|
73
|
-
return `MAX(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
74
|
-
case 'countWhere': {
|
|
75
|
-
const expr = resolveColumnExpr(seed, formula.column)
|
|
76
|
-
const val = formula.value
|
|
77
|
-
if (val === null) return `COUNT(CASE WHEN ${expr} IS NULL THEN 1 END)`
|
|
78
|
-
if (typeof val === 'boolean') return `COUNT(CASE WHEN ${expr} = ${val ? 1 : 0} THEN 1 END)`
|
|
79
|
-
if (typeof val === 'number') return `COUNT(CASE WHEN CAST(${expr} AS REAL) = ${val} THEN 1 END)`
|
|
80
|
-
return `COUNT(CASE WHEN ${expr} = '${String(val).replace(/'/g, "''")}' THEN 1 END)`
|
|
81
|
-
}
|
|
82
|
-
case 'percentageOf': {
|
|
83
|
-
const num = resolveColumnExpr(seed, formula.numeratorColumn)
|
|
84
|
-
const den = resolveColumnExpr(seed, formula.denominatorColumn)
|
|
85
|
-
return `CASE WHEN SUM(CAST(${den} AS REAL)) = 0 THEN 0 ELSE (SUM(CAST(${num} AS REAL)) * 100.0 / SUM(CAST(${den} AS REAL))) END`
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
9
|
+
const DEFAULT_LEADERBOARD_LIMIT = 10
|
|
10
|
+
const MAXIMUM_LEADERBOARD_LIMIT = 100
|
|
11
|
+
const DEFAULT_LIST_LIMIT = 25
|
|
12
|
+
const MAXIMUM_LIST_LIMIT = 100
|
|
89
13
|
|
|
90
14
|
function parseFormula(raw: string | undefined): AggregateFormula | null {
|
|
91
15
|
if (!raw) return null
|
|
@@ -103,178 +27,130 @@ function parseWindow(raw: string | undefined): TimeWindow {
|
|
|
103
27
|
return 'all'
|
|
104
28
|
}
|
|
105
29
|
|
|
106
|
-
function
|
|
30
|
+
function parseBoundedInt(raw: string | undefined, fallback: number, maximum: number, minimum = 1): number {
|
|
31
|
+
const parsed = parseInt(raw ?? String(fallback), 10)
|
|
32
|
+
if (!Number.isFinite(parsed) || parsed < minimum) return fallback
|
|
33
|
+
return Math.min(parsed, maximum)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function problem(status: number, title: string, detail: string) {
|
|
107
37
|
return { type: 'about:blank', title, status, detail }
|
|
108
38
|
}
|
|
109
39
|
|
|
110
|
-
|
|
40
|
+
function isUnsafeColumnError(error: unknown): boolean {
|
|
41
|
+
return error instanceof Error && error.message === 'UNSAFE_COLUMN'
|
|
42
|
+
}
|
|
111
43
|
|
|
112
|
-
widgetApp.get('/aggregate/:seed', async (
|
|
113
|
-
const seedSlug =
|
|
114
|
-
const seed =
|
|
115
|
-
if (!seed) return
|
|
44
|
+
widgetApp.get('/aggregate/:seed', async (context) => {
|
|
45
|
+
const seedSlug = context.req.param('seed')
|
|
46
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
47
|
+
if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
116
48
|
|
|
117
|
-
const formula = parseFormula(
|
|
118
|
-
if (!formula) return
|
|
49
|
+
const formula = parseFormula(context.req.query('formula'))
|
|
50
|
+
if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
|
|
119
51
|
|
|
120
|
-
const window = parseWindow(
|
|
121
|
-
const aggExpr = buildAggregateExpr(seed, formula)
|
|
52
|
+
const window = parseWindow(context.req.query('window'))
|
|
122
53
|
|
|
123
54
|
try {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
55
|
+
const value = await context.get('widgetRepository').aggregate(seed, formula, window)
|
|
56
|
+
return context.json({ value, window })
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
|
|
59
|
+
console.error('[widget/aggregate] DB error:', error)
|
|
60
|
+
return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
|
|
131
61
|
}
|
|
132
62
|
})
|
|
133
63
|
|
|
134
|
-
widgetApp.get('/growth/:seed', async (
|
|
135
|
-
const seedSlug =
|
|
136
|
-
const seed =
|
|
137
|
-
if (!seed) return
|
|
64
|
+
widgetApp.get('/growth/:seed', async (context) => {
|
|
65
|
+
const seedSlug = context.req.param('seed')
|
|
66
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
67
|
+
if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
138
68
|
|
|
139
|
-
const formula = parseFormula(
|
|
140
|
-
if (!formula) return
|
|
69
|
+
const formula = parseFormula(context.req.query('formula'))
|
|
70
|
+
if (!formula) return context.json(problem(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
|
|
141
71
|
|
|
142
|
-
const window = parseWindow(
|
|
143
|
-
const { current: currentSql, previous: previousSql } = previousWindowSql(window)
|
|
144
|
-
const aggExpr = buildAggregateExpr(seed, formula)
|
|
145
|
-
const table = `content_${seed.slug}`
|
|
72
|
+
const window = parseWindow(context.req.query('window'))
|
|
146
73
|
|
|
147
74
|
try {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
])
|
|
152
|
-
|
|
153
|
-
const current = currentRow?.value ?? 0
|
|
154
|
-
const previous = previousRow?.value ?? 0
|
|
75
|
+
const { currentValue, previousValue } = await context
|
|
76
|
+
.get('widgetRepository')
|
|
77
|
+
.growth(seed, formula, window)
|
|
155
78
|
|
|
156
79
|
let percentageChange = 0
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
percentageChange = Math.round(((current - previous) / Math.abs(previous)) * 1000) / 10
|
|
161
|
-
} else if (current > 0) {
|
|
80
|
+
if (previousValue !== 0) {
|
|
81
|
+
percentageChange = Math.round(((currentValue - previousValue) / Math.abs(previousValue)) * 1000) / 10
|
|
82
|
+
} else if (currentValue > 0) {
|
|
162
83
|
percentageChange = 100
|
|
163
84
|
}
|
|
164
85
|
|
|
86
|
+
let trend: 'up' | 'down' | 'flat' = 'flat'
|
|
165
87
|
if (percentageChange > 0) trend = 'up'
|
|
166
88
|
else if (percentageChange < 0) trend = 'down'
|
|
167
89
|
|
|
168
|
-
return
|
|
169
|
-
} catch (
|
|
170
|
-
|
|
171
|
-
|
|
90
|
+
return context.json({ current: currentValue, previous: previousValue, percentageChange, trend })
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
|
|
93
|
+
console.error('[widget/growth] DB error:', error)
|
|
94
|
+
return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
|
|
172
95
|
}
|
|
173
96
|
})
|
|
174
97
|
|
|
175
|
-
widgetApp.get('/leaderboard/:seed', async (
|
|
176
|
-
const seedSlug =
|
|
177
|
-
const seed =
|
|
178
|
-
if (!seed) return
|
|
98
|
+
widgetApp.get('/leaderboard/:seed', async (context) => {
|
|
99
|
+
const seedSlug = context.req.param('seed')
|
|
100
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
101
|
+
if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
179
102
|
|
|
180
|
-
const scoreColumn =
|
|
181
|
-
if (!scoreColumn) return
|
|
103
|
+
const scoreColumn = context.req.query('scoreColumn')
|
|
104
|
+
if (!scoreColumn) return context.json(problem(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
|
|
182
105
|
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const orderDir = c.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
|
|
186
|
-
const scoreExpr = resolveColumnExpr(seed, scoreColumn)
|
|
187
|
-
const labelCol = resolveColumnExpr(seed, seed.displayNameAlias)
|
|
188
|
-
const table = `content_${seed.slug}`
|
|
106
|
+
const limit = parseBoundedInt(context.req.query('limit'), DEFAULT_LEADERBOARD_LIMIT, MAXIMUM_LEADERBOARD_LIMIT)
|
|
107
|
+
const orderDirection: 'ASC' | 'DESC' = context.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
|
|
189
108
|
|
|
190
109
|
try {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const entries = (rows.results ?? []).map(row => ({
|
|
200
|
-
id: row.id,
|
|
201
|
-
label: row.label ?? row.id,
|
|
202
|
-
score: row.score ?? 0,
|
|
203
|
-
}))
|
|
204
|
-
|
|
205
|
-
return c.json(entries)
|
|
206
|
-
} catch (err) {
|
|
207
|
-
console.error('[widget/leaderboard] DB error:', err)
|
|
208
|
-
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
110
|
+
const entries = await context
|
|
111
|
+
.get('widgetRepository')
|
|
112
|
+
.leaderboard(seed, { scoreColumn, limit, orderDirection })
|
|
113
|
+
return context.json(entries)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
|
|
116
|
+
console.error('[widget/leaderboard] DB error:', error)
|
|
117
|
+
return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
|
|
209
118
|
}
|
|
210
119
|
})
|
|
211
120
|
|
|
212
|
-
widgetApp.get('/list/:seed', async (
|
|
213
|
-
const seedSlug =
|
|
214
|
-
const seed =
|
|
215
|
-
if (!seed) return
|
|
216
|
-
|
|
217
|
-
const { DB } = c.env
|
|
218
|
-
const query = c.req.query()
|
|
219
|
-
const table = `content_${seed.slug}`
|
|
121
|
+
widgetApp.get('/list/:seed', async (context) => {
|
|
122
|
+
const seedSlug = context.req.param('seed')
|
|
123
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
124
|
+
if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
220
125
|
|
|
221
|
-
const
|
|
222
|
-
const limit =
|
|
126
|
+
const query = context.req.query()
|
|
127
|
+
const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
|
|
223
128
|
const offsetRaw = parseInt(query.offset ?? '0', 10)
|
|
224
129
|
const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
|
|
130
|
+
const search = query.search?.trim() || undefined
|
|
131
|
+
const orderByColumn = query.orderBy || undefined
|
|
132
|
+
const orderDirection: 'ASC' | 'DESC' = query.orderDir === 'desc' ? 'DESC' : 'ASC'
|
|
225
133
|
|
|
226
|
-
|
|
227
|
-
const displayCol = resolveColumnExpr(seed, seed.displayNameAlias)
|
|
228
|
-
|
|
229
|
-
const conditions: string[] = []
|
|
230
|
-
const bindings: unknown[] = []
|
|
231
|
-
|
|
232
|
-
if (search) {
|
|
233
|
-
conditions.push(`${displayCol} LIKE ?`)
|
|
234
|
-
bindings.push(`%${search}%`)
|
|
235
|
-
}
|
|
236
|
-
|
|
134
|
+
let filters: Array<{ column: string; op: string; value: unknown }> | undefined
|
|
237
135
|
if (query.filters) {
|
|
238
136
|
try {
|
|
239
|
-
|
|
240
|
-
for (const f of rawFilters) {
|
|
241
|
-
const expr = resolveColumnExpr(seed, f.column)
|
|
242
|
-
switch (f.op) {
|
|
243
|
-
case '=':
|
|
244
|
-
case 'eq': conditions.push(`${expr} = ?`); bindings.push(f.value); break
|
|
245
|
-
case '!=':
|
|
246
|
-
case 'neq': conditions.push(`${expr} != ?`); bindings.push(f.value); break
|
|
247
|
-
case 'like': conditions.push(`${expr} LIKE ?`); bindings.push(f.value); break
|
|
248
|
-
case '>':
|
|
249
|
-
case 'gt': conditions.push(`CAST(${expr} AS REAL) > ?`); bindings.push(f.value); break
|
|
250
|
-
case '<':
|
|
251
|
-
case 'lt': conditions.push(`CAST(${expr} AS REAL) < ?`); bindings.push(f.value); break
|
|
252
|
-
}
|
|
253
|
-
}
|
|
137
|
+
filters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
|
|
254
138
|
} catch {
|
|
255
|
-
return
|
|
139
|
+
return context.json(problem(400, 'Bad Request', 'Invalid filters JSON'), 400)
|
|
256
140
|
}
|
|
257
141
|
}
|
|
258
142
|
|
|
259
|
-
const orderByAlias = query.orderBy ?? ''
|
|
260
|
-
const orderDir = query.orderDir === 'desc' ? 'DESC' : 'ASC'
|
|
261
|
-
const orderExpr = orderByAlias ? resolveColumnExpr(seed, orderByAlias) : 'created_at'
|
|
262
|
-
const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
263
|
-
|
|
264
143
|
try {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
])
|
|
276
|
-
|
|
277
|
-
const entries = (listRows.results ?? []).map(row => {
|
|
144
|
+
const { entries, totalCount } = await context.get('widgetRepository').list(seed, {
|
|
145
|
+
limit,
|
|
146
|
+
offset,
|
|
147
|
+
search,
|
|
148
|
+
filters,
|
|
149
|
+
orderByColumn,
|
|
150
|
+
orderDirection,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const deserializedEntries = entries.map(row => {
|
|
278
154
|
const data: Record<string, unknown> = {}
|
|
279
155
|
for (const branch of seed.branches) {
|
|
280
156
|
data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
|
|
@@ -289,60 +165,43 @@ widgetApp.get('/list/:seed', async (c) => {
|
|
|
289
165
|
}
|
|
290
166
|
})
|
|
291
167
|
|
|
292
|
-
return
|
|
293
|
-
} catch (
|
|
294
|
-
|
|
295
|
-
|
|
168
|
+
return context.json({ entries: deserializedEntries, total: totalCount })
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
|
|
171
|
+
console.error('[widget/list] DB error:', error)
|
|
172
|
+
return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
|
|
296
173
|
}
|
|
297
174
|
})
|
|
298
175
|
|
|
299
|
-
widgetApp.get('/timeseries/:seed', async (
|
|
300
|
-
const seedSlug =
|
|
301
|
-
const seed =
|
|
302
|
-
if (!seed) return
|
|
176
|
+
widgetApp.get('/timeseries/:seed', async (context) => {
|
|
177
|
+
const seedSlug = context.req.param('seed')
|
|
178
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
179
|
+
if (!seed) return context.json(problem(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
303
180
|
|
|
304
|
-
const valueColumn =
|
|
305
|
-
const groupColumn =
|
|
306
|
-
const formulaOp =
|
|
307
|
-
const window = parseWindow(
|
|
308
|
-
const table = `content_${seed.slug}`
|
|
181
|
+
const valueColumn = context.req.query('valueColumn')
|
|
182
|
+
const groupColumn = context.req.query('groupColumn') ?? 'created_at'
|
|
183
|
+
const formulaOp = context.req.query('formula') ?? 'count'
|
|
184
|
+
const window = parseWindow(context.req.query('window'))
|
|
309
185
|
|
|
310
186
|
if (!valueColumn && formulaOp !== 'count') {
|
|
311
|
-
return
|
|
187
|
+
return context.json(problem(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
|
|
312
188
|
}
|
|
313
189
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
aggExpr = 'COUNT(*)'
|
|
320
|
-
} else if (formulaOp === 'sum' && valueColumn) {
|
|
321
|
-
aggExpr = `SUM(CAST(${resolveColumnExpr(seed, valueColumn)} AS REAL))`
|
|
322
|
-
} else if (formulaOp === 'avg' && valueColumn) {
|
|
323
|
-
aggExpr = `AVG(CAST(${resolveColumnExpr(seed, valueColumn)} AS REAL))`
|
|
324
|
-
} else {
|
|
325
|
-
return c.json(error(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
|
|
326
|
-
}
|
|
190
|
+
let formula: AggregateFormula
|
|
191
|
+
if (formulaOp === 'count') formula = { op: 'count' }
|
|
192
|
+
else if (formulaOp === 'sum' && valueColumn) formula = { op: 'sum', column: valueColumn }
|
|
193
|
+
else if (formulaOp === 'avg' && valueColumn) formula = { op: 'avg', column: valueColumn }
|
|
194
|
+
else return context.json(problem(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
|
|
327
195
|
|
|
328
196
|
try {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
const points = (rows.results ?? []).map(row => ({
|
|
338
|
-
label: row.label ?? '',
|
|
339
|
-
value: row.value ?? 0,
|
|
340
|
-
}))
|
|
341
|
-
|
|
342
|
-
return c.json({ points })
|
|
343
|
-
} catch (err) {
|
|
344
|
-
console.error('[widget/timeseries] DB error:', err)
|
|
345
|
-
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
197
|
+
const points = await context
|
|
198
|
+
.get('widgetRepository')
|
|
199
|
+
.timeseries(seed, formula, window, groupColumn)
|
|
200
|
+
return context.json({ points })
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (isUnsafeColumnError(error)) return context.json(problem(400, 'Bad Request', 'Invalid column reference'), 400)
|
|
203
|
+
console.error('[widget/timeseries] DB error:', error)
|
|
204
|
+
return context.json(problem(500, 'Internal Server Error', 'Database error'), 500)
|
|
346
205
|
}
|
|
347
206
|
})
|
|
348
207
|
|