@beechcms/api 0.4.0-preview.12 → 0.4.0-preview.13
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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/{index-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -325
- package/src/features/content/constants.ts +10 -10
- package/src/features/content/handlers/create.ts +153 -163
- package/src/features/content/handlers/delete.ts +76 -85
- package/src/features/content/handlers/facets.ts +45 -45
- package/src/features/content/handlers/get.ts +116 -116
- package/src/features/content/handlers/list.ts +88 -88
- package/src/features/content/handlers/update.ts +207 -216
- package/src/features/content/index.ts +20 -20
- package/src/features/draft/draft.handler.ts +272 -272
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -267
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -24
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -18
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -166
- package/src/public/public-edit.ts +158 -158
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -216
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -152
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -28
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -382
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -45
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -41
- package/src/upload.ts +179 -331
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-ye3325L9.css +0 -1
|
@@ -1,272 +1,272 @@
|
|
|
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
|
+
/// <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 —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* ───
|
|
11
|
-
* 1.
|
|
12
|
-
* 2.
|
|
13
|
-
* 3. In `email.service.ts
|
|
14
|
-
*
|
|
15
|
-
* 4.
|
|
16
|
-
* 5.
|
|
17
|
-
* ─────────────────────────────────────────────────────────────────────────────
|
|
18
|
-
*/
|
|
19
|
-
export interface EmailProvider {
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* @param email -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* @returns Promise
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* deliverability
|
|
31
|
-
*
|
|
32
|
-
* @throws {Error}
|
|
33
|
-
*
|
|
34
|
-
* (`email.service.ts`)
|
|
35
|
-
*
|
|
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
|
+
}
|