@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.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- package/src/factory.ts +12 -4
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +8 -0
- package/src/features/content/handlers/delete.ts +8 -1
- package/src/features/content/handlers/update.ts +8 -0
- package/src/features/draft/draft.handler.ts +51 -164
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- package/src/features/settings/settings.handler.ts +2 -1
- package/src/index.ts +40 -8
- package/src/middleware/repository.middleware.ts +32 -1
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -156
- package/src/public/public-read.ts +59 -216
- package/src/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- package/src/shared/automations.repository.d1.ts +146 -0
- package/src/shared/d1-content-scan.repository.test.ts +76 -0
- package/src/shared/execution-context-scheduler.ts +9 -0
- package/src/shared/storage-utils.ts +3 -3
- package/src/types.ts +5 -1
- package/src/upload.ts +3 -2
- package/assets/dashboard/assets/index-CH13idU1.js +0 -554
- 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
|
+
}
|
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
|
-
|
|
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
|
*/
|
package/src/public/public-add.ts
CHANGED
|
@@ -1,156 +1,110 @@
|
|
|
1
|
-
import { isValidContentStatus, SlugConflictError
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return '
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|