@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.
Files changed (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. package/src/shared/notification-service.ts +0 -56
@@ -1,48 +1,50 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
2
+ import { Context, Hono } from 'hono'
3
3
  import {
4
4
  validateAndSanitizeSeedPayload,
5
- resolvePolicies,
6
- EntryNotFoundError
5
+ resolvePolicies
7
6
  } from '@beechcms/core'
8
7
  import { publicProblem } from '../../public/problem-details'
9
- import { logActivity } from '../../shared/activity-logger'
10
8
  import { cleanStr } from '../../shared/query-utils'
11
9
  import { applyVisibility } from '../../shared/apply-policies'
12
10
  import { AppEnv } from '../../types'
13
11
  import { CONTENT_ERRORS } from '../content/constants'
12
+ import { draftGuard } from './draft.middleware'
14
13
 
15
14
  const draftApp = new Hono<AppEnv>()
16
15
 
16
+
17
17
  function normalizeBody(raw: unknown): Record<string, unknown> {
18
18
  return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
19
19
  }
20
20
 
21
- function draftNotAllowed(context: any) {
22
- return publicProblem(context, {
23
- type: 'draft-not-allowed',
24
- title: 'Method Not Allowed',
25
- status: 405,
26
- detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
21
+ function logDraftActivity(
22
+ context: Context<AppEnv>,
23
+ id: string,
24
+ slug: string,
25
+ title: string,
26
+ note: 'draft saved' | 'draft published'
27
+ ) {
28
+ const actor = context.get('jwtPayload')
29
+ context.get('activityLogger').log({
30
+ action: 'update',
31
+ entityType: 'content',
32
+ entityId: id,
33
+ entitySlug: slug,
34
+ details: { title, note },
35
+ actor: {
36
+ id: actor.sub,
37
+ email: actor.email ?? 'unknown',
38
+ name: actor.name ?? null,
39
+ },
27
40
  })
28
41
  }
29
42
 
30
- // PUT /:slug/:id/draft — crea o sovrascrive la bozza in content_{slug}_drafts
31
- draftApp.put('/:slug/:id/draft', async (context) => {
43
+ // PUT /:slug/:id/draft — Creates or overwrites the pending draft
44
+ draftApp.put('/:slug/:id/draft', draftGuard, async (context) => {
32
45
  const slug = context.req.param('slug')
33
46
  const id = context.req.param('id')
34
-
35
- const seed = context.get('getSeed')(slug)
36
- if (!seed) {
37
- return publicProblem(context, {
38
- type: 'content-seed-not-found',
39
- title: 'Not Found',
40
- status: 404,
41
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
42
- })
43
- }
44
-
45
- if (!seed.allowDrafts) return draftNotAllowed(context)
47
+ const seed = context.get('getSeed')(slug)!
46
48
 
47
49
  let body: Record<string, unknown>
48
50
  try {
@@ -56,22 +58,6 @@ draftApp.put('/:slug/:id/draft', async (context) => {
56
58
  })
57
59
  }
58
60
 
59
- const repository = context.get('repository')
60
- try {
61
- // Verify entry existence
62
- await repository.findById(seed, id)
63
- } catch (error) {
64
- if (error instanceof EntryNotFoundError) {
65
- return publicProblem(context, {
66
- type: 'content-not-found',
67
- title: 'Not Found',
68
- status: 404,
69
- detail: CONTENT_ERRORS.NOT_FOUND
70
- })
71
- }
72
- throw error
73
- }
74
-
75
61
  const sensitiveAliases = Object.keys(body).filter((alias) => {
76
62
  const branch = seed.branches.find((b) => b.alias === alias)
77
63
  return branch != null && resolvePolicies(branch).privacy !== 'plain'
@@ -112,161 +98,73 @@ draftApp.put('/:slug/:id/draft', async (context) => {
112
98
  })
113
99
  }
114
100
 
101
+ const repository = context.get('repository')
115
102
  await repository.saveDraft(seed, id, validation.data)
116
103
 
117
- logActivity(context, {
118
- action: 'update',
119
- entityType: 'content',
120
- entityId: id,
121
- entitySlug: slug,
122
- details: {
123
- title: cleanStr(validation.data[seed.displayNameAlias]) ?? id,
124
- note: 'draft saved'
125
- },
126
- })
104
+ const displayTitle = cleanStr(validation.data[seed.displayNameAlias]) ?? id
105
+ logDraftActivity(context, id, slug, displayTitle, 'draft saved')
127
106
 
128
107
  return context.json({ success: true })
129
108
  })
130
109
 
131
- // GET /:slug/:id/draft — legge la bozza pendente
132
- draftApp.get('/:slug/:id/draft', async (context) => {
110
+ // GET /:slug/:id/draft — Retrieves the pending draft
111
+ draftApp.get('/:slug/:id/draft', draftGuard, async (context) => {
133
112
  const slug = context.req.param('slug')
134
113
  const id = context.req.param('id')
135
-
136
- const seed = context.get('getSeed')(slug)
137
- if (!seed) {
138
- return publicProblem(context, {
139
- type: 'content-seed-not-found',
140
- title: 'Not Found',
141
- status: 404,
142
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
143
- })
144
- }
145
-
146
- if (!seed.allowDrafts) return draftNotAllowed(context)
114
+ const seed = context.get('getSeed')(slug)!
147
115
 
148
116
  const repository = context.get('repository')
149
117
  const draft = await repository.getDraft(seed, id)
150
118
 
151
119
  if (!draft) {
152
- try {
153
- await repository.findById(seed, id)
154
- return publicProblem(context, {
155
- type: 'draft-not-found',
156
- title: 'Not Found',
157
- status: 404,
158
- detail: 'No pending draft for this entry'
159
- })
160
- } catch (error) {
161
- if (error instanceof EntryNotFoundError) {
162
- return publicProblem(context, {
163
- type: 'content-not-found',
164
- title: 'Not Found',
165
- status: 404,
166
- detail: CONTENT_ERRORS.NOT_FOUND
167
- })
168
- }
169
- throw error
170
- }
120
+ return publicProblem(context, {
121
+ type: 'draft-not-found',
122
+ title: 'Not Found',
123
+ status: 404,
124
+ detail: 'No pending draft for this entry'
125
+ })
171
126
  }
172
127
 
173
128
  return context.json({ data: applyVisibility(draft, seed) })
174
129
  })
175
130
 
176
- // POST /:slug/:id/draft/publish — promuove bozza live atomicamente
177
- draftApp.post('/:slug/:id/draft/publish', async (context) => {
131
+ // POST /:slug/:id/draft/publish — Atomically promotes draft to live
132
+ draftApp.post('/:slug/:id/draft/publish', draftGuard, async (context) => {
178
133
  const slug = context.req.param('slug')
179
134
  const id = context.req.param('id')
180
-
181
- const seed = context.get('getSeed')(slug)
182
- if (!seed) {
183
- return publicProblem(context, {
184
- type: 'content-seed-not-found',
185
- title: 'Not Found',
186
- status: 404,
187
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
188
- })
189
- }
190
-
191
- if (!seed.allowDrafts) return draftNotAllowed(context)
135
+ const seed = context.get('getSeed')(slug)!
192
136
 
193
137
  const repository = context.get('repository')
194
138
  const draft = await repository.getDraft(seed, id)
195
139
 
196
140
  if (!draft) {
197
- try {
198
- await repository.findById(seed, id)
199
- return publicProblem(context, {
200
- type: 'draft-not-found',
201
- title: 'Not Found',
202
- status: 404,
203
- detail: 'No pending draft to publish'
204
- })
205
- } catch (error) {
206
- if (error instanceof EntryNotFoundError) {
207
- return publicProblem(context, {
208
- type: 'content-not-found',
209
- title: 'Not Found',
210
- status: 404,
211
- detail: CONTENT_ERRORS.NOT_FOUND
212
- })
213
- }
214
- throw error
215
- }
141
+ return publicProblem(context, {
142
+ type: 'draft-not-found',
143
+ title: 'Not Found',
144
+ status: 404,
145
+ detail: 'No pending draft to publish'
146
+ })
216
147
  }
217
148
 
218
149
  await repository.publishDraft(seed, id)
219
150
 
220
151
  const displayValue = draft[seed.displayNameAlias]
221
152
  const displayStr = typeof displayValue === 'string' ? displayValue : id
222
-
223
- logActivity(context, {
224
- action: 'update',
225
- entityType: 'content',
226
- entityId: id,
227
- entitySlug: slug,
228
- details: { title: displayStr, note: 'draft published' },
229
- })
153
+ logDraftActivity(context, id, slug, displayStr, 'draft published')
230
154
 
231
155
  return context.json({ success: true })
232
156
  })
233
157
 
234
- // DELETE /:slug/:id/draft — scarta la bozza pendente
235
- draftApp.delete('/:slug/:id/draft', async (context) => {
158
+ // DELETE /:slug/:id/draft — Discards the pending draft
159
+ draftApp.delete('/:slug/:id/draft', draftGuard, async (context) => {
236
160
  const slug = context.req.param('slug')
237
161
  const id = context.req.param('id')
238
-
239
- const seed = context.get('getSeed')(slug)
240
- if (!seed) {
241
- return publicProblem(context, {
242
- type: 'content-seed-not-found',
243
- title: 'Not Found',
244
- status: 404,
245
- detail: CONTENT_ERRORS.SEED_NOT_FOUND
246
- })
247
- }
248
-
249
- if (!seed.allowDrafts) return draftNotAllowed(context)
162
+ const seed = context.get('getSeed')(slug)!
250
163
 
251
164
  const repository = context.get('repository')
252
- try {
253
- await repository.findById(seed, id)
254
- } catch (error) {
255
- if (error instanceof EntryNotFoundError) {
256
- return publicProblem(context, {
257
- type: 'content-not-found',
258
- title: 'Not Found',
259
- status: 404,
260
- detail: CONTENT_ERRORS.NOT_FOUND
261
- })
262
- }
263
- throw error
264
- }
265
-
266
165
  await repository.deleteDraft(seed, id)
267
166
 
268
167
  return context.json({ success: true })
269
168
  })
270
169
 
271
170
  export { draftApp }
272
-
@@ -0,0 +1,62 @@
1
+ import { createMiddleware } from 'hono/factory'
2
+ import { EntryNotFoundError } from '@beechcms/core'
3
+ import { publicProblem } from '../../public/problem-details'
4
+ import { CONTENT_ERRORS } from '../content/constants'
5
+ import type { AppEnv } from '../../types'
6
+
7
+ /**
8
+ * Middleware that guards draft-related endpoints.
9
+ * Responsibilities:
10
+ * 1. Validates that the targeted Seed schema exists.
11
+ * 2. Ensures the Seed configuration explicitly allows drafts.
12
+ * 3. Confirms that the main live content entry exists in the repository.
13
+ */
14
+ export const draftGuard = createMiddleware<AppEnv>(async (context, next) => {
15
+ const slug = context.req.param('slug')
16
+ const id = context.req.param('id')
17
+
18
+ if (!slug || !id) {
19
+ return publicProblem(context, {
20
+ type: 'content-invalid-request',
21
+ title: 'Bad Request',
22
+ status: 400,
23
+ detail: 'Missing required route parameters',
24
+ })
25
+ }
26
+
27
+ const seed = context.get('getSeed')(slug)
28
+ if (!seed) {
29
+ return publicProblem(context, {
30
+ type: 'content-seed-not-found',
31
+ title: 'Not Found',
32
+ status: 404,
33
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND,
34
+ })
35
+ }
36
+
37
+ if (!seed.allowDrafts) {
38
+ return publicProblem(context, {
39
+ type: 'draft-not-allowed',
40
+ title: 'Method Not Allowed',
41
+ status: 405,
42
+ detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
43
+ })
44
+ }
45
+
46
+ const repository = context.get('repository')
47
+ try {
48
+ await repository.findById(seed, id)
49
+ } catch (error) {
50
+ if (error instanceof EntryNotFoundError) {
51
+ return publicProblem(context, {
52
+ type: 'content-not-found',
53
+ title: 'Not Found',
54
+ status: 404,
55
+ detail: CONTENT_ERRORS.NOT_FOUND,
56
+ })
57
+ }
58
+ throw error
59
+ }
60
+
61
+ await next()
62
+ })
@@ -16,10 +16,12 @@
16
16
  import { ResendEmailProvider } from './providers/resend'
17
17
  import { buildPasswordResetEmail } from './templates/password-reset'
18
18
  import { buildPasswordChangedEmail } from './templates/password-changed'
19
+ import { buildAutomationEmail } from './templates/automation-mail'
19
20
  import type { EmailProvider } from './email.provider'
20
21
  import type {
21
22
  PasswordResetEmailParams,
22
23
  PasswordChangedEmailParams,
24
+ AutomationMailParams,
23
25
  } from './email.types'
24
26
 
25
27
  /** Default sender address (Resend test sender, works without a verified domain). */
@@ -78,3 +80,14 @@ export async function sendPasswordChangedEmail(
78
80
  html,
79
81
  })
80
82
  }
83
+
84
+ export async function sendAutomationMail(params: AutomationMailParams): Promise<void> {
85
+ const provider = createProvider(params.apiKey ?? params.resendApiKey ?? '', false)
86
+ const message = buildAutomationEmail(params)
87
+ await provider.send({
88
+ from: params.from ?? DEFAULT_FROM,
89
+ to: [message.to],
90
+ subject: message.subject,
91
+ html: message.html,
92
+ })
93
+ }
@@ -96,3 +96,13 @@ export interface PasswordResetEmailParams extends BaseEmailParams {
96
96
 
97
97
  /** Parameters for the "password changed" notification. No additional fields. */
98
98
  export type PasswordChangedEmailParams = BaseEmailParams
99
+
100
+ export interface AutomationMailParams {
101
+ to: string
102
+ subject: string
103
+ /** Plain text or HTML — passed verbatim to provider. */
104
+ body: string
105
+ apiKey?: string
106
+ resendApiKey?: string
107
+ from?: string
108
+ }
@@ -16,7 +16,7 @@
16
16
  * PasswordChangedEmailParams — shape dei parametri per sendPasswordChangedEmail
17
17
  */
18
18
 
19
- export { sendPasswordResetEmail, sendPasswordChangedEmail } from './email.service'
19
+ export { sendPasswordResetEmail, sendPasswordChangedEmail, sendAutomationMail } from './email.service'
20
20
  export {
21
21
  resolveEmailLocale,
22
22
  SUPPORTED_EMAIL_LOCALES,
@@ -25,4 +25,5 @@ export type {
25
25
  EmailLocale,
26
26
  PasswordResetEmailParams,
27
27
  PasswordChangedEmailParams,
28
+ AutomationMailParams,
28
29
  } from './email.types'
@@ -0,0 +1,15 @@
1
+ import type { AutomationMailParams } from '../email.types'
2
+
3
+ /** Identity builder: automation payloads are already user-authored. */
4
+ export function buildAutomationEmail(params: AutomationMailParams) {
5
+ return {
6
+ to: params.to,
7
+ subject: params.subject,
8
+ html: params.body,
9
+ text: stripHtml(params.body),
10
+ }
11
+ }
12
+
13
+ function stripHtml(input: string): string {
14
+ return input.replace(/<[^>]+>/g, '').trim()
15
+ }
@@ -3,47 +3,40 @@ import { Hono } from 'hono'
3
3
  import type { Env, Variables } from '../../types'
4
4
 
5
5
  /**
6
- * Notifications Feature Handler
6
+ * Notifications Feature Handler.
7
7
  *
8
- * Manages the retrieval, marking as read/unread, and deletion of system notifications.
9
- * Uses ETags for efficient client-side caching of the notification list.
8
+ * Manages retrieval, mark read/unread, and deletion of system notifications.
9
+ * All persistence goes through the {@link INotificationRepository} injected
10
+ * by `repositoryMiddleware`. The handler owns the HTTP concerns only:
11
+ * ETag negotiation, status codes, error mapping.
10
12
  */
11
13
  const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
12
14
 
15
+ const NOTIFICATION_LIST_LIMIT = 50
16
+
13
17
  /**
14
18
  * GET /notifications
15
- * Fetches the latest 50 notifications.
16
- * Implements ETag/304 Not Modified caching based on count and last update.
19
+ *
20
+ * Returns the most recent notifications. The ETag is built from aggregate
21
+ * stats so unchanged inboxes return 304 without serialising the full list.
17
22
  */
18
23
  notificationsApp.get('/notifications', async (context) => {
19
24
  try {
20
- const { DB } = context.env
21
-
22
- // Fetch aggregate stats to generate a robust ETag
23
- const stats = await DB.prepare(
24
- 'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
25
- ).first<{ count: number; latest: number | null; read_sum: number | null }>()
26
-
27
- const totalCount = stats?.count ?? 0
28
- const lastUpdateTimestamp = stats?.latest ?? 0
29
- const totalReadCount = stats?.read_sum ?? 0
30
-
31
- // ETag is derived from count, latest timestamp, and read status to ensure freshness
32
- const etag = `W/"${totalCount}-${lastUpdateTimestamp}-${totalReadCount}"`
25
+ const notificationRepository = context.get('notificationRepository')
26
+ const notificationStats = await notificationRepository.stats()
27
+ const etagValue = `W/"${notificationStats.totalCount}-${notificationStats.latestCreatedAt}-${notificationStats.readCount}"`
33
28
 
34
29
  const ifNoneMatch = context.req.header('If-None-Match')
35
- if (ifNoneMatch === etag) {
30
+ if (ifNoneMatch === etagValue) {
36
31
  return new Response(null, { status: 304 })
37
32
  }
38
33
 
39
- const dbResult = await DB.prepare(
40
- 'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
41
- ).all()
34
+ const notifications = await notificationRepository.list(NOTIFICATION_LIST_LIMIT)
42
35
 
43
- context.header('ETag', etag)
36
+ context.header('ETag', etagValue)
44
37
  context.header('Cache-Control', 'no-cache, must-revalidate')
45
38
 
46
- return context.json(dbResult.results ?? [])
39
+ return context.json(notifications)
47
40
  } catch (error) {
48
41
  console.error('[Notifications] Fetch error:', error)
49
42
  return context.json({ error: 'Failed to fetch notifications' }, 500)
@@ -51,18 +44,12 @@ notificationsApp.get('/notifications', async (context) => {
51
44
  })
52
45
 
53
46
  /**
54
- * PATCH /notifications/:id/read
55
- * Marks a specific notification as read.
47
+ * PATCH /notifications/:id/read — mark a single notification as read.
56
48
  */
57
49
  notificationsApp.patch('/notifications/:id/read', async (context) => {
58
50
  try {
59
51
  const notificationId = context.req.param('id')
60
- const { DB } = context.env
61
-
62
- await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
63
- .bind(notificationId)
64
- .run()
65
-
52
+ await context.get('notificationRepository').markRead(notificationId)
66
53
  return context.json({ success: true })
67
54
  } catch (error) {
68
55
  console.error('[Notifications] Mark read error:', error)
@@ -71,18 +58,12 @@ notificationsApp.patch('/notifications/:id/read', async (context) => {
71
58
  })
72
59
 
73
60
  /**
74
- * PATCH /notifications/:id/unread
75
- * Marks a specific notification as unread.
61
+ * PATCH /notifications/:id/unread — mark a single notification as unread.
76
62
  */
77
63
  notificationsApp.patch('/notifications/:id/unread', async (context) => {
78
64
  try {
79
65
  const notificationId = context.req.param('id')
80
- const { DB } = context.env
81
-
82
- await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
83
- .bind(notificationId)
84
- .run()
85
-
66
+ await context.get('notificationRepository').markUnread(notificationId)
86
67
  return context.json({ success: true })
87
68
  } catch (error) {
88
69
  console.error('[Notifications] Mark unread error:', error)
@@ -91,18 +72,12 @@ notificationsApp.patch('/notifications/:id/unread', async (context) => {
91
72
  })
92
73
 
93
74
  /**
94
- * DELETE /notifications/:id
95
- * Permanently deletes a notification.
75
+ * DELETE /notifications/:id — permanently remove a notification.
96
76
  */
97
77
  notificationsApp.delete('/notifications/:id', async (context) => {
98
78
  try {
99
79
  const notificationId = context.req.param('id')
100
- const { DB } = context.env
101
-
102
- await DB.prepare('DELETE FROM notifications WHERE id = ?')
103
- .bind(notificationId)
104
- .run()
105
-
80
+ await context.get('notificationRepository').delete(notificationId)
106
81
  return context.json({ success: true })
107
82
  } catch (error) {
108
83
  console.error('[Notifications] Delete error:', error)
@@ -111,15 +86,11 @@ notificationsApp.delete('/notifications/:id', async (context) => {
111
86
  })
112
87
 
113
88
  /**
114
- * POST /notifications/mark-all-read
115
- * Marks all notifications in the database as read.
89
+ * POST /notifications/mark-all-read — mark every notification as read.
116
90
  */
117
91
  notificationsApp.post('/notifications/mark-all-read', async (context) => {
118
92
  try {
119
- const { DB } = context.env
120
-
121
- await DB.prepare('UPDATE notifications SET is_read = 1').run()
122
-
93
+ await context.get('notificationRepository').markAllRead()
123
94
  return context.json({ success: true })
124
95
  } catch (error) {
125
96
  console.error('[Notifications] Mark all read error:', error)
@@ -2,22 +2,11 @@
2
2
  import type { Context } from 'hono'
3
3
  import type { Env, Variables } from '../../types'
4
4
  import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
+ import { sha256hex } from '@beechcms/core'
6
+ import { getClientIp } from '../../shared/request-utils'
5
7
 
6
8
  const PASSWORD_RESET_TOKEN_EXPIRY_SECONDS = 30 * 60
7
9
 
8
- /**
9
- * Computes the SHA-256 hash of a string and returns it as a hex string.
10
- */
11
- async function computeSha256Hash(text: string): Promise<string> {
12
- const encoder = new TextEncoder()
13
- const data = encoder.encode(text)
14
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
15
-
16
- return Array.from(new Uint8Array(hashBuffer))
17
- .map(byte => byte.toString(16).padStart(2, '0'))
18
- .join('')
19
- }
20
-
21
10
  /**
22
11
  * Handles the password reset request.
23
12
  * Generates a reset token, stores its hash in the database, and sends an email to the user.
@@ -46,41 +35,30 @@ export async function requestPasswordReset(
46
35
  const normalizedEmail = emailInput.trim().toLowerCase()
47
36
  const emailLocale = resolveEmailLocale(payload.locale)
48
37
 
49
- // Rate limiting based on IP address
50
- if (env.FORGOT_PASSWORD_RATE_LIMITER) {
51
- const clientIpAddress = req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
52
- const { success: isRateLimitAllowed } = await env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: clientIpAddress })
53
-
54
- if (!isRateLimitAllowed) {
55
- return context.json({ error: 'Too many requests' }, 429)
56
- }
38
+ const clientIpAddress = getClientIp(req)
39
+ const forgotPasswordRateLimit = await context.get('rateLimiters').getLimiter('forgotPassword').checkLimit(clientIpAddress)
40
+ if (!forgotPasswordRateLimit.isAllowed) {
41
+ return context.json({ error: 'Too many requests' }, 429)
57
42
  }
58
43
 
59
- // Find user by email. We always return 200 success even if the user is not found to prevent user enumeration.
60
- const registeredUser = await env.DB
61
- .prepare('SELECT id FROM users WHERE email = ?')
62
- .bind(normalizedEmail)
63
- .first<{ id: string }>()
64
-
44
+ // Always return 200 even when the user is not found to prevent user enumeration.
45
+ const registeredUser = await context.get('userRepository').findByEmail(normalizedEmail)
65
46
  if (!registeredUser) {
66
47
  return context.json({ success: true })
67
48
  }
68
49
 
69
- // Invalidate any existing pending tokens for the same user before issuing a new one.
70
- await env.DB
71
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
72
- .bind(registeredUser.id)
73
- .run()
50
+ const nowTimestamp = Math.floor(Date.now() / 1000)
51
+ await context.get('passwordResetTokenRepository').invalidatePending(registeredUser.id, nowTimestamp)
74
52
 
75
53
  const resetToken = crypto.randomUUID()
76
- const hashedResetToken = await computeSha256Hash(resetToken)
77
- const expirationTimestamp = Math.floor(Date.now() / 1000) + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
54
+ const tokenHash = await sha256hex(resetToken)
55
+ const expiresAt = nowTimestamp + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
78
56
 
79
- // Store the hashed token in the database
80
- await env.DB
81
- .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
82
- .bind(crypto.randomUUID(), registeredUser.id, hashedResetToken, expirationTimestamp)
83
- .run()
57
+ await context.get('passwordResetTokenRepository').create({
58
+ userId: registeredUser.id,
59
+ tokenHash,
60
+ expiresAt,
61
+ })
84
62
 
85
63
  const baseUrl = (env.APP_URL ?? new URL(req.url).origin).replace(/\/$/, '')
86
64
  const resetUrl = `${baseUrl}/admin/reset-password?token=${resetToken}`
@@ -95,7 +73,6 @@ export async function requestPasswordReset(
95
73
  isDev: env.ENV !== 'production',
96
74
  })
97
75
  } catch (error) {
98
- // Only log errors in non-production environments
99
76
  if (env.ENV !== 'production') {
100
77
  console.error('[password-reset] Failed to send email:', error)
101
78
  }
@@ -103,4 +80,3 @@ export async function requestPasswordReset(
103
80
 
104
81
  return context.json({ success: true })
105
82
  }
106
-