@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.
Files changed (85) 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-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
  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 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  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 -267
  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 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  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 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  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 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  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 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. 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 — 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
+ }