@beechcms/api 0.4.0-preview.1 → 0.4.0-preview.11

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 (98) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -0
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -0
  3. package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
  63. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  64. package/assets/dashboard/beechLogoDark.svg +48 -0
  65. package/assets/dashboard/index.html +18 -0
  66. package/assets/dashboard/sol.svg +3 -0
  67. package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
  68. package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
  69. package/assets/dashboard/vite.svg +1 -0
  70. package/package.json +4 -3
  71. package/src/factory.ts +290 -21
  72. package/src/features/content/constants.ts +10 -0
  73. package/src/features/content/handlers/create.ts +163 -0
  74. package/src/features/content/handlers/delete.ts +85 -0
  75. package/src/features/content/handlers/facets.ts +45 -0
  76. package/src/features/content/handlers/get.ts +116 -0
  77. package/src/features/content/handlers/list.ts +88 -0
  78. package/src/features/content/handlers/update.ts +216 -0
  79. package/src/features/content/index.ts +20 -0
  80. package/src/features/draft/draft.handler.ts +203 -129
  81. package/src/features/password-reset/request.ts +1 -1
  82. package/src/features/schema/schema.handler.ts +16 -0
  83. package/src/features/settings/settings.handler.ts +18 -0
  84. package/src/index.ts +17 -337
  85. package/src/middleware/repository.middleware.ts +18 -0
  86. package/src/public/public-add.ts +72 -89
  87. package/src/public/public-edit.ts +51 -76
  88. package/src/public/public-read.ts +113 -114
  89. package/src/public/query-builder.ts +47 -136
  90. package/src/search-utils.ts +5 -1
  91. package/src/shared/base.repository.d1.ts +28 -0
  92. package/src/shared/content.repository.d1.ts +382 -0
  93. package/src/shared/idempotency.repository.d1.ts +45 -0
  94. package/src/types.ts +7 -1
  95. package/src/upload.ts +3 -7
  96. package/src/content.ts +0 -502
  97. package/src/features/draft/draft.test.ts +0 -315
  98. package/src/features/rotate-field/rotate-field.test.ts +0 -297
@@ -1,33 +1,20 @@
1
- import { isValidContentStatus, resolvePolicies } from '@beechcms/core'
1
+ import { isValidContentStatus, resolvePolicies, EntryNotFoundError } from '@beechcms/core'
2
2
  import type { Seed } from '@beechcms/core'
3
3
  import type { Context } from 'hono'
4
4
  import { cleanStr } from '../shared/query-utils'
5
- import { rowToApiData, buildUpdateBindings } from '../shared/content-utils'
6
5
  import { checkPublicOperation } from './access-policy'
7
6
  import { publicProblem } from './problem-details'
8
7
  import { slugify } from './slug-utils'
9
8
  import { sanitizePublicPayload } from './sanitize'
10
9
  import { createNotification } from '../shared/notification-service'
11
-
12
- type Bindings = {
13
- DB: D1Database
14
- PUBLIC_READ_API_KEY?: string
15
- PUBLIC_WRITE_API_KEY?: string
16
- ENV?: string
17
- }
18
-
19
- type Variables = {
20
- jwtPayload: { sub: string; email?: string }
21
- getSeed: (slug: string) => Seed | null
22
- seedRegistry: Record<string, Seed>
23
- }
10
+ import { AppEnv } from '../types'
24
11
 
25
12
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
26
- type PublicCtx = Context<{ Bindings: Bindings; Variables: Variables }>
13
+ type PublicCtx = Context<AppEnv>
27
14
  type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
28
15
 
29
- function errorMessage(c: PublicCtx, err: unknown): string {
30
- if (c.env.ENV !== 'production' && err instanceof Error) return err.message
16
+ function errorMessage(context: PublicCtx, error: unknown): string {
17
+ if (context.env.ENV !== 'production' && error instanceof Error) return error.message
31
18
  return 'An unexpected error occurred.'
32
19
  }
33
20
 
@@ -45,48 +32,47 @@ function removeNullishFields(data: Record<string, unknown>): Record<string, unkn
45
32
  return next
46
33
  }
47
34
 
