@beechcms/api 0.4.1 → 0.4.3

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 (67) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-BMkd1Irh.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/factory.ts +12 -4
  8. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  9. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  10. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  11. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  12. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  13. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  14. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  15. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  16. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  17. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  18. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  19. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  20. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  21. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  22. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  23. package/src/features/automations/action-executors/index.ts +33 -0
  24. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  25. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  26. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  27. package/src/features/automations/automation-runner.ts +81 -0
  28. package/src/features/automations/automation-runner.utils.ts +43 -0
  29. package/src/features/automations/automations.handler.ts +193 -0
  30. package/src/features/automations/automations.schema.ts +160 -0
  31. package/src/features/automations/context-resolver.ts +148 -0
  32. package/src/features/automations/cron-runner.ts +136 -0
  33. package/src/features/automations/cron-runner.utils.ts +40 -0
  34. package/src/features/automations/filter-translation.ts +42 -0
  35. package/src/features/automations/index.ts +12 -0
  36. package/src/features/automations/template-grammar.ts +241 -0
  37. package/src/features/automations/var-access-resolver.ts +136 -0
  38. package/src/features/automations/when-evaluator.ts +83 -0
  39. package/src/features/automations/when-pushdown.ts +53 -0
  40. package/src/features/content/handlers/create.ts +8 -0
  41. package/src/features/content/handlers/delete.ts +8 -1
  42. package/src/features/content/handlers/update.ts +8 -0
  43. package/src/features/draft/draft.handler.ts +51 -164
  44. package/src/features/draft/draft.middleware.ts +62 -0
  45. package/src/features/email/email.service.ts +13 -0
  46. package/src/features/email/email.types.ts +10 -0
  47. package/src/features/email/index.ts +2 -1
  48. package/src/features/email/templates/automation-mail.ts +15 -0
  49. package/src/features/settings/settings.handler.ts +2 -1
  50. package/src/index.ts +40 -8
  51. package/src/middleware/repository.middleware.ts +32 -1
  52. package/src/public/cache-utils.ts +34 -0
  53. package/src/public/entry-projection.ts +42 -0
  54. package/src/public/idempotency.ts +19 -0
  55. package/src/public/problem-details.ts +5 -0
  56. package/src/public/public-add.ts +17 -63
  57. package/src/public/public-read.ts +20 -177
  58. package/src/public/read-list.ts +50 -0
  59. package/src/public/read-single.ts +44 -0
  60. package/src/shared/automations.repository.d1.ts +146 -0
  61. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  62. package/src/shared/execution-context-scheduler.ts +9 -0
  63. package/src/shared/storage-utils.ts +3 -3
  64. package/src/types.ts +5 -1
  65. package/src/upload.ts +3 -2
  66. package/assets/dashboard/assets/index-CH13idU1.js +0 -554
  67. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
@@ -1,119 +1,34 @@
1
- import { resolvePolicies, EntryNotFoundError } from '@beechcms/core'
2
- import type { Seed, ISeedRegistry } from '@beechcms/core'
3
1
  import type { Context } from 'hono'
4
2
  import { cleanStr } from '../shared/query-utils'
5
3
  import { checkPublicOperation } from './access-policy'
