@beechcms/api 0.4.0-preview.10 → 0.4.0-preview.12

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 (31) hide show
  1. package/assets/dashboard/assets/index-CTSuGxlX.js +554 -0
  2. package/assets/dashboard/assets/index-ye3325L9.css +1 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/factory.ts +96 -85
  6. package/src/features/content/constants.ts +10 -0
  7. package/src/features/content/handlers/create.ts +163 -0
  8. package/src/features/content/handlers/delete.ts +85 -0
  9. package/src/features/content/handlers/facets.ts +45 -0
  10. package/src/features/content/handlers/get.ts +116 -0
  11. package/src/features/content/handlers/list.ts +88 -0
  12. package/src/features/content/handlers/update.ts +216 -0
  13. package/src/features/content/index.ts +20 -0
  14. package/src/features/draft/draft.handler.ts +203 -129
  15. package/src/features/settings/settings.handler.ts +18 -0
  16. package/src/index.ts +16 -3
  17. package/src/middleware/repository.middleware.ts +18 -0
  18. package/src/public/public-add.ts +72 -89
  19. package/src/public/public-edit.ts +51 -76
  20. package/src/public/public-read.ts +113 -114
  21. package/src/public/query-builder.ts +47 -136
  22. package/src/shared/base.repository.d1.ts +28 -0
  23. package/src/shared/content.repository.d1.ts +382 -0
  24. package/src/shared/idempotency.repository.d1.ts +45 -0
  25. package/src/types.ts +6 -1
  26. package/src/upload.ts +3 -7
  27. package/assets/dashboard/assets/index-CFTJe1vb.js +0 -554
  28. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  29. package/src/content.ts +0 -502
  30. package/src/features/draft/draft.test.ts +0 -315
  31. package/src/features/rotate-field/rotate-field.test.ts +0 -297
