@beechcms/api 0.4.1 → 0.4.2

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-C1P9BXnU.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 +110 -156
  57. package/src/public/public-read.ts +59 -216
  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
@@ -16,10 +16,12 @@
16
16
  import { ResendEmailProvider } from './providers/resend'
17
17
  import { buildPasswordResetEmail } from './templates/password-reset'
18
18
  import { buildPasswordChangedEmail } from './templates/password-changed'
19
+ import { buildAutomationEmail } from './templates/automation-mail'
19
20
  import type { EmailProvider } from './email.provider'
20
21
  import type {
21
22
  PasswordResetEmailParams,
22
23
  PasswordChangedEmailParams,
24
+ AutomationMailParams,
23
25
  } from './email.types'
24
26
 
25
27
  /** Default sender address (Resend test sender, works without a verified domain). */
@@ -78,3 +80,14 @@ export async function sendPasswordChangedEmail(
78
80
  html,
79
81
  })
80
82
  }
83
+
84
+ export async function sendAutomationMail(params: AutomationMailParams): Promise<void> {
85
+ const provider = createProvider(params.apiKey ?? params.resendApiKey ?? '', false)
86
+ const message = buildAutomationEmail(params)
87
+ await provider.send({
88
+ from: params.from ?? DEFAULT_FROM,
89
+ to: [message.to],
90
+ subject: message.subject,
91
+ html: message.html,
92
+ })
93
+ }
@@ -96,3 +96,13 @@ export interface PasswordResetEmailParams extends BaseEmailParams {
96
96
 
97
97
  /** Parameters for the "password changed" notification. No additional fields. */
98
98
  export type PasswordChangedEmailParams = BaseEmailParams
99
+
100
+ export interface AutomationMailParams {
101
+ to: string
102
+ subject: string
103
+ /** Plain text or HTML — passed verbatim to provider. */
104
+ body: string
105
+ apiKey?: string
106
+ resendApiKey?: string
107
+ from?: string
108
+ }
@@ -16,7 +16,7 @@
16
16
  * PasswordChangedEmailParams — shape dei parametri per sendPasswordChangedEmail
17
17
  */
18
18
 
19
- export { sendPasswordResetEmail, sendPasswordChangedEmail } from './email.service'
19
+ export { sendPasswordResetEmail, sendPasswordChangedEmail, sendAutomationMail } from './email.service'
20
20
  export {
21
21
  resolveEmailLocale,
22
22
  SUPPORTED_EMAIL_LOCALES,
@@ -25,4 +25,5 @@ export type {
25
25
  EmailLocale,
26
26
  PasswordResetEmailParams,
27
27
  PasswordChangedEmailParams,
28
+ AutomationMailParams,
28
29
  } from './email.types'
@@ -0,0 +1,15 @@
1
+ import type { AutomationMailParams } from '../email.types'
2
+
3
+ /** Identity builder: automation payloads are already user-authored. */
4
+ export function buildAutomationEmail(params: AutomationMailParams) {
5
+ return {
6
+ to: params.to,
7
+ subject: params.subject,
8
+ html: params.body,
9
+ text: stripHtml(params.body),
10
+ }
11
+ }
12
+
13
+ function stripHtml(input: string): string {
14
+ return input.replace(/<[^>]+>/g, '').trim()
15
+ }
@@ -24,7 +24,8 @@ settingsApp.get('/', async (context) => {
24
24
  drafts: true,
25
25
  media: true,
26
26
  search: true,
27
- activityLog: true
27
+ activityLog: true,
28
+ email: !!(context.env.EMAIL_API_KEY || context.env.RESEND_API_KEY),
28
29
  }
29
30
  })
30
31
  })
package/src/index.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import { createBeechApp } from './factory'
2
+ import { SeedRegistry, SystemIdGenerator } from '@beechcms/core'
3
+ import { runCronAutomations } from './features/automations'
4
+ import { D1AutomationRepository } from './shared/automations.repository.d1'
5
+ import { D1ContentRepository } from './shared/content.repository.d1'
6
+ import type { Env } from './types'
2
7
 
3
- /**
4
- * Entry point per lo sviluppo locale del monorepo.
5
- * Carica dinamicamente seed.ts o seeds.ts dalla root di apps/api se presenti.
6
- */
7
- let seeds: any = []
8
+ let seeds: any[] = []
8
9
 
