@beechcms/api 0.4.0-preview.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 (61) hide show
  1. package/README.md +21 -0
  2. package/migrations/0000_v040_base.sql +213 -0
  3. package/package.json +36 -0
  4. package/src/auth/constants.ts +10 -0
  5. package/src/auth/login.ts +91 -0
  6. package/src/auth/refresh.ts +127 -0
  7. package/src/content.ts +502 -0
  8. package/src/factory.ts +56 -0
  9. package/src/features/draft/draft.handler.ts +198 -0
  10. package/src/features/draft/draft.test.ts +315 -0
  11. package/src/features/draft/index.ts +1 -0
  12. package/src/features/email/email.provider.ts +38 -0
  13. package/src/features/email/email.service.ts +80 -0
  14. package/src/features/email/email.types.ts +98 -0
  15. package/src/features/email/index.ts +28 -0
  16. package/src/features/email/providers/resend.ts +63 -0
  17. package/src/features/email/templates/password-changed.ts +59 -0
  18. package/src/features/email/templates/password-reset.ts +64 -0
  19. package/src/features/email/templates/shell.ts +93 -0
  20. package/src/features/notifications/index.ts +1 -0
  21. package/src/features/notifications/notifications.handler.ts +88 -0
  22. package/src/features/password-reset/index.ts +15 -0
  23. package/src/features/password-reset/request.ts +88 -0
  24. package/src/features/password-reset/reset.ts +110 -0
  25. package/src/features/rotate-field/index.ts +1 -0
  26. package/src/features/rotate-field/rotate-field.handler.ts +82 -0
  27. package/src/features/rotate-field/rotate-field.schema.ts +9 -0
  28. package/src/features/rotate-field/rotate-field.test.ts +297 -0
  29. package/src/features/settings/settings.handler.ts +249 -0
  30. package/src/features/setup/index.ts +59 -0
  31. package/src/features/stats/index.ts +1 -0
  32. package/src/features/stats/stats.handler.ts +395 -0
  33. package/src/index.ts +344 -0
  34. package/src/media-utils.ts +78 -0
  35. package/src/middleware.ts +67 -0
  36. package/src/public/access-policy.ts +23 -0
  37. package/src/public/api-key-middleware.ts +53 -0
  38. package/src/public/index.ts +12 -0
  39. package/src/public/problem-details.ts +42 -0
  40. package/src/public/public-add.ts +183 -0
  41. package/src/public/public-edit.ts +183 -0
  42. package/src/public/public-errors.ts +15 -0
  43. package/src/public/public-read.ts +217 -0
  44. package/src/public/public-routes.ts +31 -0
  45. package/src/public/query-builder.ts +241 -0
  46. package/src/public/rate-limit-middleware.ts +42 -0
  47. package/src/public/response-builder.ts +26 -0
  48. package/src/public/sanitize.ts +65 -0
  49. package/src/public/slug-utils.ts +14 -0
  50. package/src/search-utils.ts +188 -0
  51. package/src/search.ts +72 -0
  52. package/src/shared/activity-logger.ts +79 -0
  53. package/src/shared/apply-policies.ts +63 -0
  54. package/src/shared/content-utils.ts +108 -0
  55. package/src/shared/fts-sync.ts +4 -0
  56. package/src/shared/notification-service.ts +56 -0
  57. package/src/shared/query-utils.ts +137 -0
  58. package/src/shared/storage-utils.ts +36 -0
  59. package/src/types.ts +35 -0
  60. package/src/upload.ts +335 -0
  61. package/src/widget.ts +349 -0