@@ -0,0 +1,382 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import {
3
+ ContentRepository,
4
+ EntryNotFoundError,
5
+ RepositoryError,
6
+ SlugConflictError,
7
+ Seed,
8
+ SelectOptions,
9
+ buildSelectQuery,
10
+ deserializeFromDb,
11
+ serializeForDb,
12
+ } from '@beechcms/core'
13
+ import { BaseD1Repository } from './base.repository.d1'
14
+
15
+ export class D1ContentRepository extends BaseD1Repository implements ContentRepository {
16
+ /**
17
+ * Helper to deserialize a DB row using the Seed's branch definitions.
18
+ */
19
+ private rowToData(seed: Seed, row: any): Record<string, any> {
20
+ const data: Record<string, any> = {
21
+ id: row.id,
22
+ slug: row.slug,
23
+ status: row.status,
24
+ created_at: row.created_at,
25
+ updated_at: row.updated_at,
26
+ }
27
+
28
+ for (const branch of seed.branches) {
29
+ if (Object.hasOwn(row, branch.alias)) {
30
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
31
+ }
32
+ }
33
+
34
+ return data
35
+ }
36
+
37
+ async findMany(
38
+ seed: Seed,
39
+ options: SelectOptions
40
+ ): Promise<{ items: Record<string, any>[]; total: number }> {
41
+ try {
42
+ const { sql, bindings } = buildSelectQuery(seed, options)
43
+
44
+ // We need the total count for pagination.
45
+ // We build a count query by replacing the SELECT part.
46
+ // Note: buildSelectQuery might have joins and where clauses.
47
+ const countSql = sql
48
+ .replace(/SELECT .* FROM/, 'SELECT COUNT(*) as total FROM')
49
+ .replace(/ ORDER BY .*$/, '')
50
+ .replace(/ LIMIT \? OFFSET \?$/, '')
51
+
52
+ const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0))
53
+
54
+ const [results, countResult] = await this.db.batch([
55
+ this.db.prepare(sql).bind(...bindings),
56
+ this.db.prepare(countSql).bind(...countBindings)
57
+ ])
58
+
59
+ const items = (results.results || []).map((row) => this.rowToData(seed, row))
60
+ const total = (countResult.results?.[0] as any)?.total || 0
61
+
62
+ return { items, total }
63
+ } catch (error) {
64
+ throw this.mapError(error, `findMany(${seed.slug})`)
65
+ }
66
+ }
67
+
68
+ async findById(seed: Seed, id: string): Promise<Record<string, any>> {
69
+ try {
70
+ const table = this.getTableName(seed.slug)
71
+ const row = await this.db
72
+ .prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
73
+ .bind(id)
74
+ .first()
75
+
76
+ if (!row) {
77
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
78
+ }
79
+
80
+ return this.rowToData(seed, row)
81
+ } catch (error) {
82
+ if (error instanceof EntryNotFoundError) throw error
83
+ throw this.mapError(error, `findById(${seed.slug}, ${id})`)
84
+ }
85
+ }
86
+
87
+ async findBySlug(seed: Seed, slug: string): Promise<Record<string, any>> {
88
+ try {
89
+ const table = this.getTableName(seed.slug)
90
+ const row = await this.db
91
+ .prepare(`SELECT * FROM ${table} WHERE slug = ? LIMIT 1`)
92
+ .bind(slug)
93
+ .first()
94
+
95
+ if (!row) {
96
+ throw new EntryNotFoundError(`Entry with slug "${slug}" not found in ${seed.slug}`)
97
+ }
98
+
99
+ return this.rowToData(seed, row)
100
+ } catch (error) {
101
+ if (error instanceof EntryNotFoundError) throw error
102
+ throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`)
103
+ }
104
+ }
105
+
106
+ async getFacets(seed: Seed): Promise<{
107
+ statuses: Record<string, number>
108
+ tagsByColumn: Record<string, string[]>
109
+ }> {
110
+ try {
111
+ const table = this.getTableName(seed.slug)
112
+
113
+ // Status counts
114
+ const statusResults = await this.db
115
+ .prepare(`SELECT status, COUNT(*) as count FROM ${table} GROUP BY status`)
116
+ .all()
117
+
118
+ const statuses: Record<string, number> = {}
119
+ for (const row of statusResults.results || []) {
120
+ statuses[row.status as string] = row.count as number
121
+ }
122
+
123
+ // Tags facets (for branches of type 'tags')
124
+ const tagsByColumn: Record<string, string[]> = {}
125
+ const tagBranches = seed.branches.filter(b => b.type === 'tags')
126
+
127
+ for (const branch of tagBranches) {
128
+ // SQLite json_each for tags stored as JSON arrays
129
+ const tagResults = await this.db
130
+ .prepare(`SELECT DISTINCT value FROM ${table}, json_each(${table}.${branch.alias}) WHERE value IS NOT NULL`)
131
+ .all()
132
+ tagsByColumn[branch.alias] = (tagResults.results || []).map(r => r.value as string)
133
+ }
134
+
135
+ return { statuses, tagsByColumn }
136
+ } catch (error) {
137
+ throw this.mapError(error, `getFacets(${seed.slug})`)
138
+ }
139
+ }
140
+
141
+ async existsSlug(seed: Seed, slug: string, excludeId?: string): Promise<boolean> {
142
+ try {
143
+ const table = this.getTableName(seed.slug)
144
+ let sql = `SELECT 1 FROM ${table} WHERE slug = ?`
145
+ const bindings: any[] = [slug]
146
+
147
+ if (excludeId) {
148
+ sql += ` AND id != ?`
149
+ bindings.push(excludeId)
150
+ }
151
+
152
+ const row = await this.db.prepare(sql).bind(...bindings).first()
153
+ return row !== null
154
+ } catch (error) {
155
+ throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`)
156
+ }
157
+ }
158
+
159
+ async create(
160
+ seed: Seed,
161
+ id: string,
162
+ slug: string,
163
+ status: string,
164
+ data: Record<string, any>
165
+ ): Promise<void> {
166
+ try {
167
+ if (await this.existsSlug(seed, slug)) {
168
+ throw new SlugConflictError(`Slug "${slug}" already exists for ${seed.slug}`)
169
+ }
170
+
171
+ const table = this.getTableName(seed.slug)
172
+ const cols = ['id', 'slug', 'status']
173
+ const placeholders = ['?', '?', '?']
174
+ const bindings: any[] = [id, slug, status]
175
+
176
+ for (const branch of seed.branches) {
177
+ if (Object.hasOwn(data, branch.alias)) {
178
+ cols.push(branch.alias)
179
+ placeholders.push('?')
180
+ bindings.push(serializeForDb(branch, data[branch.alias]))
181
+ }
182
+ }
183
+
184
+ const sql = `INSERT INTO ${table} (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`
185
+ await this.db.prepare(sql).bind(...bindings).run()
186
+ } catch (error) {
187
+ if (error instanceof SlugConflictError) throw error
188
+ throw this.mapError(error, `create(${seed.slug})`)
189
+ }
190
+ }
191
+
192
+ async update(
193
+ seed: Seed,
194
+ id: string,
195
+ data: Record<string, any>,
196
+ status?: string
197
+ ): Promise<void> {
198
+ try {
199
+ const table = this.getTableName(seed.slug)
200
+ const setParts: string[] = []
201
+ const bindings: any[] = []
202
+
203
+ if (status) {
204
+ setParts.push('status = ?')
205
+ bindings.push(status)
206
+ }
207
+
208
+ for (const branch of seed.branches) {
209
+ if (Object.hasOwn(data, branch.alias)) {
210
+ setParts.push(`${branch.alias} = ?`)
211
+ bindings.push(serializeForDb(branch, data[branch.alias]))
212
+ }
213
+ }
214
+
215
+ if (setParts.length === 0) return
216
+
217
+ setParts.push('updated_at = (unixepoch())')
218
+
219
+ const sql = `UPDATE ${table} SET ${setParts.join(', ')} WHERE id = ?`
220
+ bindings.push(id)
221
+
222
+ const result = await this.db.prepare(sql).bind(...bindings).run()
223
+ if (result.meta.changes === 0) {
224
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
225
+ }
226
+ } catch (error) {
227
+ if (error instanceof EntryNotFoundError) throw error
228
+ throw this.mapError(error, `update(${seed.slug}, ${id})`)
229
+ }
230
+ }
231
+
232
+ async delete(seed: Seed, id: string): Promise<{ row: Record<string, any> }> {
233
+ try {
234
+ const table = this.getTableName(seed.slug)
235
+
236
+ // We need to return the row for R2 cleanup
237
+ const row = await this.db
238
+ .prepare(`SELECT * FROM ${table} WHERE id = ?`)
239
+ .bind(id)
240
+ .first()
241
+
242
+ if (!row) {
243
+ throw new EntryNotFoundError(`Entry ${id} not found in ${seed.slug}`)
244
+ }
245
+
246
+ await this.db.prepare(`DELETE FROM ${table} WHERE id = ?`).bind(id).run()
247
+
248
+ return { row: this.rowToData(seed, row) }
249
+ } catch (error) {
250
+ if (error instanceof EntryNotFoundError) throw error
251
+ throw this.mapError(error, `delete(${seed.slug}, ${id})`)
252
+ }
253
+ }
254
+
255
+ async saveDraft(seed: Seed, entryId: string, data: Record<string, any>): Promise<void> {
256
+ try {
257
+ if (!seed.allowDrafts) {
258
+ throw new RepositoryError(`Drafts not allowed for ${seed.slug}`)
259
+ }
260
+
261
+ const table = this.getTableName(seed.slug, true)
262
+ const cols = ['entry_id']
263
+ const placeholders = ['?']
264
+ const bindings: any[] = [entryId]
265
+ const updateParts: string[] = []
266
+
267
+ for (const branch of seed.branches) {
268
+ if (Object.hasOwn(data, branch.alias)) {
269
+ const val = serializeForDb(branch, data[branch.alias])
270
+ cols.push(branch.alias)
271
+ placeholders.push('?')
272
+ bindings.push(val)
273
+ updateParts.push(`${branch.alias} = EXCLUDED.${branch.alias}`)
274
+ }
275
+ }
276
+
277
+ updateParts.push('updated_at = (unixepoch())')
278
+
279
+ const sql = `
280
+ INSERT INTO ${table} (${cols.join(', ')})
281
+ VALUES (${placeholders.join(', ')})
282
+ ON CONFLICT(entry_id) DO UPDATE SET ${updateParts.join(', ')}
283
+ `
284
+ await this.db.prepare(sql).bind(...bindings).run()
285
+ } catch (error) {
286
+ throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`)
287
+ }
288
+ }
289
+
290
+ async getDraft(seed: Seed, entryId: string): Promise<Record<string, any> | null> {
291
+ try {
292
+ if (!seed.allowDrafts) return null
293
+
294
+ const table = this.getTableName(seed.slug, true)
295
+ const row = await this.db
296
+ .prepare(`SELECT * FROM ${table} WHERE entry_id = ?`)
297
+ .bind(entryId)
298
+ .first()
299
+
300
+ if (!row) return null
301
+
302
+ // Filter out nulls from draft row (only include provided fields)
303
+ const data: Record<string, any> = {}
304
+ for (const branch of seed.branches) {
305
+ if (row[branch.alias] !== null) {
306
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias])
307
+ }
308
+ }
309
+
310
+ return data
311
+ } catch (error) {
312
+ throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`)
313
+ }
314
+ }
315
+
316
+ async hasDraft(seed: Seed, entryId: string): Promise<boolean> {
317
+ try {
318
+ if (!seed.allowDrafts) return false
319
+ const table = this.getTableName(seed.slug, true)
320
+ const row = await this.db
321
+ .prepare(`SELECT 1 FROM ${table} WHERE entry_id = ? LIMIT 1`)
322
+ .bind(entryId)
323
+ .first()
324
+ return row !== null
325
+ } catch (error) {
326
+ throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`)
327
+ }
328
+ }
329
+
330
+ async publishDraft(seed: Seed, entryId: string): Promise<void> {
331
+ try {
332
+ if (!seed.allowDrafts) return
333
+
334
+ const draftTable = this.getTableName(seed.slug, true)
335
+ const liveTable = this.getTableName(seed.slug)
336
+
337
+ const draftRow = await this.db
338
+ .prepare(`SELECT * FROM ${draftTable} WHERE entry_id = ?`)
339
+ .bind(entryId)
340
+ .first()
341
+
342
+ if (!draftRow) {
343
+ throw new EntryNotFoundError(`No draft found for ${entryId} in ${seed.slug}`)
344
+ }
345
+
346
+ // Build UPDATE for live table
347
+ const setParts: string[] = []
348
+ const bindings: any[] = []
349
+
350
+ for (const branch of seed.branches) {
351
+ if (draftRow[branch.alias] !== null) {
352
+ setParts.push(`${branch.alias} = ?`)
353
+ bindings.push(draftRow[branch.alias])
354
+ }
355
+ }
356
+
357
+ setParts.push('updated_at = (unixepoch())')
358
+
359
+ const updateSql = `UPDATE ${liveTable} SET ${setParts.join(', ')} WHERE id = ?`
360
+ bindings.push(entryId)
361
+
362
+ // Atomic batch
363
+ await this.db.batch([
364
+ this.db.prepare(updateSql).bind(...bindings),
365
+ this.db.prepare(`DELETE FROM ${draftTable} WHERE entry_id = ?`).bind(entryId)
366
+ ])
367
+ } catch (error) {
368
+ if (error instanceof EntryNotFoundError) throw error
369
+ throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`)
370
+ }
371
+ }
372
+
373
+ async deleteDraft(seed: Seed, entryId: string): Promise<void> {
374
+ try {
375
+ if (!seed.allowDrafts) return
376
+ const table = this.getTableName(seed.slug, true)
377
+ await this.db.prepare(`DELETE FROM ${table} WHERE entry_id = ?`).bind(entryId).run()
378
+ } catch (error) {
379
+ throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`)
380
+ }
381
+ }
382
+ }
@@ -0,0 +1,45 @@
1
+ import { IdempotencyRepository, IdempotencyRecord } from '@beechcms/core'
2
+ import { BaseD1Repository } from './base.repository.d1.js'
3
+
4
+ export class D1IdempotencyRepository extends BaseD1Repository implements IdempotencyRepository {
5
+ async lookup(key: string): Promise<IdempotencyRecord | null> {
6
+ const row = await this.db.prepare(
7
+ `SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
8
+ FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
9
+ ).bind(key).first<any>()
10
+
11
+ if (!row) return null
12
+
13
+ return {
14
+ key: row.idempotency_key,
15
+ fingerprint: row.request_fingerprint,
16
+ responseStatus: row.response_status,
17
+ responseBody: row.response_body,
18
+ expiresAt: row.expires_at
19
+ }
20
+ }
21
+
22
+ async store(record: IdempotencyRecord): Promise<void> {
23
+ await this.db.prepare(
24
+ `INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
25
+ VALUES (?, ?, ?, ?, ?, ?)
26
+ ON CONFLICT(idempotency_key) DO UPDATE SET
27
+ request_fingerprint = excluded.request_fingerprint,
28
+ response_status = excluded.response_status,
29
+ response_body = excluded.response_body,
30
+ created_at = excluded.created_at,
31
+ expires_at = excluded.expires_at`
32
+ ).bind(
33
+ record.key,
34
+ record.fingerprint,
35
+ record.responseStatus,
36
+ record.responseBody,
37
+ Math.floor(Date.now() / 1000),
38
+ record.expiresAt
39
+ ).run()
40
+ }
41
+
42
+ async cleanup(now: number): Promise<void> {
43
+ await this.db.prepare(`DELETE FROM public_idempotency_keys WHERE expires_at < ?`).bind(now).run()
44
+ }
45
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
- import type { Seed } from '@beechcms/core'
2
+ import type { Seed, ContentRepository, IdempotencyRepository } from '@beechcms/core'
3
3
 