9
10
  try {
10
11
  // @ts-ignore
11
12
  const mod = await import('../seed.ts')
12
13
  const registry = mod.default || mod.SEED_REGISTRY || mod
13
- seeds = (typeof registry === 'object' && !Array.isArray(registry))
14
- ? Object.values(registry)
14
+ seeds = (typeof registry === 'object' && !Array.isArray(registry))
15
+ ? Object.values(registry)
15
16
  : registry
16
17
  } catch (e) {
17
18
  // Fallback se seed.ts non esiste
@@ -21,4 +22,35 @@ const app = createBeechApp({ seeds })
21
22
 
22
23
  app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
23
24
 
24
- export default app
25
+ const validSeeds = seeds.filter((s: any) => s && typeof s === 'object' && 'slug' in s)
26
+
27
+ export default {
28
+ fetch: app.fetch,
29
+
30
+ async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
31
+ const scheduledTime = controller?.scheduledTime ?? Date.now()
32
+
33
+ if (!env.DB) {
34
+ console.warn('[cron] D1 binding missing. Skipping cron automations.')
35
+ return
36
+ }
37
+
38
+ const automationRepository = new D1AutomationRepository(env.DB)
39
+ const contentRepository = new D1ContentRepository(env.DB)
40
+ const registry = new SeedRegistry(validSeeds)
41
+ const getSeed = (slug: string) => registry.get(slug) ?? null
42
+
43
+ ctx.waitUntil(
44
+ runCronAutomations(
45
+ {
46
+ automationRepository,
47
+ contentRepository,
48
+ getSeed,
49
+ env: env as unknown as Record<string, string | undefined>,
50
+ idGenerator: SystemIdGenerator,
51
+ },
52
+ scheduledTime,
53
+ ),
54
+ )
55
+ },
56
+ } satisfies ExportedHandler<Env>
@@ -1,4 +1,5 @@
1
1
  import { createMiddleware } from 'hono/factory'
2
+ import type { Context } from 'hono'
2
3
  import { D1ContentRepository } from '../shared/content.repository.d1'
3
4
  import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
4
5
  import { D1MediaRepository } from '../shared/media.repository.d1'
@@ -13,7 +14,11 @@ import { D1SearchRepository } from '../shared/d1-search.repository'
13
14
  import { D1AnalyticsRepository } from '../shared/d1-analytics.repository'
14
15
  import { D1ContentScanRepository } from '../shared/d1-content-scan.repository'
15
16
  import { SystemClock, SystemIdGenerator } from '@beechcms/core'
16
- import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogRepository, INotificationRepository, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, IClock, IIdGenerator } from '@beechcms/core'
17
+ import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogRepository, INotificationRepository, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, IClock, IIdGenerator, IAutomationRunner, IAutomationRepository, IScheduler } from '@beechcms/core'
18
+ import { NoOpScheduler } from '@beechcms/core'
19
+ import { AutomationRunner } from '../features/automations'
20
+ import { D1AutomationRepository } from '../shared/automations.repository.d1'
21
+ import { ExecutionContextScheduler } from '../shared/execution-context-scheduler'
17
22
  import type { Env, Variables } from '../types'
18
23
 
19
24
  interface RepositoryOverrides {
@@ -32,6 +37,17 @@ interface RepositoryOverrides {
32
37
  contentScanRepository?: IContentScanRepository
33
38
  clock?: IClock
34
39
  idGenerator?: IIdGenerator
40
+ automationRepository?: IAutomationRepository
41
+ automationRunner?: IAutomationRunner
42
+ scheduler?: IScheduler
43
+ }
44
+
45
+ function buildScheduler(context: Context): IScheduler {
46
+ try {
47
+ return new ExecutionContextScheduler(context.executionCtx)
48
+ } catch {
49
+ return new NoOpScheduler()
50
+ }
35
51
  }
36
52
 
37
53
  export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
@@ -55,6 +71,21 @@ export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
55
71
  context.set('contentScanRepository', overrides?.contentScanRepository ?? new D1ContentScanRepository(database))
56
72
  context.set('clock', resolvedClock)
57
73
  context.set('idGenerator', resolvedIdGenerator)
74
+ const automationRepository = overrides?.automationRepository
75
+ ?? new D1AutomationRepository(database)
76
+
77
+ context.set('automationRepository', automationRepository)
78
+ context.set(
79
+ 'automationRunner',
80
+ overrides?.automationRunner ?? new AutomationRunner({
81
+ automationRepository,
82
+ contentRepository: context.get('repository'),
83
+ getSeed: context.get('getSeed'),
84
+ idGenerator: resolvedIdGenerator,
85
+ env: context.env as unknown as Record<string, string | undefined>,
86
+ }),
87
+ )
88
+ context.set('scheduler', overrides?.scheduler ?? buildScheduler(context))
58
89
  await next()
59
90
  })