48
- function parseBody(c: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
49
- return c.req.json<unknown>()
35
+ function parseBody(context: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
36
+ return context.req.json<unknown>()
50
37
  .then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
51
38
  .catch(() => ({
52
39
  ok: false,
53
- response: publicProblem(c, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
40
+ response: publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
54
41
  }))
55
42
  }
56
43
 
57
- function resolveSlug(c: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
44
+ function resolveSlug(context: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
58
45
  const slugRequested = Object.hasOwn(body, 'slug')
59
46
  if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
60
47
  const requestedSlug = cleanStr(body.slug)
61
48
  if (!requestedSlug) {
62
- return { ok: false, response: publicProblem(c, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
49
+ return { ok: false, response: publicProblem(context, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
63
50
  }
64
51
  return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
65
52
  }
66
53
 
67
- function resolveStatus(c: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
54
+ function resolveStatus(context: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
68
55
  if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
69
56
  const statusValue = body.status
70
57
  if (!isValidContentStatus(statusValue)) {
71
- return { ok: false, response: publicProblem(c, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
58
+ return { ok: false, response: publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
72
59
  }
73
- return { ok: true, value: statusValue }
60
+ return { ok: true, value: statusValue as string }
74
61
  }
75
62
 
76
63
  function resolveData(
77
- c: PublicCtx,
64
+ context: PublicCtx,
78
65
  seed: Seed,
79
- body: Record<string, unknown>,
80
- currentRow: Record<string, unknown>
66
+ body: Record<string, unknown>
81
67
  ): ResolveResult<Record<string, unknown>> {
82
68
  if (!Object.hasOwn(body, 'data')) {
83
- // No data update — return empty patch (caller keeps existing columns)
69
+ // No data update — return empty patch
84
70
  return { ok: true, value: {} }
85
71
  }
86
72
 
87
73
  const rawData = asRecord(body.data)
88
74
  if (!rawData) {
89
- return { ok: false, response: publicProblem(c, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
75
+ return { ok: false, response: publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
90
76
  }
91
77
 
92
78
  const sensitiveAliases = Object.keys(rawData).filter((alias) => {
@@ -94,90 +80,79 @@ function resolveData(
94
80
  return branch != null && resolvePolicies(branch).privacy !== 'plain'
95
81
  })
96
82
  if (sensitiveAliases.length > 0) {
97
- return { ok: false, response: publicProblem(c, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
83
+ return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
98
84
  }
99
85
 
100
86
  const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
101
87
  if (!sanitized.ok) {
102
88
  if (sanitized.status === 422) {
103
- return { ok: false, response: publicProblem(c, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
89
+ return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
104
90
  }
105
- return { ok: false, response: publicProblem(c, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
91
+ return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
106
92
  }
107
93
 
108
- // Merge: current columns + patch, remove nulls
109
- const currentAliasData = rowToApiData(seed, currentRow)
110
- const merged = removeNullishFields({ ...currentAliasData, ...sanitized.data })
111
- return { ok: true, value: merged }
94
+ return { ok: true, value: removeNullishFields(sanitized.data) }
112
95
  }
113
96
 
114
- export async function publicEditHandler(c: PublicCtx) {
115
- const seedSlug = c.req.param('seed') ?? ''
116
- const id = c.req.param('id') ?? ''
117
- const seed = c.get('getSeed')(seedSlug)
97
+ export async function publicEditHandler(context: PublicCtx) {
98
+ const seedSlug = context.req.param('seed') ?? ''
99
+ const id = context.req.param('id') ?? ''
100
+ const seed = context.get('getSeed')(seedSlug)
118
101
  if (!seed) {
119
- return publicProblem(c, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
102
+ return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
120
103
  }
121
104
  const access = checkPublicOperation(seed, 'edit')
122
105
  if (!access.ok) {
123
- return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
106
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
124
107
  }
125
108
 
126
109
  if (!UUID_REGEX.test(id)) {
127
- return publicProblem(c, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
110
+ return publicProblem(context, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
128
111
  }
129
112
 
130
- const table = `content_${seedSlug}`
113
+ const repository = context.get('repository')
131
114
 
132
115
  try {
133
- const { DB } = c.env
134
- const currentRow = await DB.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
135
- .bind(id)
136
- .first<Record<string, unknown>>()
137
-
138
- if (!currentRow) {
139
- return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
140
- }
116
+ const entry = await repository.findById(seed, id)
141
117
 
142
- const bodyResult = await parseBody(c)
118
+ const bodyResult = await parseBody(context)
143
119
  if (!bodyResult.ok) return bodyResult.response
144
120
 
145
- const slugResult = resolveSlug(c, bodyResult.value, (currentRow.slug as string) ?? '')
121
+ const slugResult = resolveSlug(context, bodyResult.value, (entry.slug as string) ?? '')
146
122
  if (!slugResult.ok) return slugResult.response
147
123
 
148
- const statusResult = resolveStatus(c, bodyResult.value, (currentRow.status as string) ?? 'draft')
124
+ const statusResult = resolveStatus(context, bodyResult.value, (entry.status as string) ?? 'draft')
149
125
  if (!statusResult.ok) return statusResult.response
150
126
 
151
- const dataResult = resolveData(c, seed, bodyResult.value, currentRow)
127
+ const dataResult = resolveData(context, seed, bodyResult.value)
152
128
  if (!dataResult.ok) return dataResult.response
153
129
 
154
- if (slugResult.value.slugRequested) {
155
- const slugExisting = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? AND id != ? LIMIT 1`)
156
- .bind(slugResult.value.nextSlug, id)
157
- .first<{ id: string }>()
158
-
159
- if (slugExisting) {
160
- return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
130
+ if (slugResult.value.slugRequested && slugResult.value.nextSlug !== entry.slug) {
131
+ const exists = await repository.existsSlug(seed, slugResult.value.nextSlug, id)
132
+ if (exists) {
133
+ return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
161
134
  }
162
135
  }
163
136
 
164
- const now = Math.floor(Date.now() / 1000)
165
- const { setClause, bindings } = buildUpdateBindings(seed, dataResult.value)
166
- const systemSet = `slug = ?, status = ?, updated_at = ?`
167
- const fullSet = setClause ? `${systemSet}, ${setClause}` : systemSet
137
+ const updateData = { ...dataResult.value }
138
+ if (slugResult.value.slugRequested) {
139
+ (updateData as any).slug = slugResult.value.nextSlug
140
+ }
168
141
 
169
- await DB.prepare(`UPDATE ${table} SET ${fullSet} WHERE id = ?`)
170
- .bind(slugResult.value.nextSlug, statusResult.value, now, ...bindings, id)
171
- .run()
142
+ await repository.update(seed, id, updateData, statusResult.value)
172
143
 
173
- await createNotification(c, {
144
+ await createNotification(context, {
174
145
  title: `${seed.label}: Modifica`,
175
146
  message: `L'entry "${slugResult.value.nextSlug}" è stata modificata via API pubblica.`,
176
147
  type: 'info',
177
148
  })
178
149
 
179
- return c.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
180
- } catch (err) {
181
- return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(c, err) })
150
+ return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
151
+ } catch (error) {
152
+ if (error instanceof EntryNotFoundError) {
153
+ return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
154
+ }
155
+ console.error('Public edit error:', error)
156
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(context, error) })
182
157
  }
183
158
  }
@@ -1,31 +1,17 @@
1
- import { resolvePolicies } from '@beechcms/core'
1
+ import { resolvePolicies, EntryNotFoundError } from '@beechcms/core'
2
2
  import type { Seed } from '@beechcms/core'
3
3
  import type { Context } from 'hono'
4
4
  import { cleanStr } from '../shared/query-utils'
5
- import { rowToApiData } from '../shared/content-utils'
6
5
  import { checkPublicOperation } from './access-policy'
7
6
  import { publicProblem } from './problem-details'
8
7
  import { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
9
8
  import {
10
- buildPublicFilterWhereClause,
11
9
  parseLatestCount,
12
10
  parsePublicFilter,
13
11
  parsePublicPagination,
12
+ toEngineFilters,
14
13
  } from './query-builder'
15
-
16
- type Bindings = {
17
- DB: D1Database
18
- PUBLIC_READ_API_KEY?: string
19
- PUBLIC_WRITE_API_KEY?: string
20
- PUBLIC_PUBLISHED_ONLY?: string
21
- ENV?: string
22
- }
23
-
24
- type Variables = {
25
- jwtPayload: { sub: string; email?: string }
26
- getSeed: (slug: string) => Seed | null
27
- seedRegistry: Record<string, Seed>
28
- }
14
+ import { AppEnv } from '../types'
29
15
 
30
16
  function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
31
17
  const available = Object.keys(seedRegistry).join(', ')
@@ -33,10 +19,17 @@ function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, See
33
19
  }
34
20
 
35
21
  /** Applica policy public/visibility per la Public API. */
36
- function applyPublicPolicies(aliasData: Record<string, unknown>, seed: Seed): Record<string, unknown> {
22
+ function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
37
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
+
38
31
  for (const branch of seed.branches) {
39
- const value = aliasData[branch.alias]
32
+ const value = data[branch.alias]
40
33
  const { public: isPublic, visibility } = resolvePolicies(branch)
41
34
  if (!isPublic) continue
42
35
  if (visibility === 'hidden') continue
@@ -49,22 +42,24 @@ function applyPublicPolicies(aliasData: Record<string, unknown>, seed: Seed): Re
49
42
  return result
50
43
  }
51
44
 
52
- function toFlatPublicEntry(row: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
53
- const aliasData = applyPublicPolicies(rowToApiData(seed, row), seed)
54
- const base: Record<string, unknown> = {
55
- id: row.id,
56
- slug: row.slug,
57
- status: row.status,
58
- created_at: row.created_at,
59
- updated_at: row.updated_at,
60
- }
45
+ function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
46
+ const aliasData = applyPublicPolicies(data, seed)
61
47
  const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
62
- if (requestedFields.length === 0) return { ...base, ...aliasData }
48
+
49
+ if (requestedFields.length === 0) return aliasData
50
+
63
51
  const filteredData: Record<string, unknown> = {}
64
52
  for (const field of requestedFields) {
65
53
  if (field in aliasData) filteredData[field] = aliasData[field]
66
54
  }
67
- return { ...base, ...filteredData }
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
68
63
  }
69
64
 
70
65
  function withCache(
@@ -82,74 +77,85 @@ function withCache(
82
77
  return response
83
78
  }
84
79
 
85
- function buildInternalErrorMessage(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
86
- if (c.env.ENV !== 'production' && err instanceof Error) return err.message
80
+ function buildInternalErrorMessage(context: Context<AppEnv>, error: unknown): string {
81
+ if (context.env.ENV !== 'production' && error instanceof Error) return error.message
87
82
  return 'An unexpected error occurred.'
88
83
  }
89
84
 
90
- function buildOrderSql(seed: Seed, query: Record<string, string | undefined>, hasLatest: boolean): string {
91
- if (hasLatest) return 'ORDER BY created_at DESC'
92
- const orderBy = cleanStr(query.orderBy) ?? ''
93
- const orderDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase()
94
- if (orderBy === 'created_at' || orderBy === 'updated_at') {
95
- return `ORDER BY ${orderBy} ${orderDir === 'asc' ? 'ASC' : 'DESC'}`
96
- }
97
- // Branch column — always a real column in v0.4.0
98
- const branch = seed.branches.find((b) => b.alias === orderBy)
99
- if (branch) {
100
- const dir = orderDir === 'asc' ? 'ASC' : 'DESC'
101
- return `ORDER BY ${branch.alias} ${dir} NULLS LAST`
102
- }
103
- return 'ORDER BY created_at DESC'
104
- }
105
-
106
- export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
107
- const seedSlug = c.req.param('seed') ?? ''
108
- const seed = c.get('getSeed')(seedSlug)
85
+ export async function publicReadHandler(context: Context<AppEnv>) {
86
+ const seedSlug = context.req.param('seed') ?? ''
87
+ const seed = context.get('getSeed')(seedSlug)
109
88
  if (!seed) {
110
- return publicProblem(c, {
111
- type: 'seed-not-found', title: 'Seed Not Found', status: 404,
112
- detail: buildSeedNotFoundMessage(seedSlug, c.get('seedRegistry')),
89
+ return publicProblem(context, {
90
+ type: 'seed-not-found',
91
+ title: 'Seed Not Found',
92
+ status: 404,
93
+ detail: buildSeedNotFoundMessage(seedSlug, context.get('seedRegistry')),
113
94
  })
114
95
  }
96
+
115
97
  const access = checkPublicOperation(seed, 'read')
116
98
  if (!access.ok) {
117
- return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
99
+ return publicProblem(context, {
100
+ type: 'operation-not-allowed',
101
+ title: access.error.error,
102
+ status: 403,
103
+ detail: access.error.message
104
+ })
118
105
  }
119
106
 
120
107
  let cache: Cache | undefined
121
108
  let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
122
109
  try {
123
110
  cache = caches.default
124
- executionCtx = c.executionCtx as typeof executionCtx
111
+ executionCtx = context.executionCtx as typeof executionCtx
125
112
  } catch {}
126
113
 
127
- const cacheKey = c.req.raw
114
+ const cacheKey = context.req.raw
128
115
  if (cache) {
129
116
  const hit = await cache.match(cacheKey)
130
117
  if (hit) return hit
131
118
  }
132
119
 
133
- const query = c.req.query()
120
+ const query = context.req.query()
134
121
  const id = cleanStr(query.id)
135
- const table = `content_${seedSlug}`
136
- const publishedOnly = c.env.PUBLIC_PUBLISHED_ONLY !== 'false'
122
+ const slug = cleanStr(query.slug)
123
+ const publishedOnly = context.env.PUBLIC_PUBLISHED_ONLY !== 'false'
124
+ const repository = context.get('repository')
137
125
 
138
126
  try {
139
- const { DB } = c.env
140
-
141
- if (id) {
142
- const row = await DB.prepare(
143
- `SELECT * FROM ${table} WHERE id = ? ${publishedOnly ? "AND status = 'published'" : ''} LIMIT 1`
144
- ).bind(id).first<Record<string, unknown>>()
145
-
146
- if (!row) {
147
- return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
127
+ 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
148
158
  }
149
-
150
- return withCache(cache, executionCtx, cacheKey,
151
- c.json({ data: toFlatPublicEntry(row, seed, query.fields), meta: buildPublicSingleMeta(seedSlug) }, 200)
152
- )
153
159
  }
154
160
 
155
161
  const parsedFilter = parsePublicFilter(query.filter)
@@ -160,58 +166,51 @@ export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variabl
160
166
  const offset = (pagination.page - 1) * pagination.limit
161
167
  const search = cleanStr(query.search) ?? ''
162
168
 
163
- const whereParts: string[] = []
164
- const whereBindings: Array<string | number> = []
165
- if (publishedOnly) whereParts.push("status = 'published'")
166
-
167
- if (search) {
168
- // Search against slug and text/richtext columns
169
- const searchableBranches = seed.branches.filter((b) => ['text', 'richtext'].includes(b.type))
170
- const term = `%${search}%`
171
- const searchExprs = ['slug LIKE ?', ...searchableBranches.map((b) => `${b.alias} LIKE ?`)]
172
- whereParts.push(`(${searchExprs.join(' OR ')})`)
173
- whereBindings.push(term, ...searchableBranches.map(() => term))
174
- }
175
-
176
- const filterClause = buildPublicFilterWhereClause(seed, parsedFilter)
177
- if (filterClause.clause) {
178
- whereParts.push(`(${filterClause.clause})`)
179
- whereBindings.push(...filterClause.bindings)
180
- }
181
-
182
- const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
183
- const countRow = await DB.prepare(`SELECT COUNT(*) as total FROM ${table} ${whereSql}`)
184
- .bind(...whereBindings)
185
- .first<{ total: number }>()
186
- const total = countRow?.total ?? 0
187
-
188
- const orderSql = buildOrderSql(seed, query, latestMode)
189
- const effectiveLimit = latestMode ? (latestCount ?? 10) : pagination.limit
190
- const effectiveOffset = latestMode ? 0 : offset
191
-
192
- const rowsResult = await DB.prepare(`SELECT * FROM ${table} ${whereSql} ${orderSql} LIMIT ? OFFSET ?`)
193
- .bind(...whereBindings, effectiveLimit, effectiveOffset)
194
- .all<Record<string, unknown>>()
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
+ })
195
183
 
196
- const rows = rowsResult.results ?? []
197
- const data = rows.map((row) => toFlatPublicEntry(row, seed, query.fields))
184
+ const data = items.map((item) => toFlatPublicEntry(item, seed, query.fields))
198
185
 
199
186
  if (latestMode) {
200
187
  return withCache(cache, executionCtx, cacheKey,
201
- c.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
188
+ context.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
202
189
  )
203
190
  }
204
191
 
205
192
  return withCache(cache, executionCtx, cacheKey,
206
- c.json({
193
+ context.json({
207
194
  data,
208
- meta: buildPublicListMeta({ total, page: pagination.page, limit: effectiveLimit, returned: data.length, seed: seedSlug }),
195
+ meta: buildPublicListMeta({
196
+ total,
197
+ page: pagination.page,
198
+ limit: latestMode ? (latestCount ?? 10) : pagination.limit,
199
+ returned: data.length,
200
+ seed: seedSlug
201
+ }),
209
202
  }, 200)
210
203
  )
211
- } catch (err) {
212
- if (err instanceof Error && err.message.startsWith('Invalid filter:')) {
213
- return publicProblem(c, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: err.message })
204
+ } catch (error) {
205
+ if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
206
+ return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
214
207
  }
215
- return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: buildInternalErrorMessage(c, err) })
208
+ 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
+ })
216
215
  }
217
216
  }