@beechcms/api 0.4.0-preview.9 → 0.4.0

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 (89) 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-FQ6JhvRH.js +554 -0
  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 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  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 -249
  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 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  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 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  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 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  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 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
package/src/search.ts CHANGED
@@ -1,72 +1,72 @@
1
- // apps/api/src/search.ts
2
-
3
- import { Hono } from "hono"
4
- import type { Env, Variables } from "./types"
5
- import { authMiddleware } from "./middleware"
6
- import {
7
- buildFtsQuery,
8
- encodeCursor,
9
- mapFtsRow,
10
- type FtsRow,
11
- type SearchResponse,
12
- } from "./search-utils"
13
-
14
- export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
15
-
16
- searchRouter.use("*", async (c, next) => {
17
- return authMiddleware(c.env.JWT_SECRET, {
18
- issuer: c.env.JWT_ISSUER,
19
- audience: c.env.JWT_AUDIENCE,
20
- })(c, next)
21
- })
22
-
23
- // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
24
- searchRouter.get("/", async (c) => {
25
- const q = c.req.query("q")?.trim() ?? ""
26
- const schemaSlug = c.req.query("schema_slug") ?? null
27
- const status = c.req.query("status") ?? null
28
- const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
29
- const limit = Math.min(Math.max(rawLimit, 1), 50)
30
- const cursor = c.req.query("cursor") ?? null
31
-
32
- if (q.length < 2) {
33
- return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
34
- }
35
-
36
- const seeds = Object.values(c.get('seedRegistry'))
37
-
38
- let queryParts: ReturnType<typeof buildFtsQuery>
39
- try {
40
- queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
41
- } catch (e) {
42
- if ((e as Error).message === "EMPTY_QUERY") {
43
- return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
44
- }
45
- throw e
46
- }
47
-
48
- const { sql, binds, countSql, countBinds } = queryParts
49
-
50
- const [ftsResult, countResult] = await Promise.all([
51
- c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
52
- c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
53
- ])
54
-
55
- const rows = ftsResult.results ?? []
56
- const total = countResult?.total ?? 0
57
-
58
- const hasMore = rows.length > limit
59
- const pageRows = hasMore ? rows.slice(0, limit) : rows
60
-
61
- const nextCursor = hasMore
62
- ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
63
- : null
64
-
65
- if (pageRows.length === 0) {
66
- return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
67
- }
68
-
69
- const items = pageRows.map(row => mapFtsRow(row))
70
-
71
- return c.json({ items, nextCursor, total } satisfies SearchResponse)
72
- })
1
+ // apps/api/src/search.ts
2
+
3
+ import { Hono } from "hono"
4
+ import type { Env, Variables } from "./types"
5
+ import { authMiddleware } from "./middleware"
6
+ import {
7
+ buildFtsQuery,
8
+ encodeCursor,
9
+ mapFtsRow,
10
+ type FtsRow,
11
+ type SearchResponse,
12
+ } from "./search-utils"
13
+
14
+ export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
15
+
16
+ searchRouter.use("*", async (c, next) => {
17
+ return authMiddleware(c.env.JWT_SECRET, {
18
+ issuer: c.env.JWT_ISSUER,
19
+ audience: c.env.JWT_AUDIENCE,
20
+ })(c, next)
21
+ })
22
+
23
+ // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
24
+ searchRouter.get("/", async (c) => {
25
+ const q = c.req.query("q")?.trim() ?? ""
26
+ const schemaSlug = c.req.query("schema_slug") ?? null
27
+ const status = c.req.query("status") ?? null
28
+ const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
29
+ const limit = Math.min(Math.max(rawLimit, 1), 50)
30
+ const cursor = c.req.query("cursor") ?? null
31
+
32
+ if (q.length < 2) {
33
+ return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
34
+ }
35
+
36
+ const seeds = Object.values(c.get('seedRegistry'))
37
+
38
+ let queryParts: ReturnType<typeof buildFtsQuery>
39
+ try {
40
+ queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
41
+ } catch (e) {
42
+ if ((e as Error).message === "EMPTY_QUERY") {
43
+ return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
44
+ }
45
+ throw e
46
+ }
47
+
48
+ const { sql, binds, countSql, countBinds } = queryParts
49
+
50
+ const [ftsResult, countResult] = await Promise.all([
51
+ c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
52
+ c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
53
+ ])
54
+
55
+ const rows = ftsResult.results ?? []
56
+ const total = countResult?.total ?? 0
57
+
58
+ const hasMore = rows.length > limit
59
+ const pageRows = hasMore ? rows.slice(0, limit) : rows
60
+
61
+ const nextCursor = hasMore
62
+ ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
63
+ : null
64
+
65
+ if (pageRows.length === 0) {
66
+ return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
67
+ }
68
+
69
+ const items = pageRows.map(row => mapFtsRow(row))
70
+
71
+ return c.json({ items, nextCursor, total } satisfies SearchResponse)
72
+ })
@@ -1,79 +1,79 @@
1
- import { Context } from 'hono'
2
-
3
- export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
4
- export type EntityType = 'content' | 'media'
5
-
6
- export interface ActivityLogParams {
7
- action: ActivityAction
8
- entityType: EntityType
9
- entityId: string
10
- entitySlug?: string
11
- details?: Record<string, any>
12
- }
13
-
14
- /**
15
- * Logga un'azione utente asincronamente nel database D1.
16
- * Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
17
- */
18
- export function logActivity(
19
- c: Context<any>,
20
- params: ActivityLogParams
21
- ): void {
22
- const db = c.env.DB as D1Database
23
- const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
24
-
25
- if (!db || !user) return
26
-
27
- const { action, entityType, entityId, entitySlug, details } = params
28
- const id = crypto.randomUUID()
29
-
30
- // Usa waitUntil per non bloccare la risposta al client
31
- let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
32
- try {
33
- executionCtx = c.executionCtx
34
- } catch {
35
- // In ambiente di test Hono lancia se non presente
36
- }
37
-
38
- if (executionCtx) {
39
- executionCtx.waitUntil((async () => {
40
- try {
41
- await db.prepare(
42
- `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
43
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
44
- ).bind(
45
- id,
46
- user.sub,
47
- user.email || 'unknown',
48
- user.name || null,
49
- action,
50
- entityType,
51
- entityId,
52
- entitySlug || null,
53
- details ? JSON.stringify(details) : null
54
- ).run()
55
- } catch (err) {
56
- console.error('Failed to log activity:', err)
57
- }
58
- })())
59
- } else {
60
- try {
61
- db.prepare(
62
- `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
63
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
64
- ).bind(
65
- id,
66
- user.sub,
67
- user.email || 'unknown',
68
- user.name || null,
69
- action,
70
- entityType,
71
- entityId,
72
- entitySlug || null,
73
- details ? JSON.stringify(details) : null
74
- ).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
75
- } catch (err) {
76
- console.error('Failed to log activity (sync fallback):', err)
77
- }
78
- }
79
- }
1
+ import { Context } from 'hono'
2
+
3
+ export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
4
+ export type EntityType = 'content' | 'media'
5
+
6
+ export interface ActivityLogParams {
7
+ action: ActivityAction
8
+ entityType: EntityType
9
+ entityId: string
10
+ entitySlug?: string
11
+ details?: Record<string, any>
12
+ }
13
+
14
+ /**
15
+ * Logga un'azione utente asincronamente nel database D1.
16
+ * Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
17
+ */
18
+ export function logActivity(
19
+ c: Context<any>,
20
+ params: ActivityLogParams
21
+ ): void {
22
+ const db = c.env.DB as D1Database
23
+ const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
24
+
25
+ if (!db || !user) return
26
+
27
+ const { action, entityType, entityId, entitySlug, details } = params
28
+ const id = crypto.randomUUID()
29
+
30
+ // Usa waitUntil per non bloccare la risposta al client
31
+ let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
32
+ try {
33
+ executionCtx = c.executionCtx
34
+ } catch {
35
+ // In ambiente di test Hono lancia se non presente
36
+ }
37
+
38
+ if (executionCtx) {
39
+ executionCtx.waitUntil((async () => {
40
+ try {
41
+ await db.prepare(
42
+ `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
43
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
44
+ ).bind(
45
+ id,
46
+ user.sub,
47
+ user.email || 'unknown',
48
+ user.name || null,
49
+ action,
50
+ entityType,
51
+ entityId,
52
+ entitySlug || null,
53
+ details ? JSON.stringify(details) : null
54
+ ).run()
55
+ } catch (err) {
56
+ console.error('Failed to log activity:', err)
57
+ }
58
+ })())
59
+ } else {
60
+ try {
61
+ db.prepare(
62
+ `INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
64
+ ).bind(
65
+ id,
66
+ user.sub,
67
+ user.email || 'unknown',
68
+ user.name || null,
69
+ action,
70
+ entityType,
71
+ entityId,
72
+ entitySlug || null,
73
+ details ? JSON.stringify(details) : null
74
+ ).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
75
+ } catch (err) {
76
+ console.error('Failed to log activity (sync fallback):', err)
77
+ }
78
+ }
79
+ }
@@ -1,63 +1,63 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { resolvePolicies, sha256hex } from '@beechcms/core'
3
- import type { Seed } from '@beechcms/core'
4
-
5
- class PrivacyPolicyError extends Error {
6
- readonly status = 501 as const
7
- constructor(message: string) {
8
- super(message)
9
- this.name = 'PrivacyPolicyError'
10
- }
11
- }
12
-
13
- export { PrivacyPolicyError }
14
-
15
- /** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
16
- export async function applyPrivacy(
17
- data: Record<string, unknown>,
18
- seed: Seed,
19
- ): Promise<Record<string, unknown>> {
20
- const result: Record<string, unknown> = {}
21
- for (const [alias, value] of Object.entries(data)) {
22
- const branch = seed.branches.find((b) => b.alias === alias)
23
- if (!branch) {
24
- result[alias] = value
25
- continue
26
- }
27
- const { privacy } = resolvePolicies(branch)
28
- if (privacy === 'encrypt') {
29
- throw new PrivacyPolicyError(
30
- `Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
31
- )
32
- }
33
- if (privacy === 'hash' && value != null) {
34
- result[alias] = await sha256hex(String(value))
35
- } else {
36
- result[alias] = value
37
- }
38
- }
39
- return result
40
- }
41
-
42
- /** Applica la visibility policy ai campi del payload in uscita verso il client. */
43
- export function applyVisibility(
44
- data: Record<string, unknown>,
45
- seed: Seed,
46
- ): Record<string, unknown> {
47
- const result: Record<string, unknown> = {}
48
- for (const [alias, value] of Object.entries(data)) {
49
- const branch = seed.branches.find((b) => b.alias === alias)
50
- if (!branch) {
51
- result[alias] = value
52
- continue
53
- }
54
- const { visibility } = resolvePolicies(branch)
55
- if (visibility === 'hidden') continue
56
- if (visibility === 'masked') {
57
- result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
58
- } else {
59
- result[alias] = value
60
- }
61
- }
62
- return result
63
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { resolvePolicies, sha256hex } from '@beechcms/core'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ class PrivacyPolicyError extends Error {
6
+ readonly status = 501 as const
7
+ constructor(message: string) {
8
+ super(message)
9
+ this.name = 'PrivacyPolicyError'
10
+ }
11
+ }
12
+
13
+ export { PrivacyPolicyError }
14
+
15
+ /** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
16
+ export async function applyPrivacy(
17
+ data: Record<string, unknown>,
18
+ seed: Seed,
19
+ ): Promise<Record<string, unknown>> {
20
+ const result: Record<string, unknown> = {}
21
+ for (const [alias, value] of Object.entries(data)) {
22
+ const branch = seed.branches.find((b) => b.alias === alias)
23
+ if (!branch) {
24
+ result[alias] = value
25
+ continue
26
+ }
27
+ const { privacy } = resolvePolicies(branch)
28
+ if (privacy === 'encrypt') {
29
+ throw new PrivacyPolicyError(
30
+ `Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
31
+ )
32
+ }
33
+ if (privacy === 'hash' && value != null) {
34
+ result[alias] = await sha256hex(String(value))
35
+ } else {
36
+ result[alias] = value
37
+ }
38
+ }
39
+ return result
40
+ }
41
+
42
+ /** Applica la visibility policy ai campi del payload in uscita verso il client. */
43
+ export function applyVisibility(
44
+ data: Record<string, unknown>,
45
+ seed: Seed,
46
+ ): Record<string, unknown> {
47
+ const result: Record<string, unknown> = {}
48
+ for (const [alias, value] of Object.entries(data)) {
49
+ const branch = seed.branches.find((b) => b.alias === alias)
50
+ if (!branch) {
51
+ result[alias] = value
52
+ continue
53
+ }
54
+ const { visibility } = resolvePolicies(branch)
55
+ if (visibility === 'hidden') continue
56
+ if (visibility === 'masked') {
57
+ result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
58
+ } else {
59
+ result[alias] = value
60
+ }
61
+ }
62
+ return result
63
+ }
@@ -0,0 +1,28 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { RepositoryError } from '@beechcms/core'
3
+
4
+ /**
5
+ * Base class for D1-backed repositories.
6
+ * Handles the D1 instance and provides common database utilities and error mapping.
7
+ */
8
+ export abstract class BaseD1Repository {
9
+ constructor(protected readonly database: D1Database) {}
10
+
11
+ /**
12
+ * Utility method to extract a table name for a given seed.
13
+ */
14
+ protected getTableName(slug: string, isDraft = false): string {
15
+ return isDraft ? `content_${slug}_drafts` : `content_${slug}`
16
+ }
17
+
18
+ /**
19
+ * Helper to map generic D1 errors to Beech RepositoryErrors.
20
+ * This centralizes error handling for all D1 repositories.
21
+ */
22
+ protected mapError(error: any, context: string): RepositoryError {
23
+ const message = error?.message || 'Unknown database error'
24
+ // In the future, we can add more specific SQLite error code checks here
25
+ // (e.g., checking for UNIQUE constraint via string matching or codes if available)
26
+ return new RepositoryError(`${context}: ${message}`, error)
27
+ }
28
+ }
@@ -1,108 +1,82 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { deserializeFromDb, serializeForDb } from '@beechcms/core'
3
- import type { Seed } from '@beechcms/core'
4
- import type { ContentEntry } from './query-utils'
5
-
6
- /** Deserializza ogni branch colonna di un DB row in formato API alias. */
7
- export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
8
- const data: Record<string, unknown> = {}
9
- for (const branch of seed.branches) {
10
- data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
11
- }
12
- return data
13
- }
14
-
15
- /** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
16
- export function rowToEntry(
17
- seed: Seed,
18
- row: Record<string, unknown>,
19
- hasPendingDraft = false
20
- ): ContentEntry {
21
- return {
22
- id: row.id as string,
23
- schema_slug: seed.slug,
24
- slug: (row.slug as string | null) ?? null,
25
- status: (row.status as string) ?? 'draft',
26
- data: rowToApiData(seed, row),
27
- hasPendingDraft,
28
- created_at: (row.created_at as number | null) ?? null,
29
- updated_at: (row.updated_at as number | null) ?? null,
30
- }
31
- }
32
-
33
- export interface InsertBindings {
34
- cols: string[]
35
- placeholders: string[]
36
- bindings: (string | number | null)[]
37
- }
38
-
39
- /** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
40
- export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
41
- const cols: string[] = []
42
- const placeholders: string[] = []
43
- const bindings: (string | number | null)[] = []
44
- for (const branch of seed.branches) {
45
- if (Object.hasOwn(payload, branch.alias)) {
46
- cols.push(branch.alias)
47
- placeholders.push('?')
48
- bindings.push(serializeForDb(branch, payload[branch.alias]))
49
- }
50
- }
51
- return { cols, placeholders, bindings }
52
- }
53
-
54
- export interface UpdateBindings {
55
- setClause: string
56
- bindings: (string | number | null)[]
57
- }
58
-
59
- /** Costruisce SET clause e bindings per UPDATE content_{slug}. */
60
- export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
61
- const setParts: string[] = []
62
- const bindings: (string | number | null)[] = []
63
- for (const branch of seed.branches) {
64
- if (Object.hasOwn(payload, branch.alias)) {
65
- setParts.push(`${branch.alias} = ?`)
66
- bindings.push(serializeForDb(branch, payload[branch.alias]))
67
- }
68
- }
69
- return { setClause: setParts.join(', '), bindings }
70
- }
71
-
72
- /** Controlla se esiste un draft pendente in content_{slug}_drafts. */
73
- export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
74
- if (!seed.allowDrafts) return false
75
- const row = await db
76
- .prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
77
- .bind(entryId)
78
- .first()
79
- return row !== null
80
- }
81
-
82
- /** Scrive un evento CRUD in content_event_log per l'activity feed. */
83
- export async function logContentEvent(
84
- db: D1Database,
85
- opts: {
86
- action: 'create' | 'update' | 'delete'
87
- schemaSlug: string
88
- entryId: string
89
- userId?: string | null
90
- details?: Record<string, unknown>
91
- }
92
- ): Promise<void> {
93
- await db
94
- .prepare(
95
- `INSERT INTO content_event_log (id, schema_slug, entry_id, action, user_id, details, created_at)
96
- VALUES (?, ?, ?, ?, ?, ?, ?)`
97
- )
98
- .bind(
99
- crypto.randomUUID(),
100
- opts.schemaSlug,
101
- opts.entryId,
102
- opts.action,
103
- opts.userId ?? null,
104
- opts.details ? JSON.stringify(opts.details) : null,
105
- Math.floor(Date.now() / 1000)
106
- )
107
- .run()
108
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { deserializeFromDb, serializeForDb } from '@beechcms/core'
3
+ import type { Seed } from '@beechcms/core'
4
+ import type { ContentEntry } from './query-utils'
5
+
6
+ /** Deserializza ogni branch colonna di un DB row in formato API alias. */
7
+ export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
8
+ const data: Record<string, unknown> = {}
9
+ for (const branch of seed.branches) {
10
+ data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
11
+ }
12
+ return data
13
+ }
14
+
15
+ /** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
16
+ export function rowToEntry(
17
+ seed: Seed,
18
+ row: Record<string, unknown>,
19
+ hasPendingDraft = false
20
+ ): ContentEntry {
21
+ return {
22
+ id: row.id as string,
23
+ schema_slug: seed.slug,
24
+ slug: (row.slug as string | null) ?? null,
25
+ status: (row.status as string) ?? 'draft',
26
+ data: rowToApiData(seed, row),
27
+ hasPendingDraft,
28
+ created_at: (row.created_at as number | null) ?? null,
29
+ updated_at: (row.updated_at as number | null) ?? null,
30
+ }
31
+ }
32
+
33
+ export interface InsertBindings {
34
+ cols: string[]
35
+ placeholders: string[]
36
+ bindings: (string | number | null)[]
37
+ }
38
+
39
+ /** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
40
+ export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
41
+ const cols: string[] = []
42
+ const placeholders: string[] = []
43
+ const bindings: (string | number | null)[] = []
44
+ for (const branch of seed.branches) {
45
+ if (Object.hasOwn(payload, branch.alias)) {
46
+ cols.push(branch.alias)
47
+ placeholders.push('?')
48
+ bindings.push(serializeForDb(branch, payload[branch.alias]))
49
+ }
50
+ }
51
+ return { cols, placeholders, bindings }
52
+ }
53
+
54
+ export interface UpdateBindings {
55
+ setClause: string
56
+ bindings: (string | number | null)[]
57
+ }
58
+
59
+ /** Costruisce SET clause e bindings per UPDATE content_{slug}. */
60
+ export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
61
+ const setParts: string[] = []
62
+ const bindings: (string | number | null)[] = []
63
+ for (const branch of seed.branches) {
64
+ if (Object.hasOwn(payload, branch.alias)) {
65
+ setParts.push(`${branch.alias} = ?`)
66
+ bindings.push(serializeForDb(branch, payload[branch.alias]))
67
+ }
68
+ }
69
+ return { setClause: setParts.join(', '), bindings }
70
+ }
71
+
72
+ /** Controlla se esiste un draft pendente in content_{slug}_drafts. */
73
+ export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
74
+ if (!seed.allowDrafts) return false
75
+ const row = await db
76
+ .prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
77
+ .bind(entryId)
78
+ .first()
79
+ return row !== null
80
+ }
81
+
82
+