@beechcms/api 0.4.0-preview.8 → 0.4.0

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 (89) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -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/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -249
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
@@ -0,0 +1,20 @@
1
+ import { Hono } from 'hono'
2
+ import { AppEnv } from '../../types'
3
+ import { listHandler } from './handlers/list'
4
+ import { getByIdHandler, getBySlugHandler } from './handlers/get'
5
+ import { createHandler } from './handlers/create'
6
+ import { updateHandler } from './handlers/update'
7
+ import { deleteHandler } from './handlers/delete'
8
+ import { facetsHandler } from './handlers/facets'
9
+
10
+ const content = new Hono<AppEnv>()
11
+
12
+ content.get('/:slug', listHandler)
13
+ content.get('/:slug/facets', facetsHandler)
14
+ content.get('/:schema_slug/by-slug/:entry_slug', getBySlugHandler)
15
+ content.get('/:slug/:id', getByIdHandler)
16
+ content.post('/:slug', createHandler)
17
+ content.put('/:slug/:id', updateHandler)
18
+ content.delete('/:slug/:id', deleteHandler)
19
+
20
+ export default content
@@ -1,198 +1,272 @@
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 { logActivity } from '../../shared/activity-logger'
10
+ import { cleanStr } from '../../shared/query-utils'
11
+ import { applyVisibility } from '../../shared/apply-policies'
12
+ import { AppEnv } from '../../types'
13
+ import { CONTENT_ERRORS } from '../content/constants'
14
+
15
+ const draftApp = new Hono<AppEnv>()
16
+
17
+ function normalizeBody(raw: unknown): Record<string, unknown> {
18
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
19
+ }
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.',
27
+ })
28
+ }
29
+
30
+ // PUT /:slug/:id/draft — crea o sovrascrive la bozza in content_{slug}_drafts
31
+ draftApp.put('/:slug/:id/draft', async (context) => {
32
+ const slug = context.req.param('slug')
33
+ 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)
46
+
47
+ let body: Record<string, unknown>
48
+ try {
49
+ body = normalizeBody(await context.req.json<unknown>())
50
+ } catch {
51
+ return publicProblem(context, {
52
+ type: 'content-invalid-json',
53
+ title: 'Bad Request',
54
+ status: 400,
55
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
56
+ })
57
+ }
58
+
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
+ const sensitiveAliases = Object.keys(body).filter((alias) => {
76
+ const branch = seed.branches.find((b) => b.alias === alias)
77
+ return branch != null && resolvePolicies(branch).privacy !== 'plain'
78
+ })
79
+
80
+ if (sensitiveAliases.length > 0) {
81
+ return publicProblem(context, {
82
+ type: 'content-sensitive-field-edit',
83
+ title: 'Unprocessable Entity',
84
+ status: 422,
85
+ detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
86
+ })
87
+ }
88
+
89
+ const validation = validateAndSanitizeSeedPayload(seed, body, {
90
+ operation: 'update',
91
+ allowNull: true,
92
+ requireAtLeastOneValidField: true,
93
+ enforceRequiredFields: false,
94
+ })
95
+
96
+ if (validation.dangerousFields.length > 0) {
97
+ return publicProblem(context, {
98
+ type: 'content-dangerous-content',
99
+ title: 'Unprocessable Entity',
100
+ status: 422,
101
+ detail: `Dangerous markup in field '${validation.dangerousFields[0]}'`
102
+ })
103
+ }
104
+
105
+ if (validation.details.length > 0) {
106
+ return publicProblem(context, {
107
+ type: 'content-validation-failed',
108
+ title: 'Bad Request',
109
+ status: 400,
110
+ detail: 'Validation failed',
111
+ errors: validation.details
112
+ })
113
+ }
114
+
115
+ await repository.saveDraft(seed, id, validation.data)
116
+
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
+ })
127
+
128
+ return context.json({ success: true })
129
+ })
130
+
131
+ // GET /:slug/:id/draft legge la bozza pendente
132
+ draftApp.get('/:slug/:id/draft', async (context) => {
133
+ const slug = context.req.param('slug')
134
+ 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)
147
+
148
+ const repository = context.get('repository')
149
+ const draft = await repository.getDraft(seed, id)
150
+
151
+ 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
+ }
171
+ }
172
+
173
+ return context.json({ data: applyVisibility(draft, seed) })
174
+ })
175
+
176
+ // POST /:slug/:id/draft/publish — promuove bozza → live atomicamente
177
+ draftApp.post('/:slug/:id/draft/publish', async (context) => {
178
+ const slug = context.req.param('slug')
179
+ 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)
192
+
193
+ const repository = context.get('repository')
194
+ const draft = await repository.getDraft(seed, id)
195
+
196
+ 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
+ }
216
+ }
217
+
218
+ await repository.publishDraft(seed, id)
219
+
220
+ const displayValue = draft[seed.displayNameAlias]
221
+ 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
+ })
230
+
231
+ return context.json({ success: true })
232
+ })
233
+
234
+ // DELETE /:slug/:id/draft — scarta la bozza pendente
235
+ draftApp.delete('/:slug/:id/draft', async (context) => {
236
+ const slug = context.req.param('slug')
237
+ 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)
250
+
251
+ 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
+ await repository.deleteDraft(seed, id)
267
+
268
+ return context.json({ success: true })
269
+ })
270
+
271
+ export { draftApp }
272
+
@@ -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
+ }