60
91
  }
@@ -0,0 +1,34 @@
1
+ import type { Context } from 'hono'
2
+
3
+ type EdgeCache = {
4
+ cache: Cache
5
+ executionCtx: { waitUntil: (p: Promise<unknown>) => void }
6
+ } | null
7
+
8
+ export function resolveEdgeCache(c: Context): EdgeCache {
9
+ try {
10
+ const cache = caches.default
11
+ let executionCtx: any
12
+ try {
13
+ executionCtx = c.executionCtx
14
+ } catch {
15
+ return null
16
+ }
17
+
18
+ if (!executionCtx?.waitUntil) return null
19
+ return { cache, executionCtx: executionCtx as { waitUntil: (p: Promise<unknown>) => void } }
20
+ } catch {
21
+ return null
22
+ }
23
+ }
24
+
25
+ export function withCachedResponse(edgeCache: EdgeCache, cacheKey: Request, response: Response): Response {
26
+ if (!edgeCache) return response
27
+ const cloned = response.clone()
28
+ const headers = new Headers(cloned.headers)
29
+ headers.set('Cache-Control', 'public, max-age=60')
30
+ edgeCache.executionCtx.waitUntil(
31
+ edgeCache.cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers }))
32
+ )
33
+ return response
34
+ }
@@ -0,0 +1,42 @@
1
+ import { resolvePolicies } from '@beechcms/core'
2
+ import type { Seed } from '@beechcms/core'
3
+
4
+ const SYSTEM_FIELDS = ['id', 'slug', 'status', 'created_at', 'updated_at']
5
+ const IDENTITY_FIELDS = ['id', 'slug']
6
+
7
+ function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
8
+ const result: Record<string, unknown> = {}
9
+
10
+ for (const key of SYSTEM_FIELDS) {
11
+ if (key in data) result[key] = data[key]
12
+ }
13
+
14
+ for (const branch of seed.branches) {
15
+ const value = data[branch.alias]
16
+ const { public: isPublic, visibility } = resolvePolicies(branch)
17
+ if (!isPublic) continue
18
+ if (visibility === 'hidden') continue
19
+ result[branch.alias] = visibility === 'masked' && typeof value === 'string' && value.length > 0
20
+ ? '••••••••'
21
+ : value
22
+ }
23
+ return result
24
+ }
25
+
26
+ export function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
27
+ const projected = applyPublicPolicies(data, seed)
28
+ const requestedFields = (fieldsParam ?? '').split(',').map(f => f.trim()).filter(Boolean)
29
+
30
+ if (requestedFields.length === 0) return projected
31
+
32
+ const filtered: Record<string, unknown> = {}
33
+ for (const field of requestedFields) {
34
+ if (field in projected) filtered[field] = projected[field]
35
+ }
36
+
37
+ for (const key of IDENTITY_FIELDS) {
38
+ if (key in projected && !filtered[key]) filtered[key] = projected[key]
39
+ }
40
+
41
+ return filtered
42
+ }
@@ -0,0 +1,19 @@
1
+ import { sha256hex } from '@beechcms/core'
2
+
3
+ export function parseIdempotencyKey(rawValue: string | undefined): string | null {
4
+ if (!rawValue) return null
5
+ const key = rawValue.trim()
6
+ if (!key || key.length > 128) return null
7
+ return key
8
+ }
9
+
10
+ type FingerprintInput = {
11
+ seedSlug: string
12
+ statusValue: unknown
13
+ slug: string | null
14
+ data: Record<string, unknown>
15
+ }
16
+
17
+ export function buildRequestFingerprint(input: FingerprintInput): Promise<string> {
18
+ return sha256hex(JSON.stringify({ seedSlug: input.seedSlug, statusValue: input.statusValue, slug: input.slug, data: input.data }))
19
+ }
@@ -28,6 +28,11 @@ function normalizeProblemType(type: string): string {
28
28
  return `https://beechcms.dev/problems/${type}`
29
29
  }
30
30
 
31
+ export function internalErrorDetail(env: { ENV?: string }, error: unknown): string {
32
+ if (env.ENV !== 'production' && error instanceof Error) return error.message
33
+ return 'An unexpected error occurred.'
34
+ }
35
+
31
36
  /**
32
37
  * Restituisce errori API in formato Problem Details (RFC 9457).
33
38
  */