4
4
  export interface Env {
5
5
  DB: D1Database
@@ -26,6 +26,7 @@ export interface Env {
26
26
  FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
27
27
  RESET_PASSWORD_RATE_LIMITER?: RateLimit
28
28
  ENV?: string
29
+ DATE_FORMAT?: string
29
30
  ASSETS?: Fetcher
30
31
  }
31
32
 
@@ -33,4 +34,8 @@ export interface Variables {
33
34
  jwtPayload: { sub: string; email?: string }
34
35
  getSeed: (slug: string) => Seed | null
35
36
  seedRegistry: Record<string, Seed>
37
+ repository: ContentRepository
38
+ idempotencyRepository: IdempotencyRepository
36
39
  }
40
+
41
+ export type AppEnv = { Bindings: Env; Variables: Variables }
package/src/upload.ts CHANGED
@@ -164,9 +164,7 @@ export const uploadRoutes = new Hono<{
164
164
  }>()
165
165
 
166
166
  /** POST /upload - Carica file su R2, restituisce URL pubblico */
167
- uploadRoutes.post('/upload', async (c, next) => {
168
- await authMiddleware(c.env.JWT_SECRET)(c, next)
169
- }, async (c) => {
167
+ uploadRoutes.post('/upload', async (c) => {
170
168
  try {
171
169
  const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
172
170
  if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
@@ -267,10 +265,8 @@ uploadRoutes.post('/upload', async (c, next) => {
267
265
  }
268
266
  })
269
267
 
270
- /** DELETE /upload/:key - Elimina un file da R2 */
271
- uploadRoutes.delete('/:key', async (c, next) => {
272
- await authMiddleware(c.env.JWT_SECRET)(c, next)
273
- }, async (c) => {
268
+ /** DELETE /api/upload/:key - Elimina un file da R2 */
269
+ uploadRoutes.delete('/upload/:key', async (c) => {
274
270
  const key = c.req.param('key')
275
271
  if (!key) return c.json({ error: 'Missing key' }, 400)
276
272