@beechcms/api 0.5.0 → 0.6.0-preview.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.
Files changed (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
@@ -1,64 +1,68 @@
1
- import { MediaRepository, MediaObject } from '@beechcms/core'
2
-
3
- /**
4
- * Implementation of MediaRepository using Cloudflare D1.
5
- * Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
6
- */
7
- export class D1MediaRepository implements MediaRepository {
8
- constructor(private database: D1Database) {}
9
-
10
- /**
11
- * Registers a new media upload in the database.
12
- */
13
- async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
14
- await this.database.prepare(
15
- 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
16
- ).bind(
17
- mediaObject.key,
18
- mediaObject.filename,
19
- mediaObject.mime_type,
20
- mediaObject.size_bytes,
21
- mediaObject.uploaded_by
22
- ).run()
23
- }
24
-
25
- /**
26
- * Retrieves a media object's metadata by its unique key.
27
- */
28
- async getByKey(mediaKey: string): Promise<MediaObject | null> {
29
- return await this.database.prepare(
30
- 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
31
- ).bind(mediaKey).first<MediaObject>()
32
- }
33
-
34
- /**
35
- * Removes the metadata tracking for a specific media key.
36
- */
37
- async untrack(mediaKey: string): Promise<void> {
38
- await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
39
- }
40
-
41
- /**
42
- * Lists media objects with pagination support.
43
- */
44
- async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
45
- const { results: mediaItems } = await this.database.prepare(
46
- 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
47
- ).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
48
-
49
- const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
50
-
51
- return {
52
- items: mediaItems ?? [],
53
- total: totalCountRecord?.total ?? 0
54
- }
55
- }
56
-
57
- /**
58
- * Returns the total number of media objects in the database.
59
- */
60
- async count(): Promise<number> {
61
- const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
62
- return totalCountRecord?.total ?? 0
63
- }
64
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { MediaRepository, MediaObject } from '@beechcms/core'
6
+
7
+ /**
8
+ * Implementation of MediaRepository using Cloudflare D1.
9
+ * Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
10
+ */
11
+ export class D1MediaRepository implements MediaRepository {
12
+ constructor(private database: D1Database) {}
13
+
14
+ /**
15
+ * Registers a new media upload in the database.
16
+ */
17
+ async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
18
+ await this.database.prepare(
19
+ 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
20
+ ).bind(
21
+ mediaObject.key,
22
+ mediaObject.filename,
23
+ mediaObject.mime_type,
24
+ mediaObject.size_bytes,
25
+ mediaObject.uploaded_by
26
+ ).run()
27
+ }
28
+
29
+ /**
30
+ * Retrieves a media object's metadata by its unique key.
31
+ */
32
+ async getByKey(mediaKey: string): Promise<MediaObject | null> {
33
+ return await this.database.prepare(
34
+ 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
35
+ ).bind(mediaKey).first<MediaObject>()
36
+ }
37
+
38
+ /**
39
+ * Removes the metadata tracking for a specific media key.
40
+ */
41
+ async untrack(mediaKey: string): Promise<void> {
42
+ await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
43
+ }
44
+
45
+ /**
46
+ * Lists media objects with pagination support.
47
+ */
48
+ async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
49
+ const { results: mediaItems } = await this.database.prepare(
50
+ 'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
51
+ ).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
52
+
53
+ const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
54
+
55
+ return {
56
+ items: mediaItems ?? [],
57
+ total: totalCountRecord?.total ?? 0
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns the total number of media objects in the database.
63
+ */
64
+ async count(): Promise<number> {
65
+ const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
66
+ return totalCountRecord?.total ?? 0
67
+ }
68
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
6
+ import { QStashNotificationService } from './qstash-notification-service'
7
+ import { Client } from '@upstash/qstash'
8
+
9
+ // Mock the @upstash/qstash Client
10
+ vi.mock('@upstash/qstash', () => {
11
+ const publishJSON = vi.fn().mockResolvedValue({ messageId: 'msg_123' })
12
+ return {
13
+ Client: vi.fn().mockImplementation(function () {
14
+ return { publishJSON }
15
+ }),
16
+ }
17
+ })
18
+
19
+ describe('QStashNotificationService', () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks()
22
+ })
23
+
24
+ it('delegates publishing to the QStash client with correct URL', async () => {
25
+ const service = new QStashNotificationService('fake_token', 'https://beechcms.test/')
26
+ await service.notify({
27
+ title: 'Hello',
28
+ message: 'World',
29
+ type: 'success',
30
+ })
31
+
32
+ const clientInstance = vi.mocked(Client).mock.results[0].value
33
+ expect(clientInstance.publishJSON).toHaveBeenCalledWith({
34
+ url: 'https://beechcms.test/api/webhooks/qstash',
35
+ body: {
36
+ title: 'Hello',
37
+ message: 'World',
38
+ type: 'success',
39
+ },
40
+ })
41
+ })
42
+
43
+ it('delegates to the background scheduler when provided', () => {
44
+ const schedule = vi.fn()
45
+ const service = new QStashNotificationService('fake_token', 'https://beechcms.test', schedule)
46
+
47
+ service.notify({ title: 'T', message: 'M' })
48
+
49
+ expect(schedule).toHaveBeenCalledTimes(1)
50
+ expect(schedule.mock.calls[0][0]).toBeInstanceOf(Promise)
51
+ })
52
+
53
+ it('never throws to the caller when the publish write fails', async () => {
54
+ // Override the mock for this specific test
55
+ const mockPublish = vi.fn().mockRejectedValue(new Error('network error'))
56
+ vi.mocked(Client).mockImplementationOnce(function () {
57
+ return { publishJSON: mockPublish }
58
+ } as any)
59
+
60
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
61
+ const service = new QStashNotificationService('fake_token', 'https://beechcms.test')
62
+
63
+ await expect(service.notify({ title: 'T', message: 'M' })).resolves.toBeUndefined()
64
+ expect(consoleSpy).toHaveBeenCalled()
65
+ consoleSpy.mockRestore()
66
+ })
67
+ })
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import type { INotificationService, CreateNotificationInput } from '@beechcms/core'
6
+ import { Client } from '@upstash/qstash'
7
+
8
+ type ScheduleBackgroundTask = (task: Promise<unknown>) => void
9
+
10
+ /**
11
+ * QStash implementation of {@link INotificationService}.
12
+ *
13
+ * This service acts as a publisher, sending the notification payload to Upstash QStash,
14
+ * which guarantees delivery and retries. QStash will then forward the payload to the
15
+ * local webhook endpoint where it is actually inserted into D1.
16
+ */
17
+ export class QStashNotificationService implements INotificationService {
18
+ private readonly client: Client
19
+ private readonly webhookUrl: string
20
+
21
+ constructor(
22
+ token: string,
23
+ appUrl: string,
24
+ private readonly scheduleBackgroundTask?: ScheduleBackgroundTask,
25
+ qstashUrl?: string
26
+ ) {
27
+ this.client = new Client({
28
+ token,
29
+ ...(qstashUrl ? { baseUrl: qstashUrl } : {})
30
+ })
31
+ this.webhookUrl = `${appUrl.replace(/\/$/, '')}/api/webhooks/qstash`
32
+ }
33
+
34
+ notify(input: CreateNotificationInput): Promise<void> | void {
35
+ const publishPromise = this.runPublish(input)
36
+
37
+ if (this.scheduleBackgroundTask) {
38
+ this.scheduleBackgroundTask(publishPromise)
39
+ return
40
+ }
41
+
42
+ return publishPromise
43
+ }
44
+
45
+ private async runPublish(input: CreateNotificationInput): Promise<void> {
46
+ try {
47
+ await this.client.publishJSON({
48
+ url: this.webhookUrl,
49
+ body: input,
50
+ })
51
+ } catch (error) {
52
+ console.error('QStashNotificationService: failed to publish notification', error)
53
+ }
54
+ }
55
+ }
@@ -1,137 +1,141 @@
1
- import type { FilterGroup, FilterType } from '@beechcms/core'
2
-
3
- /** Entry parsata per le risposte API contratto immutabile (C7). */
4
- export interface ContentEntry {
5
- id: string
6
- schema_slug: string
7
- slug: string | null
8
- status: string
9
- data: Record<string, unknown>
10
- hasPendingDraft: boolean
11
- created_at: number | null
12
- updated_at: number | null
13
- }
14
-
15
- export type QueryFilterType = FilterType
16
-
17
- export type QueryFilterOperator =
18
- | 'eq'
19
- | 'gt'
20
- | 'gte'
21
- | 'lt'
22
- | 'lte'
23
- | 'contains'
24
- | 'is_empty'
25
- | 'is_not_empty'
26
-
27
- export interface QueryFilterCondition {
28
- id?: string
29
- op: QueryFilterOperator
30
- value: string | number | boolean | null
31
- }
32
-
33
- /** Formato inviato dalla dashboard (usa columnId). */
34
- export interface QueryFilterGroup {
35
- columnId: string
36
- label?: string
37
- type: QueryFilterType
38
- conditions: QueryFilterCondition[]
39
- }
40
-
41
- const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
42
- 'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
43
- ])
44
-
45
- function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
46
- return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
47
- }
48
-
49
- export function cleanStr(val: unknown): string | null {
50
- return (typeof val === 'string' && val.trim()) || null
51
- }
52
-
53
- export function safeParseJson(data: unknown): Record<string, unknown> {
54
- const cleaned = cleanStr(data)
55
- if (!cleaned) return {}
56
- try {
57
- const parsed = JSON.parse(cleaned)
58
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
59
- ? (parsed as Record<string, unknown>)
60
- : {}
61
- } catch {
62
- return {}
63
- }
64
- }
65
-
66
- export function parsePositiveInt(value: string | undefined, fallback: number): number {
67
- if (!value) return fallback
68
- const parsed = Number.parseInt(value, 10)
69
- if (Number.isNaN(parsed) || parsed < 1) return fallback
70
- return parsed
71
- }
72
-
73
- function parseCondition(cond: unknown): QueryFilterCondition | null {
74
- if (!cond || typeof cond !== 'object') return null
75
- const candidate = cond as Record<string, unknown>
76
- if (!isQueryFilterOperator(candidate.op)) return null
77
- const rawValue = candidate.value
78
- let parsedValue: QueryFilterCondition['value'] = null
79
- if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
80
- parsedValue = rawValue
81
- }
82
- return {
83
- id: typeof candidate.id === 'string' ? candidate.id : undefined,
84
- op: candidate.op,
85
- value: parsedValue,
86
- }
87
- }
88
-
89
- function parseFilterGroup(group: unknown): QueryFilterGroup | null {
90
- if (!group || typeof group !== 'object') return null
91
- const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
92
- if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
93
- return null
94
- }
95
- const validConditions: QueryFilterCondition[] = []
96
- for (const cond of rawConds) {
97
- const parsedCond = parseCondition(cond)
98
- if (parsedCond) validConditions.push(parsedCond)
99
- }
100
- if (validConditions.length === 0) return null
101
- return {
102
- columnId,
103
- label: typeof label === 'string' ? label : undefined,
104
- type,
105
- conditions: validConditions,
106
- }
107
- }
108
-
109
- /** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
110
- export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
111
- if (!raw) return []
112
- let parsed: unknown
113
- try {
114
- parsed = JSON.parse(raw)
115
- } catch {
116
- return []
117
- }
118
- if (!parsed || typeof parsed !== 'object') return []
119
- const result: QueryFilterGroup[] = []
120
- for (const group of Object.values(parsed as Record<string, unknown>)) {
121
- const parsedGroup = parseFilterGroup(group)
122
- if (parsedGroup) result.push(parsedGroup)
123
- }
124
- return result
125
- }
126
-
127
- /**
128
- * Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
129
- * compatibile con buildSelectQuery del Botanical Engine.
130
- */
131
- export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
132
- return groups.map((g) => ({
133
- column: g.columnId,
134
- type: g.type,
135
- conditions: g.conditions,
136
- }))
137
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import type { FilterGroup, FilterType } from '@beechcms/core'
6
+
7
+ /** Entry parsata per le risposte API — contratto immutabile (C7). */
8
+ export interface ContentEntry {
9
+ id: string
10
+ schema_slug: string
11
+ slug: string | null
12
+ status: string
13
+ data: Record<string, unknown>
14
+ hasPendingDraft: boolean
15
+ created_at: number | null
16
+ updated_at: number | null
17
+ }
18
+
19
+ export type QueryFilterType = FilterType
20
+
21
+ export type QueryFilterOperator =
22
+ | 'eq'
23
+ | 'gt'
24
+ | 'gte'
25
+ | 'lt'
26
+ | 'lte'
27
+ | 'contains'
28
+ | 'is_empty'
29
+ | 'is_not_empty'
30
+
31
+ export interface QueryFilterCondition {
32
+ id?: string
33
+ op: QueryFilterOperator
34
+ value: string | number | boolean | null
35
+ }
36
+
37
+ /** Formato inviato dalla dashboard (usa columnId). */
38
+ export interface QueryFilterGroup {
39
+ columnId: string
40
+ label?: string
41
+ type: QueryFilterType
42
+ conditions: QueryFilterCondition[]
43
+ }
44
+
45
+ const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
46
+ 'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
47
+ ])
48
+
49
+ function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
50
+ return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
51
+ }
52
+
53
+ export function cleanStr(val: unknown): string | null {
54
+ return (typeof val === 'string' && val.trim()) || null
55
+ }
56
+
57
+ export function safeParseJson(data: unknown): Record<string, unknown> {
58
+ const cleaned = cleanStr(data)
59
+ if (!cleaned) return {}
60
+ try {
61
+ const parsed = JSON.parse(cleaned)
62
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
63
+ ? (parsed as Record<string, unknown>)
64
+ : {}
65
+ } catch {
66
+ return {}
67
+ }
68
+ }
69
+
70
+ export function parsePositiveInt(value: string | undefined, fallback: number): number {
71
+ if (!value) return fallback
72
+ const parsed = Number.parseInt(value, 10)
73
+ if (Number.isNaN(parsed) || parsed < 1) return fallback
74
+ return parsed
75
+ }
76
+
77
+ function parseCondition(cond: unknown): QueryFilterCondition | null {
78
+ if (!cond || typeof cond !== 'object') return null
79
+ const candidate = cond as Record<string, unknown>
80
+ if (!isQueryFilterOperator(candidate.op)) return null
81
+ const rawValue = candidate.value
82
+ let parsedValue: QueryFilterCondition['value'] = null
83
+ if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
84
+ parsedValue = rawValue
85
+ }
86
+ return {
87
+ id: typeof candidate.id === 'string' ? candidate.id : undefined,
88
+ op: candidate.op,
89
+ value: parsedValue,
90
+ }
91
+ }
92
+
93
+ function parseFilterGroup(group: unknown): QueryFilterGroup | null {
94
+ if (!group || typeof group !== 'object') return null
95
+ const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
96
+ if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
97
+ return null
98
+ }
99
+ const validConditions: QueryFilterCondition[] = []
100
+ for (const cond of rawConds) {
101
+ const parsedCond = parseCondition(cond)
102
+ if (parsedCond) validConditions.push(parsedCond)
103
+ }
104
+ if (validConditions.length === 0) return null
105
+ return {
106
+ columnId,
107
+ label: typeof label === 'string' ? label : undefined,
108
+ type,
109
+ conditions: validConditions,
110
+ }
111
+ }
112
+
113
+ /** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
114
+ export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
115
+ if (!raw) return []
116
+ let parsed: unknown
117
+ try {
118
+ parsed = JSON.parse(raw)
119
+ } catch {
120
+ return []
121
+ }
122
+ if (!parsed || typeof parsed !== 'object') return []
123
+ const result: QueryFilterGroup[] = []
124
+ for (const group of Object.values(parsed as Record<string, unknown>)) {
125
+ const parsedGroup = parseFilterGroup(group)
126
+ if (parsedGroup) result.push(parsedGroup)
127
+ }
128
+ return result
129
+ }
130
+
131
+ /**
132
+ * Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
133
+ * compatibile con buildSelectQuery del Botanical Engine.
134
+ */
135
+ export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
136
+ return groups.map((g) => ({
137
+ column: g.columnId,
138
+ type: g.type,
139
+ conditions: g.conditions,
140
+ }))
141
+ }
@@ -1,22 +1,26 @@
1
- import type { HonoRequest } from "hono"
2
-
3
- /** The header Cloudflare sets on every incoming request to the Worker. */
4
- const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
5
-
6
- /** The fallback value used when the IP header is absent (local dev, unit tests). */
7
- const UNKNOWN_IP = "unknown"
8
-
9
- /**
10
- * Extracts the real client IP address from a Cloudflare Worker request.
11
- *
12
- * Cloudflare injects the cf-connecting-ip header on every request that
13
- * passes through the edge. In local development (wrangler dev) or in
14
- * unit tests the header may be absent, in which case the string "unknown"
15
- * is returned so that rate-limiter keys remain non-empty and safe to use.
16
- *
17
- * Never derive security decisions from this value alone; treat it as a
18
- * best-effort hint for rate-limiting and logging purposes only.
19
- */
20
- export function getClientIp(request: HonoRequest): string {
21
- return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
22
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import type { HonoRequest } from "hono"
6
+
7
+ /** The header Cloudflare sets on every incoming request to the Worker. */
8
+ const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
9
+
10
+ /** The fallback value used when the IP header is absent (local dev, unit tests). */
11
+ const UNKNOWN_IP = "unknown"
12
+
13
+ /**
14
+ * Extracts the real client IP address from a Cloudflare Worker request.
15
+ *
16
+ * Cloudflare injects the cf-connecting-ip header on every request that
17
+ * passes through the edge. In local development (wrangler dev) or in
18
+ * unit tests the header may be absent, in which case the string "unknown"
19
+ * is returned so that rate-limiter keys remain non-empty and safe to use.
20
+ *
21
+ * Never derive security decisions from this value alone; treat it as a
22
+ * best-effort hint for rate-limiting and logging purposes only.
23
+ */
24
+ export function getClientIp(request: HonoRequest): string {
25
+ return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
26
+ }