@beechcms/api 0.4.0-preview.9 → 0.4.1

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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -1,198 +1,283 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import { validateAndSanitizeSeedPayload, resolvePolicies, serializeForDb, deserializeFromDb } from '@beechcms/core'
4
- import type { Seed } from '@beechcms/core'
5
- import { publicProblem } from '../../public/problem-details'
6
- import { logActivity } from '../../shared/activity-logger'
7
- import { cleanStr } from '../../shared/query-utils'
8
- import { applyVisibility } from '../../shared/apply-policies'
9
-
10
- type Bindings = { DB: D1Database }
11
- type Variables = {
12
- jwtPayload: { sub: string; email?: string }
13
- getSeed: (slug: string) => Seed | null
14
- seedRegistry: Record<string, Seed>
15
- }
16
-
17
- const draftApp = new Hono<{ Bindings: Bindings; Variables: Variables }>()
18
-
19
- function normalizeBody(raw: unknown): Record<string, unknown> {
20
- return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
21
- }
22
-
23
- function draftNotAllowed(c: Parameters<typeof publicProblem>[0]) {
24
- return publicProblem(c, {
25
- type: 'draft-not-allowed', title: 'Method Not Allowed', status: 405,
26
- detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
27
- })
28
- }
29
-
30
- // PUT /:slug/:id/draft crea o sovrascrive la bozza in content_{slug}_drafts
31
- draftApp.put('/:slug/:id/draft', async (c) => {
32
- const slug = c.req.param('slug')
33
- const id = c.req.param('id')
34
-
35
- const seed = c.get('getSeed')(slug)
36
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
37
- if (!seed.allowDrafts) return draftNotAllowed(c)
38
-
39
- let body: Record<string, unknown>
40
- try {
41
- body = normalizeBody(await c.req.json<unknown>())
42
- } catch {
43
- return publicProblem(c, { type: 'content-invalid-json', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
44
- }
45
-
46
- const { DB } = c.env
47
- const existing = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first<{ id: string }>()
48
- if (!existing) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
49
-
50
- const sensitiveAliases = Object.keys(body).filter((alias) => {
51
- const branch = seed.branches.find((b) => b.alias === alias)
52
- return branch != null && resolvePolicies(branch).privacy !== 'plain'
53
- })
54
- if (sensitiveAliases.length > 0) {
55
- return publicProblem(c, { type: 'content-sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot draft sensitive fields: ${sensitiveAliases.join(', ')}` })
56
- }
57
-
58
- const validation = validateAndSanitizeSeedPayload(seed, body, {
59
- operation: 'update', allowNull: true, requireAtLeastOneValidField: true, enforceRequiredFields: false,
60
- })
61
- if (validation.dangerousFields.length > 0) {
62
- return publicProblem(c, { type: 'content-dangerous-content', title: 'Unprocessable Entity', status: 422, detail: `Dangerous markup in field '${validation.dangerousFields[0]}'` })
63
- }
64
- if (validation.details.length > 0) {
65
- return publicProblem(c, { type: 'content-validation-failed', title: 'Bad Request', status: 400, detail: 'Validation failed', errors: validation.details })
66
- }
67
-
68
- // UPSERT in content_{slug}_drafts — solo colonne branch, nullable
69
- const draftTable = `content_${slug}_drafts`
70
- const cols: string[] = []
71
- const placeholders: string[] = []
72
- const bindings: (string | number | null)[] = []
73
- for (const branch of seed.branches) {
74
- if (Object.hasOwn(validation.data, branch.alias)) {
75
- cols.push(branch.alias)
76
- placeholders.push('?')
77
- bindings.push(serializeForDb(branch, validation.data[branch.alias]))
78
- }
79
- }
80
-
81
- const now = Math.floor(Date.now() / 1000)
82
- const updateSet = cols.map((c) => `${c} = excluded.${c}`).join(', ')
83
- await DB.prepare(
84
- `INSERT INTO ${draftTable} (entry_id, ${cols.join(', ')}, updated_at)
85
- VALUES (?, ${placeholders.join(', ')}, ?)
86
- ON CONFLICT(entry_id) DO UPDATE SET ${updateSet}, updated_at = excluded.updated_at`
87
- ).bind(id, ...bindings, now).run()
88
-
89
- logActivity(c, {
90
- action: 'update', entityType: 'content', entityId: id, entitySlug: slug,
91
- details: { title: cleanStr(validation.data[seed.displayNameAlias]) ?? id, note: 'draft saved' },
92
- })
93
-
94
- return c.json({ success: true })
95
- })
96
-
97
- // GET /:slug/:id/draft — legge la bozza pendente
98
- draftApp.get('/:slug/:id/draft', async (c) => {
99
- const slug = c.req.param('slug')
100
- const id = c.req.param('id')
101
-
102
- const seed = c.get('getSeed')(slug)
103
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
104
- if (!seed.allowDrafts) return draftNotAllowed(c)
105
-
106
- const { DB } = c.env
107
- const row = await DB.prepare(`SELECT * FROM content_${slug}_drafts WHERE entry_id = ?`)
108
- .bind(id)
109
- .first<Record<string, unknown>>()
110
-
111
- if (!row) {
112
- // Verifica se entry esiste
113
- const entry = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first()
114
- if (!entry) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
115
- return publicProblem(c, { type: 'draft-not-found', title: 'Not Found', status: 404, detail: 'No pending draft for this entry' })
116
- }
117
-
118
- // Deserializza colonne branch reali
119
- const data: Record<string, unknown> = {}
120
- for (const branch of seed.branches) {
121
- if (Object.hasOwn(row, branch.alias)) {
122
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
123
- }
124
- }
125
-
126
- return c.json({ data: applyVisibility(data, seed) })
127
- })
128
-
129
- // POST /:slug/:id/draft/publish — promuove bozza → live atomicamente
130
- draftApp.post('/:slug/:id/draft/publish', async (c) => {
131
- const slug = c.req.param('slug')
132
- const id = c.req.param('id')
133
-
134
- const seed = c.get('getSeed')(slug)
135
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
136
- if (!seed.allowDrafts) return draftNotAllowed(c)
137
-
138
- const { DB } = c.env
139
- const draftRow = await DB.prepare(`SELECT * FROM content_${slug}_drafts WHERE entry_id = ?`)
140
- .bind(id)
141
- .first<Record<string, unknown>>()
142
-
143
- if (!draftRow) {
144
- const entry = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first()
145
- if (!entry) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
146
- return publicProblem(c, { type: 'draft-not-found', title: 'Not Found', status: 404, detail: 'No pending draft to publish' })
147
- }
148
-
149
- // Costruisce SET clause dal draft per UPDATE atomico
150
- const now = Math.floor(Date.now() / 1000)
151
- const setParts: string[] = ['status = ?', 'updated_at = ?']
152
- const setBindings: (string | number | null)[] = ['published', now]
153
-
154
- for (const branch of seed.branches) {
155
- if (Object.hasOwn(draftRow, branch.alias) && draftRow[branch.alias] !== null) {
156
- setParts.push(`${branch.alias} = ?`)
157
- setBindings.push(draftRow[branch.alias] as string | number | null)
158
- }
159
- }
160
-
161
- await DB.batch([
162
- DB.prepare(`UPDATE content_${slug} SET ${setParts.join(', ')} WHERE id = ?`)
163
- .bind(...setBindings, id),
164
- DB.prepare(`DELETE FROM content_${slug}_drafts WHERE entry_id = ?`)
165
- .bind(id),
166
- ])
167
-
168
- // Deserializza per activity log
169
- const displayValue = draftRow[seed.displayNameAlias]
170
- const displayStr = typeof displayValue === 'string' ? displayValue : id
171
-
172
- logActivity(c, {
173
- action: 'update', entityType: 'content', entityId: id, entitySlug: slug,
174
- details: { title: displayStr, note: 'draft published' },
175
- })
176
-
177
- return c.json({ success: true })
178
- })
179
-
180
- // DELETE /:slug/:id/draft — scarta la bozza pendente
181
- draftApp.delete('/:slug/:id/draft', async (c) => {
182
- const slug = c.req.param('slug')
183
- const id = c.req.param('id')
184
-
185
- const seed = c.get('getSeed')(slug)
186
- if (!seed) return publicProblem(c, { type: 'content-seed-not-found', title: 'Not Found', status: 404, detail: 'Seed not found' })
187
- if (!seed.allowDrafts) return draftNotAllowed(c)
188
-
189
- const { DB } = c.env
190
- const existing = await DB.prepare(`SELECT id FROM content_${slug} WHERE id = ?`).bind(id).first<{ id: string }>()
191
- if (!existing) return publicProblem(c, { type: 'content-not-found', title: 'Not Found', status: 404, detail: 'Not found' })
192
-
193
- await DB.prepare(`DELETE FROM content_${slug}_drafts WHERE entry_id = ?`).bind(id).run()
194
-
195
- return c.json({ success: true })
196
- })
197
-
198
- export { draftApp }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import {
4
+ validateAndSanitizeSeedPayload,
5
+ resolvePolicies,
6
+ EntryNotFoundError
7
+ } from '@beechcms/core'
8
+ import { publicProblem } from '../../public/problem-details'
9
+ import { cleanStr } from '../../shared/query-utils'
10
+ import { applyVisibility } from '../../shared/apply-policies'
11
+ import { AppEnv } from '../../types'
12
+ import { CONTENT_ERRORS } from '../content/constants'
13
+
14
+ const draftApp = new Hono<AppEnv>()
15
+
16
+ function normalizeBody(raw: unknown): Record<string, unknown> {
17
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
18
+ }
19
+
20
+ function draftNotAllowed(context: any) {
21
+ return publicProblem(context, {
22
+ type: 'draft-not-allowed',
23
+ title: 'Method Not Allowed',
24
+ status: 405,
25
+ detail: 'This content type does not support pending drafts. Set allowDrafts: true on the Seed to enable.',
26
+ })
27
+ }
28
+
29
+ // PUT /:slug/:id/draft — crea o sovrascrive la bozza in content_{slug}_drafts
30
+ draftApp.put('/:slug/:id/draft', async (context) => {
31
+ const slug = context.req.param('slug')
32
+ const id = context.req.param('id')
33
+
34
+ const seed = context.get('getSeed')(slug)
35
+ if (!seed) {
36
+ return publicProblem(context, {
37
+ type: 'content-seed-not-found',
38
+ title: 'Not Found',
39
+ status: 404,
40
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
41
+ })
42
+ }
43
+
44
+ if (!seed.allowDrafts) return draftNotAllowed(context)
45
+
46
+ let body: Record<string, unknown>
47
+ try {
48
+ body = normalizeBody(await context.req.json<unknown>())
49
+ } catch {
50
+ return publicProblem(context, {
51
+ type: 'content-invalid-json',
52
+ title: 'Bad Request',
53
+ status: 400,
54
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
55
+ })
56
+ }
57
+
58
+ const repository = context.get('repository')
59
+ try {
60
+ // Verify entry existence
61
+ await repository.findById(seed, id)
62
+ } catch (error) {
63
+ if (error instanceof EntryNotFoundError) {
64
+ return publicProblem(context, {
65
+ type: 'content-not-found',
66
+ title: 'Not Found',
67
+ status: 404,
68
+ detail: CONTENT_ERRORS.NOT_FOUND
69
+ })
70
+ }
71
+ throw error
72
+ }
73
+
74
+ const sensitiveAliases = Object.keys(body).filter((alias) => {
75
+ const branch = seed.branches.find((b) => b.alias === alias)
76
+ return branch != null && resolvePolicies(branch).privacy !== 'plain'
77
+ })
78
+
79
+ if (sensitiveAliases.length > 0) {
80
+ return publicProblem(context, {
81
+ type: 'content-sensitive-field-edit',
82
+ title: 'Unprocessable Entity',
83
+ status: 422,
84
+ detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
85
+ })
86
+ }
87
+
88
+ const validation = validateAndSanitizeSeedPayload(seed, body, {
89
+ operation: 'update',
90
+ allowNull: true,
91
+ requireAtLeastOneValidField: true,
92
+ enforceRequiredFields: false,
93
+ })
94
+
95
+ if (validation.dangerousFields.length > 0) {
96
+ return publicProblem(context, {
97
+ type: 'content-dangerous-content',
98
+ title: 'Unprocessable Entity',
99
+ status: 422,
100
+ detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
101
+ })
102
+ }
103
+
104
+ if (validation.details.length > 0) {
105
+ return publicProblem(context, {
106
+ type: 'content-validation-failed',
107
+ title: 'Bad Request',
108
+ status: 400,
109
+ detail: 'Validation failed',
110
+ errors: validation.details
111
+ })
112
+ }
113
+
114
+ await repository.saveDraft(seed, id, validation.data)
115
+
116
+ const draftSaveActor = context.get('jwtPayload')
117
+ context.get('activityLogger').log({
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
+ actor: {
127
+ id: draftSaveActor.sub,
128
+ email: draftSaveActor.email ?? 'unknown',
129
+ name: draftSaveActor.name ?? null,
130
+ },
131
+ })
132
+
133
+ return context.json({ success: true })
134
+ })
135
+
136
+ // GET /:slug/:id/draft — legge la bozza pendente
137
+ draftApp.get('/:slug/:id/draft', async (context) => {
138
+ const slug = context.req.param('slug')
139
+ const id = context.req.param('id')
140
+
141
+ const seed = context.get('getSeed')(slug)
142
+ if (!seed) {
143
+ return publicProblem(context, {
144
+ type: 'content-seed-not-found',
145
+ title: 'Not Found',
146
+ status: 404,
147
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
148
+ })
149
+ }
150
+
151
+ if (!seed.allowDrafts) return draftNotAllowed(context)
152
+
153
+ const repository = context.get('repository')
154
+ const draft = await repository.getDraft(seed, id)
155
+
156
+ if (!draft) {
157
+ try {
158
+ await repository.findById(seed, id)
159
+ return publicProblem(context, {
160
+ type: 'draft-not-found',
161
+ title: 'Not Found',
162
+ status: 404,
163
+ detail: 'No pending draft for this entry'
164
+ })
165
+ } catch (error) {
166
+ if (error instanceof EntryNotFoundError) {
167
+ return publicProblem(context, {
168
+ type: 'content-not-found',
169
+ title: 'Not Found',
170
+ status: 404,
171
+ detail: CONTENT_ERRORS.NOT_FOUND
172
+ })
173
+ }
174
+ throw error
175
+ }
176
+ }
177
+
178
+ return context.json({ data: applyVisibility(draft, seed) })
179
+ })
180
+
181
+ // POST /:slug/:id/draft/publish promuove bozza → live atomicamente
182
+ draftApp.post('/:slug/:id/draft/publish', async (context) => {
183
+ const slug = context.req.param('slug')
184
+ const id = context.req.param('id')
185
+
186
+ const seed = context.get('getSeed')(slug)
187
+ if (!seed) {
188
+ return publicProblem(context, {
189
+ type: 'content-seed-not-found',
190
+ title: 'Not Found',
191
+ status: 404,
192
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
193
+ })
194
+ }
195
+
196
+ if (!seed.allowDrafts) return draftNotAllowed(context)
197
+
198
+ const repository = context.get('repository')
199
+ const draft = await repository.getDraft(seed, id)
200
+
201
+ if (!draft) {
202
+ try {
203
+ await repository.findById(seed, id)
204
+ return publicProblem(context, {
205
+ type: 'draft-not-found',
206
+ title: 'Not Found',
207
+ status: 404,
208
+ detail: 'No pending draft to publish'
209
+ })
210
+ } catch (error) {
211
+ if (error instanceof EntryNotFoundError) {
212
+ return publicProblem(context, {
213
+ type: 'content-not-found',
214
+ title: 'Not Found',
215
+ status: 404,
216
+ detail: CONTENT_ERRORS.NOT_FOUND
217
+ })
218
+ }
219
+ throw error
220
+ }
221
+ }
222
+
223
+ await repository.publishDraft(seed, id)
224
+
225
+ const displayValue = draft[seed.displayNameAlias]
226
+ const displayStr = typeof displayValue === 'string' ? displayValue : id
227
+
228
+ const draftPublishActor = context.get('jwtPayload')
229
+ context.get('activityLogger').log({
230
+ action: 'update',
231
+ entityType: 'content',
232
+ entityId: id,
233
+ entitySlug: slug,
234
+ details: { title: displayStr, note: 'draft published' },
235
+ actor: {
236
+ id: draftPublishActor.sub,
237
+ email: draftPublishActor.email ?? 'unknown',
238
+ name: draftPublishActor.name ?? null,
239
+ },
240
+ })
241
+
242
+ return context.json({ success: true })
243
+ })
244
+
245
+ // DELETE /:slug/:id/draft — scarta la bozza pendente
246
+ draftApp.delete('/:slug/:id/draft', async (context) => {
247
+ const slug = context.req.param('slug')
248
+ const id = context.req.param('id')
249
+
250
+ const seed = context.get('getSeed')(slug)
251
+ if (!seed) {
252
+ return publicProblem(context, {
253
+ type: 'content-seed-not-found',
254
+ title: 'Not Found',
255
+ status: 404,
256
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
257
+ })
258
+ }
259
+
260
+ if (!seed.allowDrafts) return draftNotAllowed(context)
261
+
262
+ const repository = context.get('repository')
263
+ try {
264
+ await repository.findById(seed, id)
265
+ } catch (error) {
266
+ if (error instanceof EntryNotFoundError) {
267
+ return publicProblem(context, {
268
+ type: 'content-not-found',
269
+ title: 'Not Found',
270
+ status: 404,
271
+ detail: CONTENT_ERRORS.NOT_FOUND
272
+ })
273
+ }
274
+ throw error
275
+ }
276
+
277
+ await repository.deleteDraft(seed, id)
278
+
279
+ return context.json({ success: true })
280
+ })
281
+
282
+ export { draftApp }
283
+
@@ -1 +1 @@
1
- export { draftApp } from './draft.handler'
1
+ export { draftApp } from './draft.handler'
@@ -1,38 +1,38 @@
1
- import type { OutboundEmail } from './email.types'
2
-
3
- /**
4
- * EmailProvider — contratto formale per i provider di invio email.
5
- *
6
- * Ogni implementazione (Resend, SendGrid, Mailgun, SMTP, ) DEVE rispettare
7
- * questa interfaccia. È l'unico punto di accoppiamento tra il modulo email e
8
- * qualsiasi servizio esterno di terze parti.
9
- *
10
- * ─── COME CAMBIARE PROVIDER ──────────────────────────────────────────────────
11
- * 1. Crea un nuovo file sotto `providers/` (es. `providers/sendgrid.ts`).
12
- * 2. Esporta una classe che implementa questa interfaccia.
13
- * 3. In `email.service.ts` sostituisci l'import e l'istanziazione del provider
14
- * attuale con la tua nuova classe nella funzione `createProvider()`.
15
- * 4. Aggiorna le variabili d'ambiente necessarie in `types.ts` e `wrangler.jsonc`.
16
- * 5. Nessun altro file del progetto va toccato.
17
- * ─────────────────────────────────────────────────────────────────────────────
18
- */
19
- export interface EmailProvider {
20
- /**
21
- * Invia una singola email transazionale.
22
- *
23
- * @param email - Il messaggio completamente risolto: mittente, destinatario,
24
- * oggetto e corpo HTML. Usa i builder in `templates/` per
25
- * costruire questo oggetto in modo corretto.
26
- *
27
- * @returns Promise che si risolve quando il provider ha **accettato** il
28
- * messaggio per la consegna. L'accettazione non garantisce la ricezione
29
- * in inbox — quella dipende dal server del destinatario e dalla
30
- * deliverability del provider.
31
- *
32
- * @throws {Error} Se il provider rifiuta la richiesta (autenticazione fallita,
33
- * errore di rete, payload non valido). Il chiamante
34
- * (`email.service.ts`) è responsabile di catturare e gestire
35
- * questo errore in modo appropriato.
36
- */
37
- send(email: OutboundEmail): Promise<void>
38
- }
1
+ import type { OutboundEmail } from './email.types'
2
+
3
+ /**
4
+ * EmailProvider — formal contract for email sending providers.
5
+ *
6
+ * Every implementation (Resend, SendGrid, Mailgun, SMTP, ...) MUST comply
7
+ * with this interface. It is the only point of coupling between the email module and
8
+ * any third-party external service.
9
+ *
10
+ * ─── HOW TO CHANGE PROVIDER ──────────────────────────────────────────────────
11
+ * 1. Create a new file under `providers/` (e.g., `providers/sendgrid.ts`).
12
+ * 2. Export a class that implements this interface.
13
+ * 3. In `email.service.ts`, replace the import and instantiation of the current
14
+ * provider with your new class in the `createProvider()` function.
15
+ * 4. Update the necessary environment variables in `types.ts` and `wrangler.jsonc`.
16
+ * 5. No other file in the project needs to be touched.
17
+ * ─────────────────────────────────────────────────────────────────────────────
18
+ */
19
+ export interface EmailProvider {
20
+ /**
21
+ * Sends a single transactional email.
22
+ *
23
+ * @param email - The fully resolved message: sender, recipient,
24
+ * subject, and HTML body. Use the builders in `templates/` to
25
+ * construct this object correctly.
26
+ *
27
+ * @returns Promise that resolves when the provider has **accepted** the
28
+ * message for delivery. Acceptance does not guarantee delivery
29
+ * to the inbox — that depends on the recipient's server and the
30
+ * provider's deliverability.
31
+ *
32
+ * @throws {Error} If the provider rejects the request (failed authentication,
33
+ * network error, invalid payload). The caller
34
+ * (`email.service.ts`) is responsible for catching and handling
35
+ * this error appropriately.
36
+ */
37
+ send(email: OutboundEmail): Promise<void>
38
+ }