@@ -0,0 +1,198 @@
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 }
@@ -0,0 +1,315 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ // --- Mock jose (auth bypass) ---
6
+ const mockJwtVerify = vi.hoisted(() => vi.fn())
7
+ vi.mock('jose', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('jose')>()
9
+ return { ...actual, jwtVerify: mockJwtVerify }
10
+ })
11
+
12
+ // --- Seed di test (hoisted so they're available inside vi.mock factory) ---
13
+ const { DRAFT_SEED, NO_DRAFT_SEED } = vi.hoisted(() => {
14
+ const DRAFT_SEED = {
15
+ slug: 'test-articoli',
16
+ label: 'Articolo',
17
+ displayNameAlias: 'title',
18
+ allowDrafts: true,
19
+ branches: [
20
+ { id: 'br_01', alias: 'title', label: 'Titolo', type: 'text', requiredOnCreate: true },
21
+ { id: 'br_02', alias: 'body', label: 'Corpo', type: 'text' },
22
+ ],
23
+ }
24
+ const NO_DRAFT_SEED = {
25
+ slug: 'test-messaggi',
26
+ label: 'Messaggio',
27
+ displayNameAlias: 'name',
28
+ branches: [
29
+ { id: 'br_01', alias: 'name', label: 'Nome', type: 'text' },
30
+ ],
31
+ }
32
+ return { DRAFT_SEED, NO_DRAFT_SEED }
33
+ })
34
+
35
+ vi.mock('@beechcms/core', async (importOriginal) => {
36
+ const actual = await importOriginal<typeof import('@beechcms/core')>()
37
+ return {
38
+ ...actual,
39
+ SEED_REGISTRY: {
40
+ ...actual.SEED_REGISTRY,
41
+ 'test-articoli': DRAFT_SEED,
42
+ 'test-messaggi': NO_DRAFT_SEED,
43
+ },
44
+ getSeed: (slug: string) => {
45
+ if (slug === 'test-articoli') return DRAFT_SEED
46
+ if (slug === 'test-messaggi') return NO_DRAFT_SEED
47
+ return actual.getSeed(slug)
48
+ },
49
+ }
50
+ })
51
+
52
+ import app from '../../index'
53
+
54
+ // --- Helpers ---
55
+
56
+ const ENTRY_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
57
+
58
+ function makeAuthHeader() {
59
+ return { Authorization: 'Bearer test-token' }
60
+ }
61
+
62
+ /**
63
+ * v0.4.0 Helper: returns a row as it would appear in content_{slug}
64
+ */
65
+ function makeLiveRow(overrides: Record<string, any> = {}) {
66
+ return {
67
+ id: ENTRY_ID,
68
+ slug: 'test-article',
69
+ status: 'published',
70
+ title: 'Titolo live',
71
+ body: 'Corpo live',
72
+ created_at: 1_000_000,
73
+ updated_at: 1_000_000,
74
+ ...overrides
75
+ }
76
+ }
77
+
78
+ /**
79
+ * v0.4.0 Helper: returns a row as it would appear in content_{slug}_drafts
80
+ */
81
+ function makeDraftRow(overrides: Record<string, any> = {}) {
82
+ return {
83
+ entry_id: ENTRY_ID,
84
+ title: 'Titolo in bozza',
85
+ body: 'Corpo in bozza',
86
+ updated_at: 1_000_001,
87
+ ...overrides
88
+ }
89
+ }
90
+
91
+ function makeMockDB(options: {
92
+ liveRow?: object | null,
93
+ draftRow?: object | null,
94
+ bindCalls?: Array<{ sql: string; args: unknown[] }>
95
+ } = {}) {
96
+ const { liveRow = null, draftRow = null, bindCalls = [] } = options
97
+
98
+ const prepare = vi.fn((sql: string) => ({
99
+ bind: vi.fn((...args: unknown[]) => {
100
+ bindCalls.push({ sql, args })
101
+ return {
102
+ first: vi.fn(async () => {
103
+ if (sql.includes('_drafts')) return draftRow
104
+ return liveRow
105
+ }),
106
+ run: vi.fn(async () => ({ success: true, meta: { changes: 1 } })),
107
+ all: vi.fn(async () => ({ results: [] })),
108
+ }
109
+ }),
110
+ first: vi.fn(async () => {
111
+ if (sql.includes('_drafts')) return draftRow
112
+ return liveRow
113
+ }),
114
+ run: vi.fn(async () => ({ success: true, meta: { changes: 1 } })),
115
+ }))
116
+
117
+ return {
118
+ prepare,
119
+ batch: vi.fn(async (stmts: any[]) => {
120
+ // In tests, we just assume they run fine
121
+ return stmts.map(() => ({ success: true }))
122
+ })
123
+ } as unknown as D1Database
124
+ }
125
+
126
+ // --- Suite ---
127
+
128
+ describe('Draft feature — PUT /:slug/:id/draft', () => {
129
+ beforeEach(() => {
130
+ mockJwtVerify.mockReset()
131
+ mockJwtVerify.mockResolvedValue({
132
+ payload: { sub: 'user-1', email: 'admin@beech.local' },
133
+ protectedHeader: { alg: 'HS256', typ: 'JWT' },
134
+ })
135
+ })
136
+
137
+ it('returns 200 when draft is saved', async () => {
138
+ const res = await app.request(
139
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
140
+ {
141
+ method: 'PUT',
142
+ headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
143
+ body: JSON.stringify({ title: 'Bozza titolo' }),
144
+ },
145
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow() }) }
146
+ )
147
+ expect(res.status).toBe(200)
148
+ const json = await res.json<{ success: boolean }>()
149
+ expect(json.success).toBe(true)
150
+ })
151
+
152
+ it('stores aliases as column names in content_{slug}_drafts', async () => {
153
+ const bindCalls: Array<{ sql: string; args: unknown[] }> = []
154
+ const mockDB = makeMockDB({ liveRow: makeLiveRow(), bindCalls })
155
+
156
+ await app.request(
157
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
158
+ {
159
+ method: 'PUT',
160
+ headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
161
+ body: JSON.stringify({ title: 'Titolo in bozza' }),
162
+ },
163
+ { JWT_SECRET: 'test-secret', DB: mockDB }
164
+ )
165
+
166
+ const upsertCall = bindCalls.find((c) => c.sql.includes('INSERT INTO content_test-articoli_drafts'))
167
+ expect(upsertCall).toBeDefined()
168
+ // Column 'title' should be present in the SQL
169
+ expect(upsertCall!.sql).toContain('title')
170
+ // The value should be the second bind (first is ID)
171
+ expect(upsertCall!.args[1]).toBe('Titolo in bozza')
172
+ })
173
+
174
+ it('returns 405 when seed does not allow drafts', async () => {
175
+ const res = await app.request(
176
+ `/api/content/test-messaggi/${ENTRY_ID}/draft`,
177
+ {
178
+ method: 'PUT',
179
+ headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
180
+ body: JSON.stringify({ name: 'test' }),
181
+ },
182
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow() }) }
183
+ )
184
+ expect(res.status).toBe(405)
185
+ })
186
+
187
+ it('returns 404 when entry does not exist', async () => {
188
+ const res = await app.request(
189
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
190
+ {
191
+ method: 'PUT',
192
+ headers: { 'Content-Type': 'application/json', ...makeAuthHeader() },
193
+ body: JSON.stringify({ title: 'Test' }),
194
+ },
195
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: null }) }
196
+ )
197
+ expect(res.status).toBe(404)
198
+ })
199
+ })
200
+
201
+ describe('Draft feature — GET /:slug/:id/draft', () => {
202
+ beforeEach(() => {
203
+ mockJwtVerify.mockReset()
204
+ mockJwtVerify.mockResolvedValue({
205
+ payload: { sub: 'user-1', email: 'admin@beech.local' },
206
+ protectedHeader: { alg: 'HS256', typ: 'JWT' },
207
+ })
208
+ })
209
+
210
+ it('returns draft data with aliases', async () => {
211
+ const res = await app.request(
212
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
213
+ { headers: makeAuthHeader() },
214
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() }) }
215
+ )
216
+ expect(res.status).toBe(200)
217
+ const json = await res.json<{ data: Record<string, unknown> }>()
218
+ expect(json.data.title).toBe('Titolo in bozza')
219
+ expect(json.data.body).toBe('Corpo in bozza')
220
+ })
221
+
222
+ it('returns 404 when no draft exists', async () => {
223
+ const res = await app.request(
224
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
225
+ { headers: makeAuthHeader() },
226
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: null }) }
227
+ )
228
+ expect(res.status).toBe(404)
229
+ })
230
+
231
+ it('returns 404 when entry does not exist', async () => {
232
+ const res = await app.request(
233
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
234
+ { headers: makeAuthHeader() },
235
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: null }) }
236
+ )
237
+ expect(res.status).toBe(404)
238
+ })
239
+ })
240
+
241
+ describe('Draft feature — POST /:slug/:id/draft/publish', () => {
242
+ beforeEach(() => {
243
+ mockJwtVerify.mockReset()
244
+ mockJwtVerify.mockResolvedValue({
245
+ payload: { sub: 'user-1', email: 'admin@beech.local' },
246
+ protectedHeader: { alg: 'HS256', typ: 'JWT' },
247
+ })
248
+ })
249
+
250
+ it('returns 200 and promotes draft to live', async () => {
251
+ const res = await app.request(
252
+ `/api/content/test-articoli/${ENTRY_ID}/draft/publish`,
253
+ { method: 'POST', headers: makeAuthHeader() },
254
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() }) }
255
+ )
256
+ expect(res.status).toBe(200)
257
+ const json = await res.json<{ success: boolean }>()
258
+ expect(json.success).toBe(true)
259
+ })
260
+
261
+ it('uses DB.batch to update live table and delete draft', async () => {
262
+ const mockDB = makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() })
263
+ const res = await app.request(
264
+ `/api/content/test-articoli/${ENTRY_ID}/draft/publish`,
265
+ { method: 'POST', headers: makeAuthHeader() },
266
+ { JWT_SECRET: 'test-secret', DB: mockDB }
267
+ )
268
+ expect(res.status).toBe(200)
269
+ expect(mockDB.batch).toHaveBeenCalled()
270
+ })
271
+
272
+ it('returns 404 when no pending draft exists', async () => {
273
+ const res = await app.request(
274
+ `/api/content/test-articoli/${ENTRY_ID}/draft/publish`,
275
+ { method: 'POST', headers: makeAuthHeader() },
276
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: null }) }
277
+ )
278
+ expect(res.status).toBe(404)
279
+ })
280
+ })
281
+
282
+ describe('Draft feature — DELETE /:slug/:id/draft', () => {
283
+ beforeEach(() => {
284
+ mockJwtVerify.mockReset()
285
+ mockJwtVerify.mockResolvedValue({
286
+ payload: { sub: 'user-1', email: 'admin@beech.local' },
287
+ protectedHeader: { alg: 'HS256', typ: 'JWT' },
288
+ })
289
+ })
290
+
291
+ it('returns 200 when draft is discarded', async () => {
292
+ const res = await app.request(
293
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
294
+ { method: 'DELETE', headers: makeAuthHeader() },
295
+ { JWT_SECRET: 'test-secret', DB: makeMockDB({ liveRow: makeLiveRow(), draftRow: makeDraftRow() }) }
296
+ )
297
+ expect(res.status).toBe(200)
298
+ const json = await res.json<{ success: boolean }>()
299
+ expect(json.success).toBe(true)
300
+ })
301
+
302
+ it('issues DELETE FROM content_{slug}_drafts', async () => {
303
+ const bindCalls: Array<{ sql: string; args: unknown[] }> = []
304
+ const mockDB = makeMockDB({ liveRow: makeLiveRow(), bindCalls })
305
+
306
+ await app.request(
307
+ `/api/content/test-articoli/${ENTRY_ID}/draft`,
308
+ { method: 'DELETE', headers: makeAuthHeader() },
309
+ { JWT_SECRET: 'test-secret', DB: mockDB }
310
+ )
311
+
312
+ const deleteCall = bindCalls.find((c) => c.sql.includes('DELETE FROM content_test-articoli_drafts'))
313
+ expect(deleteCall).toBeDefined()
314
+ })
315
+ })
@@ -0,0 +1 @@
1
+ export { draftApp } from './draft.handler'
@@ -0,0 +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
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Email Service — orchestratore del modulo email di Beech CMS.
3
+ *
4
+ * Pipeline di invio:
5
+ * chiamante → funzione service → template builder → provider → Resend (o altro)
6
+ *
7
+ * Questo è l'unico file che importa sia dai template che dal provider.
8
+ * Nessun altro layer conosce l'intera pipeline.
9
+ *
10
+ * ─── CAMBIO PROVIDER ─────────────────────────────────────────────────────────
11
+ * Per sostituire Resend con un altro servizio, modifica SOLO la funzione
12
+ * `createProvider()` qui sotto: cambia l'import e l'istanziazione.
13
+ * Nessun altro file del modulo — né nel resto del progetto — va toccato.
14
+ * ─────────────────────────────────────────────────────────────────────────────
15
+ */
16
+ import { ResendEmailProvider } from './providers/resend'
17
+ import { buildPasswordResetEmail } from './templates/password-reset'
18
+ import { buildPasswordChangedEmail } from './templates/password-changed'
19
+ import type { EmailProvider } from './email.provider'
20
+ import type {
21
+ PasswordResetEmailParams,
22
+ PasswordChangedEmailParams,
23
+ } from './email.types'
24
+
25
+ /** Indirizzo mittente di default (mittente di test Resend, funziona senza dominio verificato). */
26
+ const DEFAULT_FROM = 'Beech CMS <onboarding@resend.dev>'
27
+
28
+ /**
29
+ * Istanzia il provider email attivo.
30
+ *
31
+ * Questo è il punto singolo di cambio provider: sostituisci la riga
32
+ * `new ResendEmailProvider(…)` con qualsiasi classe che implementi `EmailProvider`.
33
+ */
34
+ function createProvider(apiKey: string, isDev: boolean): EmailProvider {
35
+ return new ResendEmailProvider(apiKey, isDev)
36
+ }
37
+
38
+ /**
39
+ * Invia l'email con il link di reset password al destinatario specificato.
40
+ *
41
+ * Il corpo dell'email è costruito dal template localizzato in
42
+ * `templates/password-reset.ts` e composto con il layout base in
43
+ * `templates/shell.ts`.
44
+ *
45
+ * @throws Se il provider rifiuta la richiesta. Il chiamante decide se propagare
46
+ * l'errore (fail della request) o gestirlo silenziosamente (fire-and-forget).
47
+ */
48
+ export async function sendPasswordResetEmail(
49
+ params: PasswordResetEmailParams,
50
+ ): Promise<void> {
51
+ const provider = createProvider(params.apiKey, params.isDev ?? false)
52
+ const { subject, html } = buildPasswordResetEmail(params.resetUrl, params.locale)
53
+ await provider.send({
54
+ from: params.from ?? DEFAULT_FROM,
55
+ to: [params.to],
56
+ subject,
57
+ html,
58
+ })
59
+ }
60
+
61
+ /**
62
+ * Invia la notifica di sicurezza "password modificata" al proprietario dell'account.
63
+ *
64
+ * Chiamata dopo un reset password riuscito per avvisare l'utente. Non ha un
65
+ * pulsante CTA — è una pura notifica, nessuna azione richiesta all'utente.
66
+ *
67
+ * @throws Se il provider rifiuta la richiesta.
68
+ */
69
+ export async function sendPasswordChangedEmail(
70
+ params: PasswordChangedEmailParams,
71
+ ): Promise<void> {
72
+ const provider = createProvider(params.apiKey, params.isDev ?? false)
73
+ const { subject, html } = buildPasswordChangedEmail(params.locale)
74
+ await provider.send({
75
+ from: params.from ?? DEFAULT_FROM,
76
+ to: [params.to],
77
+ subject,
78
+ html,
79
+ })
80
+ }