@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.
- package/README.md +21 -0
- package/migrations/0000_v040_base.sql +213 -0
- package/package.json +36 -0
- package/src/auth/constants.ts +10 -0
- package/src/auth/login.ts +91 -0
- package/src/auth/refresh.ts +127 -0
- package/src/content.ts +502 -0
- package/src/factory.ts +56 -0
- package/src/features/draft/draft.handler.ts +198 -0
- package/src/features/draft/draft.test.ts +315 -0
- package/src/features/draft/index.ts +1 -0
- package/src/features/email/email.provider.ts +38 -0
- package/src/features/email/email.service.ts +80 -0
- package/src/features/email/email.types.ts +98 -0
- package/src/features/email/index.ts +28 -0
- package/src/features/email/providers/resend.ts +63 -0
- package/src/features/email/templates/password-changed.ts +59 -0
- package/src/features/email/templates/password-reset.ts +64 -0
- package/src/features/email/templates/shell.ts +93 -0
- package/src/features/notifications/index.ts +1 -0
- package/src/features/notifications/notifications.handler.ts +88 -0
- package/src/features/password-reset/index.ts +15 -0
- package/src/features/password-reset/request.ts +88 -0
- package/src/features/password-reset/reset.ts +110 -0
- package/src/features/rotate-field/index.ts +1 -0
- package/src/features/rotate-field/rotate-field.handler.ts +82 -0
- package/src/features/rotate-field/rotate-field.schema.ts +9 -0
- package/src/features/rotate-field/rotate-field.test.ts +297 -0
- package/src/features/settings/settings.handler.ts +249 -0
- package/src/features/setup/index.ts +59 -0
- package/src/features/stats/index.ts +1 -0
- package/src/features/stats/stats.handler.ts +395 -0
- package/src/index.ts +344 -0
- package/src/media-utils.ts +78 -0
- package/src/middleware.ts +67 -0
- package/src/public/access-policy.ts +23 -0
- package/src/public/api-key-middleware.ts +53 -0
- package/src/public/index.ts +12 -0
- package/src/public/problem-details.ts +42 -0
- package/src/public/public-add.ts +183 -0
- package/src/public/public-edit.ts +183 -0
- package/src/public/public-errors.ts +15 -0
- package/src/public/public-read.ts +217 -0
- package/src/public/public-routes.ts +31 -0
- package/src/public/query-builder.ts +241 -0
- package/src/public/rate-limit-middleware.ts +42 -0
- package/src/public/response-builder.ts +26 -0
- package/src/public/sanitize.ts +65 -0
- package/src/public/slug-utils.ts +14 -0
- package/src/search-utils.ts +188 -0
- package/src/search.ts +72 -0
- package/src/shared/activity-logger.ts +79 -0
- package/src/shared/apply-policies.ts +63 -0
- package/src/shared/content-utils.ts +108 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/notification-service.ts +56 -0
- package/src/shared/query-utils.ts +137 -0
- package/src/shared/storage-utils.ts +36 -0
- package/src/types.ts +35 -0
- package/src/upload.ts +335 -0
- 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
|
+
}
|