6
- import { publicProblem } from './problem-details'
7
- import { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
8
- import {
9
- parseLatestCount,
10
- parsePublicFilter,
11
- parsePublicPagination,
12
- toEngineFilters,
13
- } from './query-builder'
4
+ import { publicProblem, internalErrorDetail } from './problem-details'
5
+ import { resolveEdgeCache, withCachedResponse } from './cache-utils'
6
+ import { readSingleEntry } from './read-single'
7
+ import { readListEntries } from './read-list'
14
8
  import { AppEnv } from '../types'
15
9
 
16
- function buildSeedNotFoundMessage(seed: string, seedRegistry: ISeedRegistry): string {
17
- const available = seedRegistry.all().map(s => s.slug).join(', ')
18
- return `The content type '${seed}' does not exist. Available types: ${available}.`
19
- }
20
-
21
- /** Applies public/visibility policies for the Public API. */
22
- function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
23
- const result: Record<string, unknown> = {}
24
-
25
- // System fields are mapped to top-level for public API
26
- const system = ['id', 'slug', 'status', 'created_at', 'updated_at']
27
- for (const key of system) {
28
- if (key in data) result[key] = data[key]
29
- }
30
-
31
- for (const branch of seed.branches) {
32
- const value = data[branch.alias]
33
- const { public: isPublic, visibility } = resolvePolicies(branch)
34
- if (!isPublic) continue
35
- if (visibility === 'hidden') continue
36
- if (visibility === 'masked') {
37
- result[branch.alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
38
- } else {
39
- result[branch.alias] = value
40
- }
41
- }
42
- return result
43
- }
44
-
45
- function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
46
- const aliasData = applyPublicPolicies(data, seed)
47
- const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
48
-
49
- if (requestedFields.length === 0) return aliasData
50
-
51
- const filteredData: Record<string, unknown> = {}
52
- for (const field of requestedFields) {
53
- if (field in aliasData) filteredData[field] = aliasData[field]
54
- }
55
-
56
- // Always include basic identity fields if they exist in filtered data or if requested
57
- const identity = ['id', 'slug']
58
- for (const key of identity) {
59
- if (key in aliasData && !filteredData[key]) filteredData[key] = aliasData[key]
60
- }
61
-
62
- return filteredData
63
- }
64
-
65
- function withCache(
66
- cache: Cache | undefined,
67
- executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined,
68
- cacheKey: Request,
69
- response: Response
70
- ): Response {
71
- if (cache && executionCtx) {
72
- const cloned = response.clone()
73
- const headers = new Headers(cloned.headers)
74
- headers.set('Cache-Control', 'public, max-age=60')
75
- executionCtx.waitUntil(cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers })))
76
- }
77
- return response
78
- }
79
-
80
- function buildInternalErrorMessage(context: Context<AppEnv>, error: unknown): string {
81
- if (context.env.ENV !== 'production' && error instanceof Error) return error.message
82
- return 'An unexpected error occurred.'
83
- }
84
-
85
10
  export async function publicReadHandler(context: Context<AppEnv>) {
86
11
  const seedSlug = context.req.param('seed') ?? ''
87
12
  const seed = context.get('getSeed')(seedSlug)
88
13
  if (!seed) {
14
+ const available = context.get('seedRegistry').all().map(s => s.slug).join(', ')
89
15
  return publicProblem(context, {
90
- type: 'seed-not-found',
91
- title: 'Seed Not Found',
16
+ type: 'seed-not-found',
17
+ title: 'Seed Not Found',
92
18
  status: 404,
93
- detail: buildSeedNotFoundMessage(seedSlug, context.get('seedRegistry')),
19
+ detail: `The content type '${seedSlug}' does not exist. Available types: ${available}.`,
94
20
  })
95
21
  }
96
-
22
+
97
23
  const access = checkPublicOperation(seed, 'read')
98
24
  if (!access.ok) {
99
- return publicProblem(context, {
100
- type: 'operation-not-allowed',
101
- title: access.error.error,
102
- status: 403,
103
- detail: access.error.message
104
- })
25
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
105
26
  }
106
27
 
107
- let cache: Cache | undefined
108
- let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
109
- try {
110
- cache = caches.default
111
- executionCtx = context.executionCtx as typeof executionCtx
112
- } catch {}
113
-
28
+ const edgeCache = resolveEdgeCache(context)
114
29
  const cacheKey = context.req.raw
115
- if (cache) {
116
- const hit = await cache.match(cacheKey)
30
+ if (edgeCache) {
31
+ const hit = await edgeCache.cache.match(cacheKey)
117
32
  if (hit) return hit
118
33
  }
119
34
 
@@ -125,92 +40,20 @@ export async function publicReadHandler(context: Context<AppEnv>) {
125
40
 
126
41
  try {
127
42
  if (id || slug) {
128
- try {
129
- const entry = id
130
- ? await repository.findById(seed, id)
131
- : await repository.findBySlug(seed, slug!)
132
-
133
- if (publishedOnly && entry.status !== 'published') {
134
- return publicProblem(context, {
135
- type: 'entry-not-found',
136
- title: 'Not Found',
137
- status: 404,
138
- detail: `Entry '${id || slug}' not found or not published.`
139
- })
140
- }
141
-
142
- return withCache(cache, executionCtx, cacheKey,
143
- context.json({
144
- data: toFlatPublicEntry(entry, seed, query.fields),
145
- meta: buildPublicSingleMeta(seedSlug)
146
- }, 200)
147
- )
148
- } catch (error) {
149
- if (error instanceof EntryNotFoundError) {
150
- return publicProblem(context, {
151
- type: 'entry-not-found',
152
- title: 'Not Found',
153
- status: 404,
154
- detail: `Entry '${id || slug}' not found for content type '${seedSlug}'.`
155
- })
156
- }
157
- throw error
43
+ const result = await readSingleEntry({ seed, seedSlug, repository, id, slug, publishedOnly, fieldsParam: query.fields })
44
+ if (!result.ok) {
45
+ return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: result.detail })
158
46
  }
47
+ return withCachedResponse(edgeCache, cacheKey, context.json({ data: result.data, meta: result.meta }, 200))
159
48
  }
160
49
 
161
- const parsedFilter = parsePublicFilter(query.filter)
162
- const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
163
- const latestMode = cleanStr(query.latest) !== null
164
- const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
165
- const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
166
- const offset = (pagination.page - 1) * pagination.limit
167
- const search = cleanStr(query.search) ?? ''
168
-
169
- const engineFilters = toEngineFilters(seed, parsedFilter)
170
- const sortBy = cleanStr(query.orderBy) ?? 'created_at'
171
- const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
172
-
173
- const { items, total } = await repository.findMany(seed, {
174
- filters: engineFilters,
175
- search: search || undefined,
176
- status: publishedOnly ? 'published' : null,
177
- pagination: {
178
- limit: latestMode ? (latestCount ?? 10) : pagination.limit,
179
- offset: latestMode ? 0 : offset
180
- },
181
- orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir }
182
- })
183
-
184
- const data = items.map((item) => toFlatPublicEntry(item, seed, query.fields))
185
-
186
- if (latestMode) {
187
- return withCache(cache, executionCtx, cacheKey,
188
- context.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
189
- )
190
- }
191
-
192
- return withCache(cache, executionCtx, cacheKey,
193
- context.json({
194
- data,
195
- meta: buildPublicListMeta({
196
- total,
197
- page: pagination.page,
198
- limit: latestMode ? (latestCount ?? 10) : pagination.limit,
199
- returned: data.length,
200
- seed: seedSlug
201
- }),
202
- }, 200)
203
- )
50
+ const result = await readListEntries({ seed, seedSlug, repository, query, publishedOnly })
51
+ return withCachedResponse(edgeCache, cacheKey, context.json(result, 200))
204
52
  } catch (error) {
205
53
  if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
206
54
  return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
207
55
  }
208
56
  console.error('Public read error:', error)
209
- return publicProblem(context, {
210
- type: 'internal-server-error',
211
- title: 'Internal Server Error',
212
- status: 500,
213
- detail: buildInternalErrorMessage(context, error)
214
- })
57
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
215
58
  }
