@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
@@ -0,0 +1,10 @@
1
+ export const CONTENT_ERRORS = {
2
+ INVALID_SLUG: 'Invalid slug',
3
+ INVALID_SLUG_OR_ID: 'Invalid slug or id',
4
+ INVALID_JSON_BODY: 'Invalid JSON body',
5
+ NOT_FOUND: 'Not found',
6
+ SEED_NOT_FOUND: 'Seed not found',
7
+ DATABASE_ERROR: 'Database error',
8
+ SLUG_CONFLICT: 'Slug already exists for this schema',
9
+ SENSITIVE_FIELD_EDIT: 'Cannot edit sensitive fields',
10
+ } as const
@@ -0,0 +1,153 @@
1
+ import { Context } from 'hono'
2
+ import {
3
+ slugify,
4
+ isValidContentStatus,
5
+ validateAndSanitizeSeedPayload,
6
+ SlugConflictError
7
+ } from '@beechcms/core'
8
+ import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
9
+ import { publicProblem } from '../../../public/problem-details'
10
+ import { CONTENT_ERRORS } from '../constants'
11
+ import { logActivity } from '../../../shared/activity-logger'
12
+ import { cleanStr } from '../../../shared/query-utils'
13
+ import { AppEnv } from '../../../types'
14
+
15
+ function normalizeBody(raw: unknown): Record<string, unknown> {
16
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
17
+ }
18
+
19
+ function contentValidationProblem(
20
+ context: Context,
21
+ details: Array<{ field: string; expected: string; received: string; message: string }>
22
+ ) {
23
+ return publicProblem(context, {
24
+ type: 'content-validation-failed',
25
+ title: 'Bad Request',
26
+ status: 400,
27
+ detail: 'Validation failed',
28
+ errors: details
29
+ })
30
+ }
31
+
32
+ export async function createHandler(context: Context<AppEnv>) {
33
+ const slug = context.req.param('slug')
34
+ if (!slug) {
35
+ return publicProblem(context, {
36
+ type: 'content-invalid-slug',
37
+ title: 'Bad Request',
38
+ status: 400,
39
+ detail: CONTENT_ERRORS.INVALID_SLUG
40
+ })
41
+ }
42
+
43
+ const seed = context.get('getSeed')(slug)
44
+ if (!seed) {
45
+ return publicProblem(context, {
46
+ type: 'content-seed-not-found',
47
+ title: 'Not Found',
48
+ status: 404,
49
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
50
+ })
51
+ }
52
+
53
+ let body: Record<string, unknown>
54
+ try {
55
+ body = normalizeBody(await context.req.json<unknown>())
56
+ } catch {
57
+ return publicProblem(context, {
58
+ type: 'content-invalid-json',
59
+ title: 'Bad Request',
60
+ status: 400,
61
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
62
+ })
63
+ }
64
+
65
+ const entrySlug = body.slug ? slugify(String(body.slug)) : null
66
+ const status = cleanStr(body.status) ?? 'draft'
67
+ if (!isValidContentStatus(status)) {
68
+ return publicProblem(context, {
69
+ type: 'content-invalid-status',
70
+ title: 'Bad Request',
71
+ status: 400,
72
+ detail: 'Invalid status. Allowed values are: draft, review, published'
73
+ })
74
+ }
75
+
76
+ const bodyForData = { ...body }
77
+ delete bodyForData.slug
78
+ delete bodyForData.status
79
+
80
+ const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
81
+ operation: 'create',
82
+ allowNull: false,
83
+ requireAtLeastOneValidField: true,
84
+ enforceRequiredFields: true,
85
+ })
86
+
87
+ if (validation.dangerousFields.length > 0) {
88
+ return publicProblem(context, {
89
+ type: 'content-dangerous-content',
90
+ title: 'Unprocessable Entity',
91
+ status: 422,
92
+ detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
93
+ })
94
+ }
95
+
96
+ if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
97
+
98
+ let privacyData: Record<string, unknown>
99
+ try {
100
+ privacyData = await applyPrivacy(validation.data, seed)
101
+ } catch (error) {
102
+ if (error instanceof PrivacyPolicyError) {
103
+ return publicProblem(context, {
104
+ type: 'content-policy-not-implemented',
105
+ title: 'Not Implemented',
106
+ status: 501,
107
+ detail: error.message
108
+ })
109
+ }
110
+ throw error
111
+ }
112
+
113
+ const id = crypto.randomUUID()
114
+ let finalSlug = entrySlug
115
+ if (!finalSlug) {
116
+ const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
117
+ finalSlug = slugify(String(fallbackSource))
118
+ }
119
+
120
+ try {
121
+ const repository = context.get('repository')
122
+ await repository.create(seed, id, finalSlug, status, privacyData)
123
+
124
+ const userId = context.get('jwtPayload')?.sub
125
+ const title = privacyData.title || privacyData.name || finalSlug
126
+
127
+ logActivity(context, {
128
+ action: 'create',
129
+ entityType: 'content',
130
+ entityId: id,
131
+ entitySlug: slug,
132
+ details: { title }
133
+ })
134
+
135
+ return context.json({ id }, 201)
136
+ } catch (error) {
137
+ if (error instanceof SlugConflictError) {
138
+ return publicProblem(context, {
139
+ type: 'content-slug-conflict',
140
+ title: 'Conflict',
141
+ status: 409,
142
+ detail: CONTENT_ERRORS.SLUG_CONFLICT
143
+ })
144
+ }
145
+ console.error('Content create error:', error)
146
+ return publicProblem(context, {
147
+ type: 'content-database-error',
148
+ title: 'Internal Server Error',
149
+ status: 500,
150
+ detail: CONTENT_ERRORS.DATABASE_ERROR
151
+ })
152
+ }
153
+ }
@@ -0,0 +1,76 @@
1
+ import { Context } from 'hono'
2
+ import { EntryNotFoundError } from '@beechcms/core'
3
+ import { deleteR2Objects } from '../../../upload'
4
+ import { extractMediaKeysFromData } from '../../../media-utils'
5
+ import { publicProblem } from '../../../public/problem-details'
6
+ import { CONTENT_ERRORS } from '../constants'
7
+ import { logActivity } from '../../../shared/activity-logger'
8
+ import { AppEnv } from '../../../types'
9
+
10
+ export async function deleteHandler(context: Context<AppEnv>) {
11
+ const schemaSlug = context.req.param('slug')
12
+ const entryId = context.req.param('id')
13
+ if (!schemaSlug || !entryId) {
14
+ return publicProblem(context, {
15
+ type: 'content-invalid-slug-or-id',
16
+ title: 'Bad Request',
17
+ status: 400,
18
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
19
+ })
20
+ }
21
+
22
+ const seed = context.get('getSeed')(schemaSlug)
23
+ if (!seed) {
24
+ return publicProblem(context, {
25
+ type: 'content-seed-not-found',
26
+ title: 'Not Found',
27
+ status: 404,
28
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
29
+ })
30
+ }
31
+
32
+ try {
33
+ const repository = context.get('repository')
34
+ // Repository.delete returns the row data for cleanup
35
+ const { row } = await repository.delete(seed, entryId)
36
+
37
+ const userId = context.get('jwtPayload')?.sub
38
+ const title = row.title || row.name || entryId
39
+
40
+ logActivity(context, {
41
+ action: 'delete',
42
+ entityType: 'content',
43
+ entityId: entryId,
44
+ entitySlug: schemaSlug,
45
+ details: { title }
46
+ })
47
+
48
+
49
+ const r2ObjectKeys = extractMediaKeysFromData(seed, row)
50
+ if (r2ObjectKeys.length > 0) {
51
+ await deleteR2Objects(context, r2ObjectKeys).catch((error) => {
52
+ if (context.env.ENV !== 'production') {
53
+ console.warn('R2 cleanup on delete failed (orphaned files):', error)
54
+ }
55
+ })
56
+ }
57
+
58
+ return context.json({ success: true })
59
+ } catch (error) {
60
+ if (error instanceof EntryNotFoundError) {
61
+ return publicProblem(context, {
62
+ type: 'content-not-found',
63
+ title: 'Not Found',
64
+ status: 404,
65
+ detail: CONTENT_ERRORS.NOT_FOUND
66
+ })
67
+ }
68
+ console.error('Content delete error:', error)
69
+ return publicProblem(context, {
70
+ type: 'content-database-error',
71
+ title: 'Internal Server Error',
72
+ status: 500,
73
+ detail: CONTENT_ERRORS.DATABASE_ERROR
74
+ })
75
+ }
76
+ }
@@ -0,0 +1,45 @@
1
+ import { Context } from 'hono'
2
+ import { publicProblem } from '../../../public/problem-details'
3
+ import { CONTENT_ERRORS } from '../constants'
4
+ import { AppEnv } from '../../../types'
5
+
6
+ export async function facetsHandler(context: Context<AppEnv>) {
7
+ const slug = context.req.param('slug')
8
+ if (!slug) {
9
+ return publicProblem(context, {
10
+ type: 'content-invalid-slug',
11
+ title: 'Bad Request',
12
+ status: 400,
13
+ detail: CONTENT_ERRORS.INVALID_SLUG
14
+ })
15
+ }
16
+
17
+ const seed = context.get('getSeed')(slug)
18
+ if (!seed) {
19
+ return publicProblem(context, {
20
+ type: 'content-seed-not-found',
21
+ title: 'Not Found',
22
+ status: 404,
23
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
24
+ })
25
+ }
26
+
27
+ try {
28
+ const repository = context.get('repository')
29
+ const { statuses, tagsByColumn } = await repository.getFacets(seed)
30
+
31
+ // Legacy format expected by the dashboard
32
+ return context.json({
33
+ statuses: Object.keys(statuses).sort((a, b) => a.localeCompare(b, 'it')),
34
+ tagsByColumnId: tagsByColumn,
35
+ })
36
+ } catch (error) {
37
+ console.error('Content facets error:', error)
38
+ return publicProblem(context, {
39
+ type: 'content-database-error',
40
+ title: 'Internal Server Error',
41
+ status: 500,
42
+ detail: CONTENT_ERRORS.DATABASE_ERROR
43
+ })
44
+ }
45
+ }
@@ -0,0 +1,116 @@
1
+ import { Context } from 'hono'
2
+ import { applyVisibility } from '../../../shared/apply-policies'
3
+ import { publicProblem } from '../../../public/problem-details'
4
+ import { CONTENT_ERRORS } from '../constants'
5
+ import { AppEnv } from '../../../types'
6
+ import { EntryNotFoundError } from '@beechcms/core'
7
+
8
+ export async function getByIdHandler(context: Context<AppEnv>) {
9
+ const slug = context.req.param('slug')
10
+ const id = context.req.param('id')
11
+ if (!slug || !id) {
12
+ return publicProblem(context, {
13
+ type: 'content-invalid-slug-or-id',
14
+ title: 'Bad Request',
15
+ status: 400,
16
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
17
+ })
18
+ }
19
+
20
+ const seed = context.get('getSeed')(slug)
21
+ if (!seed) {
22
+ return publicProblem(context, {
23
+ type: 'content-seed-not-found',
24
+ title: 'Not Found',
25
+ status: 404,
26
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
27
+ })
28
+ }
29
+
30
+ try {
31
+ const repository = context.get('repository')
32
+ const item = await repository.findById(seed, id)
33
+
34
+ let hasPendingDraft = false
35
+ if (seed.allowDrafts) {
36
+ hasPendingDraft = await repository.hasDraft(seed, id)
37
+ }
38
+
39
+ return context.json({
40
+ ...item,
41
+ has_pending_draft: hasPendingDraft,
42
+ data: applyVisibility(item, seed)
43
+ })
44
+ } catch (error) {
45
+ if (error instanceof EntryNotFoundError) {
46
+ return publicProblem(context, {
47
+ type: 'content-not-found',
48
+ title: 'Not Found',
49
+ status: 404,
50
+ detail: CONTENT_ERRORS.NOT_FOUND
51
+ })
52
+ }
53
+ console.error('Content detail error:', error)
54
+ return publicProblem(context, {
55
+ type: 'content-database-error',
56
+ title: 'Internal Server Error',
57
+ status: 500,
58
+ detail: CONTENT_ERRORS.DATABASE_ERROR
59
+ })
60
+ }
61
+ }
62
+
63
+ export async function getBySlugHandler(context: Context<AppEnv>) {
64
+ const schemaSlug = context.req.param('schema_slug')
65
+ const entrySlug = context.req.param('entry_slug')
66
+ if (!schemaSlug || !entrySlug) {
67
+ return publicProblem(context, {
68
+ type: 'content-invalid-slug-or-id',
69
+ title: 'Bad Request',
70
+ status: 400,
71
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
72
+ })
73
+ }
74
+
75
+ const seed = context.get('getSeed')(schemaSlug)
76
+ if (!seed) {
77
+ return publicProblem(context, {
78
+ type: 'content-seed-not-found',
79
+ title: 'Not Found',
80
+ status: 404,
81
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
82
+ })
83
+ }
84
+
85
+ try {
86
+ const repository = context.get('repository')
87
+ const item = await repository.findBySlug(seed, entrySlug)
88
+
89
+ let hasPendingDraft = false
90
+ if (seed.allowDrafts) {
91
+ hasPendingDraft = await repository.hasDraft(seed, item.id)
92
+ }
93
+
94
+ return context.json({
95
+ ...item,
96
+ has_pending_draft: hasPendingDraft,
97
+ data: applyVisibility(item, seed)
98
+ })
99
+ } catch (error) {
100
+ if (error instanceof EntryNotFoundError) {
101
+ return publicProblem(context, {
102
+ type: 'content-not-found',
103
+ title: 'Not Found',
104
+ status: 404,
105
+ detail: CONTENT_ERRORS.NOT_FOUND
106
+ })
107
+ }
108
+ console.error('Content by-slug error:', error)
109
+ return publicProblem(context, {
110
+ type: 'content-database-error',
111
+ title: 'Internal Server Error',
112
+ status: 500,
113
+ detail: CONTENT_ERRORS.DATABASE_ERROR
114
+ })
115
+ }
116
+ }
@@ -0,0 +1,88 @@
1
+ import { Context } from 'hono'
2
+ import { parsePositiveInt, parseQueryFilters, cleanStr, toEngineFilters } from '../../../shared/query-utils'
3
+ import { applyVisibility } from '../../../shared/apply-policies'
4
+ import { publicProblem } from '../../../public/problem-details'
5
+ import { CONTENT_ERRORS } from '../constants'
6
+ import { AppEnv } from '../../../types'
7
+
8
+ export async function listHandler(context: Context<AppEnv>) {
9
+ const slug = context.req.param('slug')
10
+ if (!slug) {
11
+ return publicProblem(context, {
12
+ type: 'content-invalid-slug',
13
+ title: 'Bad Request',
14
+ status: 400,
15
+ detail: CONTENT_ERRORS.INVALID_SLUG
16
+ })
17
+ }
18
+
19
+ const seed = context.get('getSeed')(slug)
20
+ if (!seed) {
21
+ return publicProblem(context, {
22
+ type: 'content-seed-not-found',
23
+ title: 'Not Found',
24
+ status: 404,
25
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
26
+ })
27
+ }
28
+
29
+ try {
30
+ const query = context.req.query()
31
+ const search = cleanStr(query.search) ?? ''
32
+ const sortBy = cleanStr(query.sortBy) ?? ''
33
+ const sortDirRaw = cleanStr(query.sortDir)?.toLowerCase() ?? 'asc'
34
+ const rawFilters = parseQueryFilters(query.filters)
35
+ const engineFilters = toEngineFilters(rawFilters)
36
+
37
+ // Note: repository doesn't yet support has_pending_draft filter/column natively
38
+ // in findMany without SQL manipulation. We'll stick to basic findMany for now
39
+ // and might need to enhance the repository if this feature is critical for v1 of this refactor.
40
+ // The legacy code was doing a lot of SQL injection here.
41
+
42
+ const page = parsePositiveInt(query.page, 1)
43
+ const limit = Math.min(parsePositiveInt(query.limit, 25), 100)
44
+ const offset = (page - 1) * limit
45
+
46
+ const orderBy = sortBy
47
+ ? { column: sortBy, dir: (sortDirRaw === 'desc' ? 'DESC' : 'ASC') as 'ASC' | 'DESC' }
48
+ : undefined
49
+
50
+ const repository = context.get('repository')
51
+ const { items, total } = await repository.findMany(seed, {
52
+ filters: engineFilters,
53
+ orderBy,
54
+ search: search || undefined,
55
+ pagination: { limit, offset },
56
+ })
57
+
58
+ const entries = await Promise.all(items.map(async (item) => {
59
+ // Check for pending draft if allowed
60
+ let hasPendingDraft = false
61
+ if (seed.allowDrafts) {
62
+ hasPendingDraft = await repository.hasDraft(seed, item.id)
63
+ }
64
+
65
+ return {
66
+ ...item,
67
+ has_pending_draft: hasPendingDraft,
68
+ data: applyVisibility(item, seed) // Repository returns "pure" data including system fields
69
+ }
70
+ }))
71
+
72
+ // If no query params (except slug), return array directly (legacy compatibility)
73
+ const hasQueryParams = Boolean(search) || Boolean(sortBy) || Boolean(query.filters) || query.page !== undefined || query.limit !== undefined
74
+ if (!hasQueryParams) {
75
+ return context.json(entries)
76
+ }
77
+
78
+ return context.json({ items: entries, total, page, limit })
79
+ } catch (error) {
80
+ console.error('Content list error:', error)
81
+ return publicProblem(context, {
82
+ type: 'content-database-error',
83
+ title: 'Internal Server Error',
84
+ status: 500,
85
+ detail: CONTENT_ERRORS.DATABASE_ERROR
86
+ })
87
+ }
88
+ }
@@ -0,0 +1,207 @@
1
+ import { Context } from 'hono'
2
+ import {
3
+ slugify,
4
+ isValidContentStatus,
5
+ validateAndSanitizeSeedPayload,
6
+ resolvePolicies,
7
+ EntryNotFoundError,
8
+ SlugConflictError
9
+ } from '@beechcms/core'
10
+ import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
11
+ import { publicProblem } from '../../../public/problem-details'
12
+ import { CONTENT_ERRORS } from '../constants'
13
+ import { logActivity } from '../../../shared/activity-logger'
14
+ import { cleanStr } from '../../../shared/query-utils'
15
+ import { AppEnv } from '../../../types'
16
+
17
+ function normalizeBody(raw: unknown): Record<string, unknown> {
18
+ return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
19
+ }
20
+
21
+ function contentValidationProblem(
22
+ context: Context,
23
+ details: Array<{ field: string; expected: string; received: string; message: string }>
24
+ ) {
25
+ return publicProblem(context, {
26
+ type: 'content-validation-failed',
27
+ title: 'Bad Request',
28
+ status: 400,
29
+ detail: 'Validation failed',
30
+ errors: details
31
+ })
32
+ }
33
+
34
+ export async function updateHandler(context: Context<AppEnv>) {
35
+ const slug = context.req.param('slug')
36
+ const id = context.req.param('id')
37
+ if (!slug || !id) {
38
+ return publicProblem(context, {
39
+ type: 'content-invalid-slug-or-id',
40
+ title: 'Bad Request',
41
+ status: 400,
42
+ detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
43
+ })
44
+ }
45
+
46
+ const seed = context.get('getSeed')(slug)
47
+ if (!seed) {
48
+ return publicProblem(context, {
49
+ type: 'content-seed-not-found',
50
+ title: 'Not Found',
51
+ status: 404,
52
+ detail: CONTENT_ERRORS.SEED_NOT_FOUND
53
+ })
54
+ }
55
+
56
+ let body: Record<string, unknown>
57
+ try {
58
+ body = normalizeBody(await context.req.json<unknown>())
59
+ } catch {
60
+ return publicProblem(context, {
61
+ type: 'content-invalid-json',
62
+ title: 'Bad Request',
63
+ status: 400,
64
+ detail: CONTENT_ERRORS.INVALID_JSON_BODY
65
+ })
66
+ }
67
+
68
+ const bodyForData = { ...body }
69
+ delete bodyForData.slug
70
+ delete bodyForData.status
71
+
72
+ try {
73
+ const repository = context.get('repository')
74
+ // We need current data to handle slug and status if not provided,
75
+ // and to verify existence.
76
+ const current = await repository.findById(seed, id)
77
+
78
+ const newSlug = body.slug !== undefined ? slugify(String(body.slug)) : (current.slug as string)
79
+ const newStatus = body.status !== undefined ? (cleanStr(body.status) ?? current.status as string) : current.status as string
80
+
81
+ if (!isValidContentStatus(newStatus)) {
82
+ return publicProblem(context, {
83
+ type: 'content-invalid-status',
84
+ title: 'Bad Request',
85
+ status: 400,
86
+ detail: 'Invalid status. Allowed values are: draft, review, published'
87
+ })
88
+ }
89
+
90
+ if (!newSlug) {
91
+ return publicProblem(context, {
92
+ type: 'content-missing-slug',
93
+ title: 'Bad Request',
94
+ status: 400,
95
+ detail: 'Missing required field: slug'
96
+ })
97
+ }
98
+
99
+ let mergedData: Record<string, unknown> = {}
100
+
101
+ if (Object.keys(bodyForData).length > 0) {
102
+ const sensitiveAliases = Object.keys(bodyForData).filter((alias) => {
103
+ const branch = seed.branches.find((b) => b.alias === alias)
104
+ return branch != null && resolvePolicies(branch).privacy !== 'plain'
105
+ })
106
+
107
+ if (sensitiveAliases.length > 0) {
108
+ return publicProblem(context, {
109
+ type: 'content-sensitive-field-edit',
110
+ title: 'Unprocessable Entity',
111
+ status: 422,
112
+ detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(', ')}`
113
+ })
114
+ }
115
+
116
+ const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
117
+ operation: 'update',
118
+ allowNull: true,
119
+ requireAtLeastOneValidField: true,
120
+ enforceRequiredFields: true,
121
+ })
122
+
123
+ if (validation.dangerousFields.length > 0) {
124
+ return publicProblem(context, {
125
+ type: 'content-dangerous-content',
126
+ title: 'Unprocessable Entity',
127
+ status: 422,
128
+ detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
129
+ })
130
+ }
131
+
132
+ if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
133
+
134
+ let privacyPatch: Record<string, unknown>
135
+ try {
136
+ privacyPatch = await applyPrivacy(validation.data, seed)
137
+ } catch (error) {
138
+ if (error instanceof PrivacyPolicyError) {
139
+ return publicProblem(context, {
140
+ type: 'content-policy-not-implemented',
141
+ title: 'Not Implemented',
142
+ status: 501,
143
+ detail: error.message
144
+ })
145
+ }
146
+ throw error
147
+ }
148
+
149
+ // Remove null values from patch (patch semantics: null = leave unchanged)
150
+ for (const [k, v] of Object.entries(privacyPatch)) {
151
+ if (v !== null) mergedData[k] = v
152
+ }
153
+ }
154
+
155
+ if (newSlug !== current.slug) {
156
+ if (await repository.existsSlug(seed, newSlug, id)) {
157
+ return publicProblem(context, {
158
+ type: 'content-slug-conflict',
159
+ title: 'Conflict',
160
+ status: 409,
161
+ detail: CONTENT_ERRORS.SLUG_CONFLICT
162
+ })
163
+ }
164
+ // Add slug to mergedData if changed
165
+ mergedData.slug = newSlug
166
+ }
167
+
168
+ await repository.update(seed, id, mergedData, newStatus)
169
+
170
+ const userId = context.get('jwtPayload')?.sub
171
+ const title = mergedData.title || mergedData.name || newSlug
172
+
173
+ logActivity(context, {
174
+ action: 'update',
175
+ entityType: 'content',
176
+ entityId: id,
177
+ entitySlug: slug,
178
+ details: { title }
179
+ })
180
+
181
+ return context.json({ success: true })
182
+ } catch (error) {
183
+ if (error instanceof EntryNotFoundError) {
184
+ return publicProblem(context, {
185
+ type: 'content-not-found',
186
+ title: 'Not Found',
187
+ status: 404,
188
+ detail: CONTENT_ERRORS.NOT_FOUND
189
+ })
190
+ }
191
+ if (error instanceof SlugConflictError) {
192
+ return publicProblem(context, {
193
+ type: 'content-slug-conflict',
194
+ title: 'Conflict',
195
+ status: 409,
196
+ detail: CONTENT_ERRORS.SLUG_CONFLICT
197
+ })
198
+ }
199
+ console.error('Content update error:', error)
200
+ return publicProblem(context, {
201
+ type: 'content-database-error',
202
+ title: 'Internal Server Error',
203
+ status: 500,
204
+ detail: CONTENT_ERRORS.DATABASE_ERROR
205
+ })
206
+ }
207
+ }