@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,136 +1,140 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { D1ActivityLogRepository } from './d1-activity-log.repository'
3
-
4
- function makeMockDb(allResults: unknown[], firstResult: unknown = null) {
5
- const allMock = vi.fn().mockResolvedValue({ results: allResults })
6
- const firstMock = vi.fn().mockResolvedValue(firstResult)
7
- const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
8
- const prepareMock = vi.fn(() => ({ bind: bindMock }))
9
- return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
10
- }
11
-
12
- describe('D1ActivityLogRepository', () => {
13
- it('maps snake_case rows to camelCase records and parses the details JSON', async () => {
14
- const row = {
15
- id: 'log-1',
16
- user_id: 'u1',
17
- user_email: 'a@b.com',
18
- user_name: 'Admin',
19
- action: 'create',
20
- entity_type: 'content',
21
- entity_id: 'e1',
22
- entity_slug: 'posts',
23
- details: '{"title":"Hello"}',
24
- created_at: 1234,
25
- }
26
- const { db } = makeMockDb([row])
27
- const result = await new D1ActivityLogRepository(db).list({ limit: 10 })
28
- expect(result).toEqual([
29
- {
30
- id: 'log-1',
31
- userId: 'u1',
32
- userEmail: 'a@b.com',
33
- userName: 'Admin',
34
- action: 'create',
35
- entityType: 'content',
36
- entityId: 'e1',
37
- entitySlug: 'posts',
38
- details: { title: 'Hello' },
39
- createdAt: 1234,
40
- },
41
- ])
42
- })
43
-
44
- it('returns null details when the column is null', async () => {
45
- const { db } = makeMockDb([
46
- {
47
- id: 'l',
48
- user_id: 'u',
49
- user_email: 'a',
50
- user_name: null,
51
- action: 'delete',
52
- entity_type: 'content',
53
- entity_id: 'e',
54
- entity_slug: null,
55
- details: null,
56
- created_at: 1,
57
- },
58
- ])
59
- const [record] = await new D1ActivityLogRepository(db).list({ limit: 1 })
60
- expect(record.details).toBeNull()
61
- expect(record.userName).toBeNull()
62
- expect(record.entitySlug).toBeNull()
63
- })
64
-
65
- it('omits the WHERE clause when no filters are provided', async () => {
66
- const { db, prepareMock, bindMock } = makeMockDb([])
67
- await new D1ActivityLogRepository(db).list({ limit: 5 })
68
- expect(prepareMock.mock.calls[0][0]).not.toMatch(/WHERE/)
69
- expect(bindMock).toHaveBeenCalledWith(5)
70
- })
71
-
72
- it('builds a userId WHERE clause when the option is set', async () => {
73
- const { db, prepareMock, bindMock } = makeMockDb([])
74
- await new D1ActivityLogRepository(db).list({ userId: 'u-9', limit: 3 })
75
- expect(prepareMock.mock.calls[0][0]).toMatch(/WHERE user_id = \?/)
76
- expect(bindMock).toHaveBeenCalledWith('u-9', 3)
77
- })
78
-
79
- it('builds a combined userId + entitySlug WHERE clause with AND', async () => {
80
- const { db, prepareMock, bindMock } = makeMockDb([])
81
- await new D1ActivityLogRepository(db).list({ userId: 'u', entitySlug: 'posts', limit: 7 })
82
- expect(prepareMock.mock.calls[0][0]).toMatch(/WHERE user_id = \? AND entity_slug = \?/)
83
- expect(bindMock).toHaveBeenCalledWith('u', 'posts', 7)
84
- })
85
-
86
- it('always orders by created_at DESC and applies LIMIT', async () => {
87
- const { db, prepareMock } = makeMockDb([])
88
- await new D1ActivityLogRepository(db).list({ limit: 1 })
89
- expect(prepareMock.mock.calls[0][0]).toMatch(/ORDER BY created_at DESC/)
90
- expect(prepareMock.mock.calls[0][0]).toMatch(/LIMIT \?/)
91
- })
92
-
93
- describe('countSince', () => {
94
- it('binds action, entityType and sinceTimestamp and returns the count', async () => {
95
- const { db, prepareMock, bindMock } = makeMockDb([], { count: 42 })
96
- const result = await new D1ActivityLogRepository(db).countSince({
97
- action: 'create',
98
- entityType: 'content',
99
- sinceTimestamp: 1000,
100
- })
101
- expect(result).toBe(42)
102
- expect(prepareMock.mock.calls[0][0]).toMatch(/COUNT\(\*\)/)
103
- expect(prepareMock.mock.calls[0][0]).toMatch(/WHERE action = \?\s+AND entity_type = \?\s+AND created_at >= \?/)
104
- expect(bindMock).toHaveBeenCalledWith('create', 'content', 1000)
105
- })
106
-
107
- it('returns 0 when no rows match', async () => {
108
- const { db } = makeMockDb([], null)
109
- const result = await new D1ActivityLogRepository(db).countSince({
110
- action: 'delete',
111
- entityType: 'media',
112
- sinceTimestamp: 0,
113
- })
114
- expect(result).toBe(0)
115
- })
116
- })
117
-
118
- it('returns null details when the JSON payload cannot be parsed', async () => {
119
- const { db } = makeMockDb([
120
- {
121
- id: 'l',
122
- user_id: 'u',
123
- user_email: 'a',
124
- user_name: null,
125
- action: 'create',
126
- entity_type: 'content',
127
- entity_id: 'e',
128
- entity_slug: null,
129
- details: 'not-json',
130
- created_at: 1,
131
- },
132
- ])
133
- const [record] = await new D1ActivityLogRepository(db).list({ limit: 1 })
134
- expect(record.details).toBeNull()
135
- })
136
- })
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 } from 'vitest'
6
+ import { D1ActivityLogRepository } from './d1-activity-log.repository'
7
+
8
+ function makeMockDb(allResults: unknown[], firstResult: unknown = null) {
9
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
10
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
11
+ const bindMock = vi.fn(() => ({ all: allMock, first: firstMock }))
12
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
13
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock }
14
+ }
15
+
16
+ describe('D1ActivityLogRepository', () => {
17
+ it('maps snake_case rows to camelCase records and parses the details JSON', async () => {
18
+ const row = {
19
+ id: 'log-1',
20
+ user_id: 'u1',
21
+ user_email: 'a@b.com',
22
+ user_name: 'Admin',
23
+ action: 'create',
24
+ entity_type: 'content',
25
+ entity_id: 'e1',
26
+ entity_slug: 'posts',
27
+ details: '{"title":"Hello"}',
28
+ created_at: 1234,
29
+ }
30
+ const { db } = makeMockDb([row])
31
+ const result = await new D1ActivityLogRepository(db).list({ limit: 10 })
32
+ expect(result).toEqual([
33
+ {
34
+ id: 'log-1',
35
+ userId: 'u1',
36
+ userEmail: 'a@b.com',
37
+ userName: 'Admin',
38
+ action: 'create',
39
+ entityType: 'content',
40
+ entityId: 'e1',
41
+ entitySlug: 'posts',
42
+ details: { title: 'Hello' },
43
+ createdAt: 1234,
44
+ },
45
+ ])
46
+ })
47
+
48
+ it('returns null details when the column is null', async () => {
49
+ const { db } = makeMockDb([
50
+ {
51
+ id: 'l',
52
+ user_id: 'u',
53
+ user_email: 'a',
54
+ user_name: null,
55
+ action: 'delete',
56
+ entity_type: 'content',
57
+ entity_id: 'e',
58
+ entity_slug: null,
59
+ details: null,
60
+ created_at: 1,
61
+ },
62
+ ])
63
+ const [record] = await new D1ActivityLogRepository(db).list({ limit: 1 })
64
+ expect(record.details).toBeNull()
65
+ expect(record.userName).toBeNull()
66
+ expect(record.entitySlug).toBeNull()
67
+ })
68
+
69
+ it('omits the WHERE clause when no filters are provided', async () => {
70
+ const { db, prepareMock, bindMock } = makeMockDb([])
71
+ await new D1ActivityLogRepository(db).list({ limit: 5 })
72
+ expect(prepareMock.mock.calls[0][0]).not.toMatch(/WHERE/)
73
+ expect(bindMock).toHaveBeenCalledWith(5)
74
+ })
75
+
76
+ it('builds a userId WHERE clause when the option is set', async () => {
77
+ const { db, prepareMock, bindMock } = makeMockDb([])
78
+ await new D1ActivityLogRepository(db).list({ userId: 'u-9', limit: 3 })
79
+ expect(prepareMock.mock.calls[0][0]).toMatch(/WHERE user_id = \?/)
80
+ expect(bindMock).toHaveBeenCalledWith('u-9', 3)
81
+ })
82
+
83
+ it('builds a combined userId + entitySlug WHERE clause with AND', async () => {
84
+ const { db, prepareMock, bindMock } = makeMockDb([])
85
+ await new D1ActivityLogRepository(db).list({ userId: 'u', entitySlug: 'posts', limit: 7 })
86
+ expect(prepareMock.mock.calls[0][0]).toMatch(/WHERE user_id = \? AND entity_slug = \?/)
87
+ expect(bindMock).toHaveBeenCalledWith('u', 'posts', 7)
88
+ })
89
+
90
+ it('always orders by created_at DESC and applies LIMIT', async () => {
91
+ const { db, prepareMock } = makeMockDb([])
92
+ await new D1ActivityLogRepository(db).list({ limit: 1 })
93
+ expect(prepareMock.mock.calls[0][0]).toMatch(/ORDER BY created_at DESC/)
94
+ expect(prepareMock.mock.calls[0][0]).toMatch(/LIMIT \?/)
95
+ })
96
+
97
+ describe('countSince', () => {
98
+ it('binds action, entityType and sinceTimestamp and returns the count', async () => {
99
+ const { db, prepareMock, bindMock } = makeMockDb([], { count: 42 })
100
+ const result = await new D1ActivityLogRepository(db).countSince({
101
+ action: 'create',
102
+ entityType: 'content',
103
+ sinceTimestamp: 1000,
104
+ })
105
+ expect(result).toBe(42)
106
+ expect(prepareMock.mock.calls[0][0]).toMatch(/COUNT\(\*\)/)
107
+ expect(prepareMock.mock.calls[0][0]).toMatch(/WHERE action = \?\s+AND entity_type = \?\s+AND created_at >= \?/)
108
+ expect(bindMock).toHaveBeenCalledWith('create', 'content', 1000)
109
+ })
110
+
111
+ it('returns 0 when no rows match', async () => {
112
+ const { db } = makeMockDb([], null)
113
+ const result = await new D1ActivityLogRepository(db).countSince({
114
+ action: 'delete',
115
+ entityType: 'media',
116
+ sinceTimestamp: 0,
117
+ })
118
+ expect(result).toBe(0)
119
+ })
120
+ })
121
+
122
+ it('returns null details when the JSON payload cannot be parsed', async () => {
123
+ const { db } = makeMockDb([
124
+ {
125
+ id: 'l',
126
+ user_id: 'u',
127
+ user_email: 'a',
128
+ user_name: null,
129
+ action: 'create',
130
+ entity_type: 'content',
131
+ entity_id: 'e',
132
+ entity_slug: null,
133
+ details: 'not-json',
134
+ created_at: 1,
135
+ },
136
+ ])
137
+ const [record] = await new D1ActivityLogRepository(db).list({ limit: 1 })
138
+ expect(record.details).toBeNull()
139
+ })
140
+ })
@@ -1,101 +1,105 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type {
3
- IActivityLogRepository,
4
- ActivityLogRecord,
5
- ActivityLogListOptions,
6
- ActivityAction,
7
- EntityType,
8
- CountSinceOptions,
9
- } from '@beechcms/core'
10
-
11
- interface ActivityLogRow {
12
- id: string
13
- user_id: string
14
- user_email: string
15
- user_name: string | null
16
- action: string
17
- entity_type: string
18
- entity_id: string
19
- entity_slug: string | null
20
- details: string | null
21
- created_at: number
22
- }
23
-
24
- /**
25
- * D1-backed implementation of {@link IActivityLogRepository}.
26
- *
27
- * Builds parameterised queries with optional WHERE clauses without nesting
28
- * conditionals — guard clauses keep the body flat and the SQL deterministic.
29
- */
30
- export class D1ActivityLogRepository implements IActivityLogRepository {
31
- constructor(private readonly database: D1Database) {}
32
-
33
- async list(options: ActivityLogListOptions): Promise<ActivityLogRecord[]> {
34
- const whereClauses: string[] = []
35
- const bindings: unknown[] = []
36
-
37
- if (options.userId) {
38
- whereClauses.push('user_id = ?')
39
- bindings.push(options.userId)
40
- }
41
-
42
- if (options.entitySlug) {
43
- whereClauses.push('entity_slug = ?')
44
- bindings.push(options.entitySlug)
45
- }
46
-
47
- const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
48
-
49
- const sql =
50
- `SELECT id, user_id, user_email, user_name, action, entity_type,
51
- entity_id, entity_slug, details, created_at
52
- FROM activity_logs
53
- ${whereSql}
54
- ORDER BY created_at DESC
55
- LIMIT ?`
56
-
57
- bindings.push(options.limit)
58
-
59
- const queryResult = await this.database.prepare(sql).bind(...bindings).all<ActivityLogRow>()
60
- return (queryResult.results ?? []).map(mapRowToRecord)
61
- }
62
-
63
- async countSince(options: CountSinceOptions): Promise<number> {
64
- const row = await this.database
65
- .prepare(
66
- `SELECT COUNT(*) as count
67
- FROM activity_logs
68
- WHERE action = ?
69
- AND entity_type = ?
70
- AND created_at >= ?`
71
- )
72
- .bind(options.action, options.entityType, options.sinceTimestamp)
73
- .first<{ count: number }>()
74
- return row?.count ?? 0
75
- }
76
- }
77
-
78
- function mapRowToRecord(row: ActivityLogRow): ActivityLogRecord {
79
- return {
80
- id: row.id,
81
- userId: row.user_id,
82
- userEmail: row.user_email,
83
- userName: row.user_name,
84
- action: row.action as ActivityAction,
85
- entityType: row.entity_type as EntityType,
86
- entityId: row.entity_id,
87
- entitySlug: row.entity_slug,
88
- details: parseDetails(row.details),
89
- createdAt: row.created_at,
90
- }
91
- }
92
-
93
- function parseDetails(raw: string | null): Record<string, unknown> | null {
94
- if (raw === null) return null
95
- try {
96
- const parsed = JSON.parse(raw)
97
- return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
98
- } catch {
99
- return null
100
- }
101
- }
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
+ /// <reference types="@cloudflare/workers-types" />
6
+ import type {
7
+ IActivityLogRepository,
8
+ ActivityLogRecord,
9
+ ActivityLogListOptions,
10
+ ActivityAction,
11
+ EntityType,
12
+ CountSinceOptions,
13
+ } from '@beechcms/core'
14
+
15
+ interface ActivityLogRow {
16
+ id: string
17
+ user_id: string
18
+ user_email: string
19
+ user_name: string | null
20
+ action: string
21
+ entity_type: string
22
+ entity_id: string
23
+ entity_slug: string | null
24
+ details: string | null
25
+ created_at: number
26
+ }
27
+
28
+ /**
29
+ * D1-backed implementation of {@link IActivityLogRepository}.
30
+ *
31
+ * Builds parameterised queries with optional WHERE clauses without nesting
32
+ * conditionals — guard clauses keep the body flat and the SQL deterministic.
33
+ */
34
+ export class D1ActivityLogRepository implements IActivityLogRepository {
35
+ constructor(private readonly database: D1Database) {}
36
+
37
+ async list(options: ActivityLogListOptions): Promise<ActivityLogRecord[]> {
38
+ const whereClauses: string[] = []
39
+ const bindings: unknown[] = []
40
+
41
+ if (options.userId) {
42
+ whereClauses.push('user_id = ?')
43
+ bindings.push(options.userId)
44
+ }
45
+
46
+ if (options.entitySlug) {
47
+ whereClauses.push('entity_slug = ?')
48
+ bindings.push(options.entitySlug)
49
+ }
50
+
51
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
52
+
53
+ const sql =
54
+ `SELECT id, user_id, user_email, user_name, action, entity_type,
55
+ entity_id, entity_slug, details, created_at
56
+ FROM activity_logs
57
+ ${whereSql}
58
+ ORDER BY created_at DESC
59
+ LIMIT ?`
60
+
61
+ bindings.push(options.limit)
62
+
63
+ const queryResult = await this.database.prepare(sql).bind(...bindings).all<ActivityLogRow>()
64
+ return (queryResult.results ?? []).map(mapRowToRecord)
65
+ }
66
+
67
+ async countSince(options: CountSinceOptions): Promise<number> {
68
+ const row = await this.database
69
+ .prepare(
70
+ `SELECT COUNT(*) as count
71
+ FROM activity_logs
72
+ WHERE action = ?
73
+ AND entity_type = ?
74
+ AND created_at >= ?`
75
+ )
76
+ .bind(options.action, options.entityType, options.sinceTimestamp)
77
+ .first<{ count: number }>()
78
+ return row?.count ?? 0
79
+ }
80
+ }
81
+
82
+ function mapRowToRecord(row: ActivityLogRow): ActivityLogRecord {
83
+ return {
84
+ id: row.id,
85
+ userId: row.user_id,
86
+ userEmail: row.user_email,
87
+ userName: row.user_name,
88
+ action: row.action as ActivityAction,
89
+ entityType: row.entity_type as EntityType,
90
+ entityId: row.entity_id,
91
+ entitySlug: row.entity_slug,
92
+ details: parseDetails(row.details),
93
+ createdAt: row.created_at,
94
+ }
95
+ }
96
+
97
+ function parseDetails(raw: string | null): Record<string, unknown> | null {
98
+ if (raw === null) return null
99
+ try {
100
+ const parsed = JSON.parse(raw)
101
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
102
+ } catch {
103
+ return null
104
+ }
105
+ }