@@ -1,156 +1,110 @@
1
- import { isValidContentStatus, SlugConflictError, sha256hex } from '@beechcms/core'
2
- import type { Context } from 'hono'
3
- import { cleanStr } from '../shared/query-utils'
4
- import { checkPublicOperation } from './access-policy'
5
- import { publicProblem } from './problem-details'
6
- import { generateEntrySlug, slugify } from './slug-utils'
7
- import { sanitizePublicPayload } from './sanitize'
8
- import { AppEnv } from '../types'
9
-
10
- function errorMessage(context: Context<AppEnv>, error: unknown): string {
11
- if (context.env.ENV !== 'production' && error instanceof Error) return error.message
12
- return 'An unexpected error occurred.'
13
- }
14
-
15
- function asRecord(value: unknown): Record<string, unknown> | null {
16
- return value !== null && typeof value === 'object' && !Array.isArray(value)
17
- ? (value as Record<string, unknown>)
18
- : null
19
- }
20
-
21
- function pickSlugFromBody(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
22
- const explicitSlug = cleanStr(body.slug)
23
- if (explicitSlug) return slugify(explicitSlug)
24
- return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
25
- }
26
-
27
- function parseIdempotencyKey(rawValue: string | undefined): string | null {
28
- if (!rawValue) return null
29
- const key = rawValue.trim()
30
- if (!key || key.length > 128) return null
31
- return key
32
- }
33
-
34
-
35
- export async function publicAddHandler(context: Context<AppEnv>) {
36
- const seedSlug = context.req.param('seed') ?? ''
37
- const seed = context.get('getSeed')(seedSlug)
38
- if (!seed) {
39
- return publicProblem(context, {
40
- type: 'seed-not-found',
41
- title: 'Seed Not Found',
42
- status: 404,
43
- detail: `The content type '${seedSlug}' does not exist.`
44
- })
45
- }
46
-
47
- const access = checkPublicOperation(seed, 'add')
48
- if (!access.ok) {
49
- return publicProblem(context, {
50
- type: 'operation-not-allowed',
51
- title: access.error.error,
52
- status: 403,
53
- detail: access.error.message
54
- })
55
- }
56
-
57
- let body: Record<string, unknown>
58
- try {
59
- const parsed = await context.req.json<unknown>()
60
- body = asRecord(parsed) ?? {}
61
- } catch {
62
- return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
63
- }
64
-
65
- const rawData = asRecord(body.data)
66
- if (!rawData || Object.keys(rawData).length === 0) {
67
- return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
68
- }
69
-
70
- const statusValue = body.status ?? 'draft'
71
- if (!isValidContentStatus(statusValue)) {
72
- return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
73
- }
74
-
75
- const sanitized = sanitizePublicPayload(seed, rawData, {
76
- operation: 'create',
77
- allowNull: false,
78
- requireAtLeastOneValidField: true,
79
- enforceRequiredFields: true,
80
- })
81
-
82
- if (!sanitized.ok) {
83
- if (sanitized.status === 422) {
84
- return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
85
- }
86
- return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
87
- }
88
-
89
- const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
90
- const entrySlug = pickSlugFromBody(body, sanitized.data)
91
- const finalSlug = entrySlug || context.get('idGenerator').uuid().slice(0, 8)
92
- const repository = context.get('repository')
93
- const idempotencyRepository = context.get('idempotencyRepository')
94
-
95
- try {
96
- const now = Math.floor(Date.now() / 1000)
97
- const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
98
- const fingerprint = await sha256hex(fingerprintPayload)
99
- const idempotencyTtlSeconds = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
100
-
101
- if (idempotencyKey) {
102
- const existing = await idempotencyRepository.lookup(idempotencyKey)
103
- if (existing && existing.expiresAt >= now) {
104
- if (existing.fingerprint !== fingerprint) {
105
- return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
106
- }
107
- let parsedBody: unknown = null
108
- try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
109
- return context.json(parsedBody, existing.responseStatus as 201)
110
- }
111
- }
112
-
113
- const id = context.get('idGenerator').uuid()
114
-
115
- try {
116
- await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
117
- } catch (error) {
118
- if (error instanceof SlugConflictError) {
119
- return publicProblem(context, {
120
- type: 'slug-conflict',
121
- title: 'Conflict',
122
- status: 409,
123
- detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.`
124
- })
125
- }
126
- throw error
127
- }
128
-
129
- const responseBody = { success: true, id, slug: finalSlug }
130
- if (idempotencyKey) {
131
- await idempotencyRepository.store({
132
- key: idempotencyKey,
133
- fingerprint,
134
- responseStatus: 201,
135
- responseBody: JSON.stringify(responseBody),
136
- expiresAt: now + idempotencyTtlSeconds
137
- })
138
- }
139
-
140
- context.get('notificationService').notify({
141
- title: `${seed.label}: New entry`,
142
- message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
143
- type: 'success',
144
- })
145
-
146
- return context.json(responseBody, 201)
147
- } catch (error) {
148
- console.error('Public add error:', error)
149
- return publicProblem(context, {
150
- type: 'internal-server-error',
151
- title: 'Internal Server Error',
152
- status: 500,
153
- detail: errorMessage(context, error)
154
- })
155
- }
156
- }
1
+ import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
2
+ import type { Context } from 'hono'
3
+ import { cleanStr } from '../shared/query-utils'
4
+ import { checkPublicOperation } from './access-policy'
5
+ import { publicProblem, internalErrorDetail } from './problem-details'
6
+ import { generateEntrySlug, slugify } from './slug-utils'
7
+ import { sanitizePublicPayload } from './sanitize'
8
+ import { parseIdempotencyKey, buildRequestFingerprint } from './idempotency'
9
+ import { AppEnv } from '../types'
10
+
11
+ function asRecord(value: unknown): Record<string, unknown> | null {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
13
+ ? (value as Record<string, unknown>)
14
+ : null
15
+ }
16
+
17
+ function pickSlug(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
18
+ const explicit = cleanStr(body.slug)
19
+ if (explicit) return slugify(explicit)
20
+ return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
21
+ }
22
+
23
+ export async function publicAddHandler(context: Context<AppEnv>) {
24
+ const seedSlug = context.req.param('seed') ?? ''
25
+ const seed = context.get('getSeed')(seedSlug)
26
+ if (!seed) {
27
+ return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
28
+ }
29
+
30
+ const access = checkPublicOperation(seed, 'add')
31
+ if (!access.ok) {
32
+ return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
33
+ }
34
+
35
+ let body: Record<string, unknown>
36
+ try {
37
+ const parsed = await context.req.json<unknown>()
38
+ body = asRecord(parsed) ?? {}
39
+ } catch {
40
+ return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
41
+ }
42
+
43
+ const rawData = asRecord(body.data)
44
+ if (!rawData || Object.keys(rawData).length === 0) {
45
+ return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
46
+ }
47
+
48
+ const statusValue = body.status ?? 'draft'
49
+ if (!isValidContentStatus(statusValue)) {
50
+ return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
51
+ }
52
+
53
+ const sanitized = sanitizePublicPayload(seed, rawData, { operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true })
54
+ if (!sanitized.ok) {
55
+ if (sanitized.status === 422) {
56
+ return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
57
+ }
58
+ return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
59
+ }
60
+
61
+ const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
62
+ const finalSlug = pickSlug(body, sanitized.data) || context.get('idGenerator').uuid().slice(0, 8)
63
+ const repository = context.get('repository')
64
+ const idempotencyRepository = context.get('idempotencyRepository')
65
+
66
+ try {
67
+ const now = Math.floor(Date.now() / 1000)
68
+ const fingerprint = await buildRequestFingerprint({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
69
+ const idempotencyTtl = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
70
+
71
+ if (idempotencyKey) {
72
+ const existing = await idempotencyRepository.lookup(idempotencyKey)
73
+ if (existing && existing.expiresAt >= now) {
74
+ if (existing.fingerprint !== fingerprint) {
75
+ return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
76
+ }
77
+ let parsedBody: unknown = null
78
+ try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
79
+ return context.json(parsedBody, existing.responseStatus as 201)
80
+ }
81
+ }
82
+
83
+ const id = context.get('idGenerator').uuid()
84
+
85
+ try {
86
+ await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
87
+ } catch (error) {
88
+ if (error instanceof SlugConflictError) {
89
+ return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
90
+ }
91
+ throw error
92
+ }
93
+
94
+ const responseBody = { success: true, id, slug: finalSlug }
95
+ if (idempotencyKey) {
96
+ await idempotencyRepository.store({ key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), expiresAt: now + idempotencyTtl })
97
+ }
98
+
99
+ context.get('notificationService').notify({
100
+ title: `${seed.label}: New entry`,
101
+ message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
102
+ type: 'success',
103
+ })
104
+
105
+ return context.json(responseBody, 201)
106
+ } catch (error) {
107
+ console.error('Public add error:', error)
108
+ return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: internalErrorDetail(context.env, error) })
109
+ }
110
+ }