216
59
  }
@@ -0,0 +1,50 @@
1
+ import type { Seed, ContentRepository } from '@beechcms/core'
2
+ import { cleanStr } from '../shared/query-utils'
3
+ import { toFlatPublicEntry } from './entry-projection'
4
+ import { buildPublicListMeta } from './response-builder'
5
+ import { parsePublicFilter, parsePublicPagination, parseLatestCount, toEngineFilters } from './query-builder'
6
+
7
+ type ReadListInput = {
8
+ seed: Seed
9
+ seedSlug: string
10
+ repository: ContentRepository
11
+ query: Record<string, string | undefined>
12
+ publishedOnly: boolean
13
+ }
14
+
15
+ export async function readListEntries(input: ReadListInput) {
16
+ const { seed, seedSlug, repository, query, publishedOnly } = input
17
+
18
+ const parsedFilter = parsePublicFilter(query.filter)
19
+ const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
20
+ const latestMode = cleanStr(query.latest) !== null
21
+ const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
22
+ const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
23
+ const offset = (pagination.page - 1) * pagination.limit
24
+ const search = cleanStr(query.search) ?? ''
25
+ const engineFilters = toEngineFilters(seed, parsedFilter)
26
+ const sortBy = cleanStr(query.orderBy) ?? 'created_at'
27
+ const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
28
+
29
+ const { items, total } = await repository.findMany(seed, {
30
+ filters: engineFilters,
31
+ search: search || undefined,
32
+ status: publishedOnly ? 'published' : null,
33
+ pagination: {
34
+ limit: latestMode ? (latestCount ?? 10) : pagination.limit,
35
+ offset: latestMode ? 0 : offset,
36
+ },
37
+ orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir },
38
+ })
39
+
40
+ const data = items.map(item => toFlatPublicEntry(item, seed, query.fields))
41
+
42
+ if (latestMode) {
43
+ return { data, meta: { total, returned: data.length, seed: seedSlug } }
44
+ }
45
+
46
+ return {
47
+ data,
48
+ meta: buildPublicListMeta({ total, page: pagination.page, limit: pagination.limit, returned: data.length, seed: seedSlug }),
49
+ }
50
+ }
@@ -0,0 +1,44 @@
1
+ import { EntryNotFoundError } from '@beechcms/core'
2
+ import type { Seed, ContentRepository } from '@beechcms/core'
3
+ import { toFlatPublicEntry } from './entry-projection'
4
+ import { buildPublicSingleMeta } from './response-builder'
5
+
6
+ type ReadSingleInput = {
7
+ seed: Seed
8
+ seedSlug: string
9
+ repository: ContentRepository
10
+ id: string | null
11
+ slug: string | null
12
+ publishedOnly: boolean
13
+ fieldsParam?: string
14
+ }
15
+
16
+ export type ReadSingleResult =
17
+ | { ok: true; data: Record<string, unknown>; meta: { seed: string } }
18
+ | { ok: false; detail: string }
19
+
20
+ export async function readSingleEntry(input: ReadSingleInput): Promise<ReadSingleResult> {
21
+ const { seed, seedSlug, repository, id, slug, publishedOnly, fieldsParam } = input
22
+ const label = id ?? slug!
23
+
24
+ try {
25
+ const entry = id
26
+ ? await repository.findById(seed, id)
27
+ : await repository.findBySlug(seed, slug!)
28
+
29
+ if (publishedOnly && entry.status !== 'published') {
30
+ return { ok: false, detail: `Entry '${label}' not found or not published.` }
31
+ }
32
+
33
+ return {
34
+ ok: true,
35
+ data: toFlatPublicEntry(entry, seed, fieldsParam),
36
+ meta: buildPublicSingleMeta(seedSlug),
37
+ }
38
+ } catch (error) {
39
+ if (error instanceof EntryNotFoundError) {
40
+ return { ok: false, detail: `Entry '${label}' not found for content type '${seedSlug}'.` }
41
+ }
42
+ throw error
43
+ }
44
+ }
@@ -0,0 +1,146 @@
1
+ import type {
2
+ IAutomationRepository,
3
+ Automation,
4
+ AutomationAction,
5
+ AutomationTrigger,
6
+ AutomationTriggerEvent,
7
+ CreateAutomationInput,
8
+ UpdateAutomationInput,
9
+ WhenNode,
10
+ } from '@beechcms/core'
11
+
12
+ interface AutomationRow {
13
+ id: string
14
+ seed_slug: string
15
+ name: string
16
+ enabled: number
17
+ triggers: string
18
+ trigger_conditions: string | null
19
+ actions: string
20
+ created_at: number
21
+ updated_at: number
22
+ }
23
+
24
+ export class D1AutomationRepository implements IAutomationRepository {
25
+ constructor(private readonly db: D1Database) {}
26
+
27
+ async findActive(seedSlug: string, event: AutomationTriggerEvent): Promise<Automation[]> {
28
+ const result = seedSlug === '*'
29
+ ? await this.db
30
+ .prepare(
31
+ `SELECT DISTINCT a.* FROM automations a, json_each(a.triggers) t
32
+ WHERE a.enabled = 1 AND json_extract(t.value, '$.event') = ?`,
33
+ )
34
+ .bind(event)
35
+ .all<AutomationRow>()
36
+ : await this.db
37
+ .prepare(
38
+ `SELECT DISTINCT a.* FROM automations a, json_each(a.triggers) t
39
+ WHERE a.seed_slug = ? AND a.enabled = 1 AND json_extract(t.value, '$.event') = ?`,
40
+ )
41
+ .bind(seedSlug, event)
42
+ .all<AutomationRow>()
43
+ return (result.results ?? []).map(rowToAutomation)
44
+ }
45
+
46
+ async list(seedSlug: string): Promise<Automation[]> {
47
+ const result = await this.db
48
+ .prepare(`SELECT * FROM automations WHERE seed_slug = ? ORDER BY created_at DESC`)
49
+ .bind(seedSlug)
50
+ .all<AutomationRow>()
51
+ return (result.results ?? []).map(rowToAutomation)
52
+ }
53
+
54
+ async findById(id: string): Promise<Automation | null> {
55
+ const row = await this.db
56
+ .prepare(`SELECT * FROM automations WHERE id = ?`)
57
+ .bind(id)
58
+ .first<AutomationRow>()
59
+ return row ? rowToAutomation(row) : null
60
+ }
61
+
62
+ async create(input: CreateAutomationInput): Promise<string> {
63
+ const id = crypto.randomUUID()
64
+ const now = Math.floor(Date.now() / 1000)
65
+ await this.db
66
+ .prepare(
67
+ `INSERT INTO automations
68
+ (id, seed_slug, name, enabled, triggers, trigger_conditions, actions, created_at, updated_at)
69
+ VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?)`,
70
+ )
71
+ .bind(
72
+ id,
73
+ input.seed_slug,
74
+ input.name,
75
+ JSON.stringify(input.triggers),
76
+ input.trigger_conditions ? JSON.stringify(input.trigger_conditions) : null,
77
+ JSON.stringify(input.actions),
78
+ now,
79
+ now,
80
+ )
81
+ .run()
82
+ return id
83
+ }
84
+
85
+ async update(id: string, input: UpdateAutomationInput): Promise<void> {
86
+ const now = Math.floor(Date.now() / 1000)
87
+ const fields: string[] = []
88
+ const values: unknown[] = []
89
+
90
+ const map: Record<string, unknown> = {
91
+ seed_slug: input.seed_slug,
92
+ name: input.name,
93
+ triggers: input.triggers !== undefined ? JSON.stringify(input.triggers) : undefined,
94
+ trigger_conditions:
95
+ input.trigger_conditions !== undefined
96
+ ? input.trigger_conditions === null
97
+ ? null
98
+ : JSON.stringify(input.trigger_conditions)
99
+ : undefined,
100
+ actions: input.actions !== undefined ? JSON.stringify(input.actions) : undefined,
101
+ }
102
+
103
+ for (const [column, value] of Object.entries(map)) {
104
+ if (value !== undefined) {
105
+ fields.push(`${column} = ?`)
106
+ values.push(value)
107
+ }
108
+ }
109
+
110
+ if (fields.length === 0) return
111
+
112
+ values.push(now, id)
113
+ await this.db
114
+ .prepare(`UPDATE automations SET ${fields.join(', ')}, updated_at = ? WHERE id = ?`)
115
+ .bind(...values)
116
+ .run()
117
+ }
118
+
119
+ async toggle(id: string, enabled: boolean): Promise<void> {
120
+ const now = Math.floor(Date.now() / 1000)
121
+ await this.db
122
+ .prepare(`UPDATE automations SET enabled = ?, updated_at = ? WHERE id = ?`)
123
+ .bind(enabled ? 1 : 0, now, id)
124
+ .run()
125
+ }
126
+
127
+ async delete(id: string): Promise<void> {
128
+ await this.db.prepare(`DELETE FROM automations WHERE id = ?`).bind(id).run()
129
+ }
130
+ }
131
+
132
+ function rowToAutomation(row: AutomationRow): Automation {
133
+ return {
134
+ id: row.id,
135
+ seed_slug: row.seed_slug,
136
+ name: row.name,
137
+ enabled: row.enabled === 1,
138
+ triggers: JSON.parse(row.triggers) as AutomationTrigger[],
139
+ trigger_conditions: row.trigger_conditions
140
+ ? (JSON.parse(row.trigger_conditions) as WhenNode)
141
+ : null,
142
+ actions: JSON.parse(row.actions) as AutomationAction[],
143
+ created_at: row.created_at,
144
+ updated_at: row.updated_at,
145
+ }
146
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { Seed } from '@beechcms/core'
3
+ import { D1ContentScanRepository } from './d1-content-scan.repository'
4
+
5
+ function makeMockDb(allResults: unknown[] = []) {
6
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
7
+ const prepareMock = vi.fn(() => ({ all: allMock }))
8
+ return { db: { prepare: prepareMock } as any, prepareMock, allMock }
9
+ }
10
+
11
+ describe('D1ContentScanRepository', () => {
12
+ it('returns empty set if seeds list is empty', async () => {
13
+ const { db, prepareMock } = makeMockDb()
14
+ const repo = new D1ContentScanRepository(db)
15
+ const result = await repo.getReferencedMediaKeys([])
16
+ expect(result.size).toBe(0)
17
+ expect(prepareMock).not.toHaveBeenCalled()
18
+ })
19
+
20
+ it('skips seeds with no file branches', async () => {
21
+ const { db, prepareMock } = makeMockDb()
22
+ const repo = new D1ContentScanRepository(db)
23
+ const seedWithoutFile: Seed = {
24
+ slug: 'posts',
25
+ label: 'Post',
26
+ displayNameAlias: 'title',
27
+ branches: [
28
+ { alias: 'title', type: 'text', label: 'Title' },
29
+ ],
30
+ }
31
+ const result = await repo.getReferencedMediaKeys([seedWithoutFile])
32
+ expect(result.size).toBe(0)
33
+ expect(prepareMock).not.toHaveBeenCalled()
34
+ })
35
+
36
+ it('extracts referenced media keys from file columns', async () => {
37
+ const mockResults = [
38
+ { cover: '/api/media/image1.jpg', gallery: '/api/media/img2.png /api/media/img3%20spaced.jpg' },
39
+ { cover: null, gallery: 'no-media-here' },
40
+ ]
41
+ const { db, prepareMock } = makeMockDb(mockResults)
42
+ const repo = new D1ContentScanRepository(db)
43
+ const seedWithFiles: Seed = {
44
+ slug: 'articles',
45
+ label: 'Article',
46
+ displayNameAlias: 'title',
47
+ branches: [
48
+ { alias: 'title', type: 'text', label: 'Title' },
49
+ { alias: 'cover', type: 'file', label: 'Cover' },
50
+ { alias: 'gallery', type: 'file', label: 'Gallery' },
51
+ ],
52
+ }
53
+
54
+ const result = await repo.getReferencedMediaKeys([seedWithFiles])
55
+ expect(prepareMock).toHaveBeenCalledWith('SELECT cover, gallery FROM content_articles')
56
+ expect(result).toEqual(new Set(['image1.jpg', 'img2.png', 'img3 spaced.jpg']))
57
+ })
58
+
59
+ it('handles null or missing results gracefully', async () => {
60
+ const allMock = vi.fn().mockResolvedValue({ results: null })
61
+ const prepareMock = vi.fn(() => ({ all: allMock }))
62
+ const db = { prepare: prepareMock } as any
63
+ const repo = new D1ContentScanRepository(db)
64
+ const seedWithFiles: Seed = {
65
+ slug: 'articles',
66
+ label: 'Article',
67
+ displayNameAlias: 'title',
68
+ branches: [
69
+ { alias: 'cover', type: 'file', label: 'Cover' },
70
+ ],
71
+ }
72
+
73
+ const result = await repo.getReferencedMediaKeys([seedWithFiles])
74
+ expect(result.size).toBe(0)
75
+ })
76
+ })
@@ -0,0 +1,9 @@
1
+ import type { IScheduler } from '@beechcms/core'
2
+
3
+ export class ExecutionContextScheduler implements IScheduler {
4
+ constructor(private readonly ctx: ExecutionContext) {}
5
+
6
+ waitUntil(promise: Promise<unknown>): void {
7
+ this.ctx.waitUntil(promise)
8
+ }
9
+ }
@@ -12,13 +12,13 @@ export async function getBucketSize(client: S3Client, bucketName: string): Promi
12
12
 
13
13
  try {
14
14
  while (isTruncatedFlag) {
15
- const command = new ListObjectsV2Command({
15
+ const command: ListObjectsV2Command = new ListObjectsV2Command({
16
16
  Bucket: bucketName,
17
17
  ContinuationToken: continuationToken,
18
18
  })
19
19
 
20
- const response = (await client.send(command)) as ListObjectsV2CommandOutput
21
-
20
+ const response: ListObjectsV2CommandOutput = await client.send(command)
21
+
22
22
  if (response.Contents) {
23
23
  for (const obj of response.Contents) {
24
24
  totalSize += obj.Size ?? 0
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository, IHashProvider, ITokenService, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogger, IActivityLogRepository, INotificationRepository, INotificationService, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, ISeedRegistry, IClock, IIdGenerator } from '@beechcms/core'
2
+ import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository, IHashProvider, ITokenService, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogger, IActivityLogRepository, INotificationRepository, INotificationService, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, ISeedRegistry, IClock, IIdGenerator, IAutomationRunner, IAutomationRepository, IScheduler } from '@beechcms/core'
3
3
  import type { IRateLimiterRegistry } from './middleware/rate-limit.middleware'
4
4
 
5
5
  export interface Env {
@@ -23,6 +23,7 @@ export interface Env {
23
23
  MEDIA_BASE_URL?: string
24
24
  MEDIA_CDN_URL?: string
25
25
  RESEND_API_KEY?: string
26
+ EMAIL_API_KEY?: string
26
27
  APP_URL?: string
27
28
  EMAIL_FROM?: string
28
29
  FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
@@ -58,6 +59,9 @@ export interface Variables {
58
59
  contentScanRepository: IContentScanRepository
59
60
  clock: IClock
60
61
  idGenerator: IIdGenerator
62
+ automationRepository: IAutomationRepository
63
+ automationRunner: IAutomationRunner
64
+ scheduler: IScheduler
61
65
  }
62
66
 
63
67
  export type AppEnv = { Bindings: Env; Variables: Variables }
package/src/upload.ts CHANGED
@@ -158,7 +158,7 @@ uploadRoutes.post('/upload', async (c) => {
158
158
  uploadRoutes.delete('/upload/:key', async (c) => {
159
159
  const key = c.req.param('key')
160
160
  if (!key) return c.json({ error: 'Missing key' }, 400)
161
-
161
+
162
162
  await deleteR2Objects(c, [decodeURIComponent(key)])
163
163
  return c.json({ success: true }, 200)
164
164
  })
@@ -178,9 +178,10 @@ export async function serveMediaHandler(c: any): Promise<Response> {
178
178
  const headers = new Headers()
179
179
  headers.set('Content-Type', object.contentType ?? 'application/octet-stream')
180
180
  headers.set('Cache-Control', 'public, max-age=31536000, immutable')
181
-
181
+
182
182
  return new Response(object.body, { status: 200, headers })
183
183
  } catch (err) {
184
+ console.error(`[serveMediaHandler] Error serving file ${key}:`, err)
184
185
  return new Response('Internal error', { status: 500 })
185
186
  }
186
187
  }