betterstart-cli 0.0.12 → 0.0.14

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 (99) hide show
  1. package/dist/assets/adapters/next/plugins/blog/plugin.ts +1 -1
  2. package/dist/assets/adapters/next/plugins/blog/schemas/menus.json +155 -0
  3. package/dist/assets/adapters/next/plugins/blog/schemas/posts.json +2 -7
  4. package/dist/assets/adapters/next/templates/init/components/layouts/admin-sidebar.tsx +9 -3
  5. package/dist/assets/adapters/next/templates/init/components/layouts/sidebar-branding.tsx +1 -1
  6. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-grid.tsx +107 -0
  7. package/dist/assets/adapters/next/templates/init/components/shared/data-table/data-table.tsx +58 -45
  8. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode/lifecycle-hooks-tab.tsx +1 -1
  9. package/dist/assets/adapters/next/templates/init/components/shared/dev-mode-integrate.tsx +1 -1
  10. package/dist/assets/adapters/next/templates/init/components/shared/entity-filters-bar.tsx +1 -1
  11. package/dist/assets/adapters/next/templates/init/components/shared/entity-versions/entity-versions-drawer.tsx +1 -1
  12. package/dist/assets/adapters/next/templates/init/components/shared/page-header.tsx +20 -17
  13. package/dist/assets/adapters/next/templates/init/components/shared/sort-indicator.tsx +7 -3
  14. package/dist/assets/adapters/next/templates/init/data/navigation.ts +4 -1
  15. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-slash-menu.ts +28 -2
  16. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor.ts +22 -1
  17. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-table-button.ts +85 -0
  18. package/dist/assets/adapters/next/templates/init/hooks/use-audit-log.ts +23 -0
  19. package/dist/assets/adapters/next/templates/init/lib/actions/audit/cleanup-audit-logs.ts +63 -0
  20. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +122 -0
  21. package/dist/assets/adapters/next/templates/init/lib/actions/audit/index.ts +19 -0
  22. package/dist/assets/adapters/next/templates/init/lib/actions/audit/record-audit-event.ts +37 -0
  23. package/dist/assets/adapters/next/templates/init/lib/actions/audit/register.ts +13 -0
  24. package/dist/assets/adapters/next/templates/init/lib/actions/audit/request-metadata.ts +37 -0
  25. package/dist/assets/adapters/next/templates/init/lib/actions/audit/types.ts +77 -0
  26. package/dist/assets/adapters/next/templates/init/lib/actions/forms/upsert-form-settings.ts +3 -0
  27. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +19 -0
  28. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media-bulk.ts +23 -0
  29. package/dist/assets/adapters/next/templates/init/lib/actions/media/delete-media.ts +21 -0
  30. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +23 -1
  31. package/dist/assets/adapters/next/templates/init/lib/actions/profile/update-email.ts +17 -0
  32. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-files.ts +3 -0
  33. package/dist/assets/adapters/next/templates/init/lib/actions/upload/upload-media-from-url.ts +3 -0
  34. package/dist/assets/adapters/next/templates/init/lib/actions/users/create-user.ts +19 -1
  35. package/dist/assets/adapters/next/templates/init/lib/actions/users/delete-user.ts +23 -1
  36. package/dist/assets/adapters/next/templates/init/lib/actions/users/update-user-role.ts +23 -1
  37. package/dist/assets/adapters/next/templates/init/lib/db/core/schema.ts +49 -0
  38. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/index.ts +6 -1
  39. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/register.ts +3 -0
  40. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/registry.ts +36 -1
  41. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/runner.ts +117 -12
  42. package/dist/assets/adapters/next/templates/init/lib/lifecycle-hooks/types.ts +35 -1
  43. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-drawer.tsx +104 -0
  44. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-detail-row.tsx +15 -0
  45. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-content.tsx +565 -0
  46. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page-skeleton.tsx +7 -0
  47. package/dist/assets/adapters/next/templates/init/pages/audit-log/audit-log-page.tsx +14 -0
  48. package/dist/assets/adapters/next/templates/init/pages/users/users-page-content.tsx +83 -2
  49. package/dist/assets/adapters/next/templates/init/pages/users/users-table.tsx +126 -78
  50. package/dist/assets/adapters/next/templates/init/utils/audit/audit.ts +54 -0
  51. package/dist/assets/adapters/next/templates/init/utils/editor/tiptap.ts +7 -32
  52. package/dist/assets/adapters/next/templates/init/utils/form/list-field.ts +17 -0
  53. package/dist/assets/adapters/next/templates/init/utils/user/user.ts +70 -0
  54. package/dist/assets/shared-assets/react-admin/custom/content-editor/horizontal-rule-button.tsx +1 -1
  55. package/dist/assets/shared-assets/react-admin/custom/content-editor/index.tsx +6 -0
  56. package/dist/assets/shared-assets/react-admin/custom/content-editor/main-toolbar-content.tsx +1 -1
  57. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-button.tsx +1 -1
  58. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-editor-controls.tsx +27 -40
  59. package/dist/assets/shared-assets/react-admin/custom/content-editor/math-popover-button.tsx +0 -1
  60. package/dist/assets/shared-assets/react-admin/custom/content-editor/mobile-toolbar-content.tsx +4 -4
  61. package/dist/assets/shared-assets/react-admin/custom/content-editor/mode-toggle-button.tsx +1 -1
  62. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +45 -8
  63. package/dist/assets/shared-assets/react-admin/custom/content-editor/slash-command-menu.tsx +153 -27
  64. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode-dropdown-button.tsx +1 -1
  65. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +9 -11
  66. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-bubble-menu.tsx +1 -1
  67. package/dist/assets/shared-assets/react-admin/custom/content-editor/table-button.tsx +24 -67
  68. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/blockquote-button/blockquote-button.tsx +1 -1
  69. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/code-block-button/code-block-button.tsx +1 -1
  70. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-button.tsx +1 -1
  71. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover-content.tsx +1 -1
  72. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +11 -2
  73. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/color-highlight-popover/source-color-highlight-popover.tsx +1 -1
  74. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu-item.tsx +1 -1
  75. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +9 -4
  76. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-button.tsx +1 -1
  77. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-control-popover.tsx +5 -12
  78. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-main.tsx +19 -45
  79. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/link-popover/link-popover.tsx +2 -3
  80. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu-item.tsx +1 -1
  81. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +9 -4
  82. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/mark-button/mark-button.tsx +1 -1
  83. package/dist/assets/shared-assets/react-admin/custom/content-editor/tiptap-ui/undo-redo-button/undo-redo-button.tsx +1 -1
  84. package/dist/assets/shared-assets/react-admin/custom/date-range-picker.tsx +2 -2
  85. package/dist/assets/shared-assets/react-admin/custom/dynamic-list-field.tsx +20 -8
  86. package/dist/assets/shared-assets/react-admin/custom/media-gallery-field.tsx +1 -1
  87. package/dist/assets/shared-assets/react-admin/custom/nested-object-list-field.tsx +211 -0
  88. package/dist/assets/shared-assets/react-admin/schema.json +136 -23
  89. package/dist/assets/shared-assets/react-admin/ui/button.tsx +18 -18
  90. package/dist/assets/shared-assets/react-admin/ui/card.tsx +1 -1
  91. package/dist/assets/shared-assets/react-admin/ui/checkbox.tsx +1 -1
  92. package/dist/assets/shared-assets/react-admin/ui/combobox.tsx +27 -3
  93. package/dist/assets/shared-assets/react-admin/ui/drawer.tsx +9 -5
  94. package/dist/assets/shared-assets/react-admin/ui/dropdown-menu.tsx +27 -12
  95. package/dist/assets/shared-assets/react-admin/ui/form.tsx +1 -1
  96. package/dist/assets/shared-assets/react-admin/ui/sidebar.tsx +4 -4
  97. package/dist/cli.js +5396 -2385
  98. package/dist/cli.js.map +1 -1
  99. package/package.json +10 -2
@@ -15,12 +15,13 @@ import * as React from 'react'
15
15
 
16
16
  export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | null>) {
17
17
  const [slashMenu, setSlashMenu] = React.useState<SlashMenuState | null>(null)
18
+ const [slashSearchQuery, setSlashSearchQuery] = React.useState('')
18
19
  const [activeSlashIndexState, setActiveSlashIndexState] = React.useState(0)
19
20
  const slashMenuRef = React.useRef<SlashMenuState | null>(null)
20
21
  const activeSlashIndexRef = React.useRef(0)
21
22
  const filteredSlashCommands = React.useMemo(
22
- () => getFilteredSlashCommands(slashMenu?.query ?? ''),
23
- [slashMenu]
23
+ () => getFilteredSlashCommands(slashSearchQuery),
24
+ [slashSearchQuery]
24
25
  )
25
26
  const filteredSlashCommandsRef = React.useRef(filteredSlashCommands)
26
27
  const activeSlashIndex =
@@ -37,6 +38,23 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
37
38
  isSameSlashMenuState(current, nextSlashMenu) ? current : nextSlashMenu
38
39
  )
39
40
 
41
+ setSlashSearchQuery((current) => {
42
+ const currentSlashMenu = slashMenuRef.current
43
+
44
+ if (!nextSlashMenu) return ''
45
+
46
+ if (
47
+ currentSlashMenu &&
48
+ currentSlashMenu.from === nextSlashMenu.from &&
49
+ currentSlashMenu.to === nextSlashMenu.to &&
50
+ currentSlashMenu.query === nextSlashMenu.query
51
+ ) {
52
+ return current
53
+ }
54
+
55
+ return nextSlashMenu.query
56
+ })
57
+
40
58
  setActiveSlashIndexState((current) =>
41
59
  slashMenuRef.current?.query === nextSlashMenu?.query && nextSlashMenu ? current : 0
42
60
  )
@@ -44,6 +62,12 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
44
62
 
45
63
  const closeSlashMenu = React.useCallback(() => {
46
64
  setSlashMenu(null)
65
+ setSlashSearchQuery('')
66
+ setActiveSlashIndexState(0)
67
+ }, [])
68
+
69
+ const setSlashSearch = React.useCallback((query: string) => {
70
+ setSlashSearchQuery(query)
47
71
  setActiveSlashIndexState(0)
48
72
  }, [])
49
73
 
@@ -110,7 +134,9 @@ export function useContentEditorSlashMenu(editorRef: React.RefObject<Editor | nu
110
134
  handleSlashKeyDown,
111
135
  selectSlashCommand,
112
136
  setActiveSlashIndex: setActiveSlashIndexState,
137
+ setSlashSearch,
113
138
  slashMenu,
139
+ slashSearchQuery,
114
140
  updateSlashMenu
115
141
  }
116
142
  }
@@ -66,6 +66,7 @@ export function useContentEditor({
66
66
  const isInternalChange = React.useRef(false)
67
67
  const isSyncingRichContent = React.useRef(false)
68
68
  const editorRef = React.useRef<Editor | null>(null)
69
+ const isSlashMenuOpenRef = React.useRef(false)
69
70
  const {
70
71
  activeSlashIndex,
71
72
  closeSlashMenu,
@@ -73,10 +74,14 @@ export function useContentEditor({
73
74
  handleSlashKeyDown,
74
75
  selectSlashCommand,
75
76
  setActiveSlashIndex,
77
+ setSlashSearch,
76
78
  slashMenu,
79
+ slashSearchQuery,
77
80
  updateSlashMenu
78
81
  } = useContentEditorSlashMenu(editorRef)
79
82
 
83
+ isSlashMenuOpenRef.current = slashMenu !== null
84
+
80
85
  if (value !== lastValuePropRef.current) {
81
86
  lastValuePropRef.current = value
82
87
  latestMarkdownRef.current = value
@@ -158,7 +163,20 @@ export function useContentEditor({
158
163
  updateSlashMenu(nextEditor)
159
164
  },
160
165
  onSelectionUpdate: ({ editor: nextEditor }) => updateSlashMenu(nextEditor),
161
- onBlur: closeSlashMenu
166
+ onBlur: ({ event }) => {
167
+ const nextFocusTarget = event.relatedTarget
168
+
169
+ if (isSlashMenuOpenRef.current && !nextFocusTarget) return
170
+
171
+ if (
172
+ nextFocusTarget instanceof Element &&
173
+ nextFocusTarget.closest('[data-content-editor-slash-menu]')
174
+ ) {
175
+ return
176
+ }
177
+
178
+ closeSlashMenu()
179
+ }
162
180
  })
163
181
 
164
182
  editorRef.current = editor
@@ -278,9 +296,12 @@ export function useContentEditor({
278
296
  handleSourceChange,
279
297
  handleSourceSelectionChange,
280
298
  mode,
299
+ closeSlashMenu,
281
300
  selectSlashCommand,
282
301
  setActiveSlashIndex,
302
+ setSlashSearch,
283
303
  slashMenu,
304
+ slashSearchQuery,
284
305
  sourceValue: latestMarkdownRef.current,
285
306
  sourceSelectionRequest,
286
307
  syncRichContent
@@ -0,0 +1,85 @@
1
+ 'use client'
2
+
3
+ import { normalizeCount } from '@admin/utils/editor/table-input'
4
+ import { insertContentEditorTable } from '@admin/utils/editor/tiptap'
5
+ import type { Editor } from '@tiptap/core'
6
+ import * as React from 'react'
7
+
8
+ const DEFAULT_TABLE_ROWS = 3
9
+ const DEFAULT_TABLE_COLS = 3
10
+ const MIN_TABLE_ROWS = 2
11
+ const MIN_TABLE_COLS = 1
12
+
13
+ interface UseTableButtonOptions {
14
+ disabled?: boolean
15
+ editor?: Editor | null
16
+ onInsertTable?: (size: { cols: number; rows: number }) => void
17
+ }
18
+
19
+ export function useTableButton({ disabled, editor, onInsertTable }: UseTableButtonOptions) {
20
+ const [isOpen, setIsOpen] = React.useState(false)
21
+ const [rows, setRows] = React.useState(String(DEFAULT_TABLE_ROWS))
22
+ const [cols, setCols] = React.useState(String(DEFAULT_TABLE_COLS))
23
+ const rowsId = React.useId()
24
+ const colsId = React.useId()
25
+ const canInsert = Boolean(onInsertTable || editor?.isEditable)
26
+ const isDisabled = disabled || !canInsert
27
+
28
+ const closePopover = React.useCallback(() => {
29
+ setIsOpen(false)
30
+ window.setTimeout(() => setIsOpen(false), 0)
31
+ }, [])
32
+
33
+ const insertTable = React.useCallback(() => {
34
+ if (isDisabled) return
35
+
36
+ const rowCount = normalizeCount(rows, DEFAULT_TABLE_ROWS, MIN_TABLE_ROWS)
37
+ const colCount = normalizeCount(cols, DEFAULT_TABLE_COLS, MIN_TABLE_COLS)
38
+
39
+ if (onInsertTable) {
40
+ onInsertTable({ cols: colCount, rows: rowCount })
41
+ closePopover()
42
+ return
43
+ }
44
+
45
+ if (!editor) return
46
+
47
+ insertContentEditorTable(editor, rowCount, colCount)
48
+ closePopover()
49
+ }, [closePopover, cols, editor, isDisabled, onInsertTable, rows])
50
+
51
+ const handleKeyDown = React.useCallback(
52
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
53
+ if (event.key !== 'Enter') return
54
+
55
+ event.preventDefault()
56
+ event.stopPropagation()
57
+ insertTable()
58
+ },
59
+ [insertTable]
60
+ )
61
+
62
+ const handleRowsChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
63
+ setRows(event.target.value)
64
+ }, [])
65
+
66
+ const handleColsChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
67
+ setCols(event.target.value)
68
+ }, [])
69
+
70
+ return {
71
+ cols,
72
+ colsId,
73
+ handleColsChange,
74
+ handleKeyDown,
75
+ handleRowsChange,
76
+ insertTable,
77
+ isDisabled,
78
+ isOpen,
79
+ minCols: MIN_TABLE_COLS,
80
+ minRows: MIN_TABLE_ROWS,
81
+ rows,
82
+ rowsId,
83
+ setIsOpen
84
+ }
85
+ }
@@ -0,0 +1,23 @@
1
+ 'use client'
2
+
3
+ import type { AuditLogPage, AuditLogQuery, CleanupAuditLogsResult } from '@admin/actions/audit'
4
+ import { cleanupAuditLogs, getAuditLogs } from '@admin/actions/audit'
5
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
6
+
7
+ export function useAuditLogs(query: AuditLogQuery) {
8
+ return useQuery<AuditLogPage>({
9
+ queryKey: ['audit-log', query],
10
+ queryFn: () => getAuditLogs(query)
11
+ })
12
+ }
13
+
14
+ export function useCleanupAuditLogs() {
15
+ const queryClient = useQueryClient()
16
+
17
+ return useMutation<CleanupAuditLogsResult>({
18
+ mutationFn: () => cleanupAuditLogs(),
19
+ onSuccess: () => {
20
+ void queryClient.invalidateQueries({ queryKey: ['audit-log'] })
21
+ }
22
+ })
23
+ }
@@ -0,0 +1,63 @@
1
+ 'use server'
2
+
3
+ import { getSetting } from '@admin/actions/settings'
4
+ import { requireRole, UserRole } from '@admin/auth/middleware'
5
+ import db from '@admin/db'
6
+ import { auditLog } from '@admin/db/schema'
7
+ import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
8
+ import { lt } from 'drizzle-orm'
9
+ import { getAuditRequestMetadata } from './request-metadata'
10
+ import type { CleanupAuditLogsResult } from './types'
11
+
12
+ const MS_PER_DAY = 24 * 60 * 60 * 1000
13
+
14
+ function getRetentionDays(value: unknown): number | null {
15
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
16
+ const days = Math.floor(value)
17
+ return days > 0 ? days : null
18
+ }
19
+
20
+ export async function cleanupAuditLogs(): Promise<CleanupAuditLogsResult> {
21
+ const actor = await requireRole([UserRole.ADMIN])
22
+
23
+ try {
24
+ const settings = await getSetting()
25
+ const retentionEnabled = settings?.auditRetentionEnabled === true
26
+ const retentionDays = getRetentionDays(settings?.auditRetentionDays)
27
+
28
+ if (!retentionEnabled || !retentionDays) {
29
+ return { success: true, deletedCount: 0, skipped: true }
30
+ }
31
+
32
+ const cutoff = new Date(Date.now() - retentionDays * MS_PER_DAY).toISOString()
33
+ const deletedRows = await db
34
+ .delete(auditLog)
35
+ .where(lt(auditLog.createdAt, cutoff))
36
+ .returning({ id: auditLog.id })
37
+
38
+ const requestMetadata = await getAuditRequestMetadata()
39
+ await runAdminEventHooks({
40
+ category: 'system',
41
+ action: 'cleanup',
42
+ targetType: 'audit-log',
43
+ targetLabel: 'Audit log retention cleanup',
44
+ user: actor,
45
+ timestamp: new Date().toISOString(),
46
+ sourceType: 'system_job',
47
+ changedFields: [],
48
+ metadata: {
49
+ cutoff,
50
+ deletedCount: deletedRows.length,
51
+ retentionDays
52
+ },
53
+ ...requestMetadata
54
+ })
55
+
56
+ return { success: true, deletedCount: deletedRows.length, cutoff }
57
+ } catch (error) {
58
+ return {
59
+ success: false,
60
+ error: error instanceof Error ? error.message : 'Failed to clean up audit logs'
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,122 @@
1
+ 'use server'
2
+
3
+ import { requireRole, UserRole } from '@admin/auth/middleware'
4
+ import db from '@admin/db'
5
+ import { auditLog } from '@admin/db/schema'
6
+ import { and, asc, desc, eq, gte, ilike, lt, or, sql } from 'drizzle-orm'
7
+ import type { AuditLogEntry, AuditLogPage, AuditLogQuery } from './types'
8
+
9
+ const DEFAULT_AUDIT_LOG_PAGE = 0
10
+ const DEFAULT_AUDIT_LOG_PAGE_SIZE = 20
11
+ const MAX_AUDIT_LOG_PAGE_SIZE = 100
12
+
13
+ function clampPage(value: number | undefined): number {
14
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_AUDIT_LOG_PAGE
15
+ return Math.max(0, Math.floor(value))
16
+ }
17
+
18
+ function normalizePageSize(value: number | undefined): number | null {
19
+ if (value === -1) return null
20
+
21
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
22
+ return DEFAULT_AUDIT_LOG_PAGE_SIZE
23
+ }
24
+
25
+ return Math.min(MAX_AUDIT_LOG_PAGE_SIZE, Math.max(1, Math.floor(value)))
26
+ }
27
+
28
+ function getAuditLogOrderBy(query: AuditLogQuery) {
29
+ const sortFn = query.dir === 'asc' ? asc : desc
30
+
31
+ switch (query.sort) {
32
+ case 'event':
33
+ return [sortFn(auditLog.category), sortFn(auditLog.action), desc(auditLog.createdAt)]
34
+ case 'target':
35
+ return [
36
+ sortFn(auditLog.targetType),
37
+ sortFn(auditLog.targetLabel),
38
+ sortFn(auditLog.targetId),
39
+ desc(auditLog.createdAt)
40
+ ]
41
+ case 'actor':
42
+ return [sortFn(auditLog.actorEmail), sortFn(auditLog.actorName), desc(auditLog.createdAt)]
43
+ case 'sourceType':
44
+ return [sortFn(auditLog.sourceType), desc(auditLog.createdAt)]
45
+ default:
46
+ return [sortFn(auditLog.createdAt)]
47
+ }
48
+ }
49
+
50
+ function getExclusiveDateUpperBound(value: string | undefined): string | null {
51
+ if (!value) return null
52
+ const date = new Date(value)
53
+ if (Number.isNaN(date.getTime())) return null
54
+
55
+ date.setUTCDate(date.getUTCDate() + 1)
56
+ return date.toISOString()
57
+ }
58
+
59
+ async function queryAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
60
+ const page = clampPage(query.page)
61
+ const pageSize = normalizePageSize(query.pageSize)
62
+ const offset = pageSize ? page * pageSize : 0
63
+ const conditions = []
64
+
65
+ if (query.category) conditions.push(eq(auditLog.category, query.category))
66
+ if (query.action) conditions.push(eq(auditLog.action, query.action))
67
+ if (query.targetType) conditions.push(ilike(auditLog.targetType, `%${query.targetType}%`))
68
+ if (query.actor) {
69
+ const actorPattern = `%${query.actor}%`
70
+ conditions.push(
71
+ or(ilike(auditLog.actorEmail, actorPattern), ilike(auditLog.actorName, actorPattern))
72
+ )
73
+ }
74
+ if (query.createdAtFrom) {
75
+ conditions.push(gte(auditLog.createdAt, query.createdAtFrom))
76
+ }
77
+ const createdAtExclusiveTo = getExclusiveDateUpperBound(query.createdAtTo)
78
+ if (createdAtExclusiveTo) {
79
+ conditions.push(lt(auditLog.createdAt, createdAtExclusiveTo))
80
+ }
81
+ if (query.search) {
82
+ const searchPattern = `%${query.search}%`
83
+ conditions.push(
84
+ or(
85
+ ilike(auditLog.targetId, searchPattern),
86
+ ilike(auditLog.targetLabel, searchPattern),
87
+ ilike(auditLog.actorEmail, searchPattern),
88
+ ilike(auditLog.actorName, searchPattern),
89
+ ilike(auditLog.sourceType, searchPattern),
90
+ ilike(auditLog.ipAddress, searchPattern),
91
+ ilike(auditLog.userAgent, searchPattern)
92
+ )
93
+ )
94
+ }
95
+
96
+ const where = conditions.length > 0 ? and(...conditions) : undefined
97
+ const orderBy = getAuditLogOrderBy(query)
98
+ const orderedQuery = db
99
+ .select()
100
+ .from(auditLog)
101
+ .where(where)
102
+ .orderBy(...orderBy)
103
+ const [rows, countResult] = await Promise.all([
104
+ pageSize ? orderedQuery.limit(pageSize).offset(offset) : orderedQuery,
105
+ db.select({ count: sql<number>`count(*)` }).from(auditLog).where(where)
106
+ ])
107
+ const total = Number(countResult[0]?.count ?? 0)
108
+ const resolvedPageSize = pageSize ?? total
109
+
110
+ return {
111
+ auditLogs: rows as AuditLogEntry[],
112
+ total,
113
+ page,
114
+ pageSize: resolvedPageSize,
115
+ totalPages: pageSize ? Math.max(1, Math.ceil(total / pageSize)) : 1
116
+ }
117
+ }
118
+
119
+ export async function getAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
120
+ await requireRole([UserRole.ADMIN])
121
+ return queryAuditLogs(query)
122
+ }
@@ -0,0 +1,19 @@
1
+ export { cleanupAuditLogs } from './cleanup-audit-logs'
2
+ export { getAuditLogs } from './get-audit-logs'
3
+ export type {
4
+ AuditLogAction,
5
+ AuditLogCategory,
6
+ AuditLogEntry,
7
+ AuditLogPage,
8
+ AuditLogQuery,
9
+ AuditLogSortDirection,
10
+ AuditLogSortField,
11
+ AuditLogSourceType,
12
+ CleanupAuditLogsResult
13
+ } from './types'
14
+ export {
15
+ AUDIT_LOG_ACTIONS,
16
+ AUDIT_LOG_CATEGORIES,
17
+ AUDIT_LOG_SOURCE_TYPES,
18
+ auditLogCacheTags
19
+ } from './types'
@@ -0,0 +1,37 @@
1
+ import db from '@admin/db'
2
+ import { auditLog } from '@admin/db/schema'
3
+ import type { AdminEventContext } from '@admin/lib/lifecycle-hooks/types'
4
+
5
+ const MAX_TARGET_TYPE_LENGTH = 100
6
+ const MAX_SOURCE_TYPE_LENGTH = 50
7
+ const MAX_IP_LENGTH = 100
8
+ const MAX_USER_AGENT_LENGTH = 500
9
+
10
+ function truncate(value: string | null | undefined, maxLength: number): string | null {
11
+ if (!value) return null
12
+ return value.length > maxLength ? value.slice(0, maxLength) : value
13
+ }
14
+
15
+ function normalizeChangedFields(value: readonly string[] | undefined): string[] {
16
+ return [...new Set(value ?? [])].filter(Boolean).sort((a, b) => a.localeCompare(b))
17
+ }
18
+
19
+ export async function recordAuditEvent(event: AdminEventContext): Promise<void> {
20
+ await db.insert(auditLog).values({
21
+ category: event.category,
22
+ action: event.action,
23
+ targetType: truncate(event.targetType, MAX_TARGET_TYPE_LENGTH) ?? 'unknown',
24
+ targetId: event.targetId ?? null,
25
+ targetLabel: event.targetLabel ?? null,
26
+ actorUserId: event.user?.id ?? null,
27
+ actorEmail: event.user?.email ?? null,
28
+ actorName: event.user?.name ?? null,
29
+ actorRole: typeof event.user?.role === 'string' ? event.user.role : null,
30
+ sourceType: truncate(event.sourceType, MAX_SOURCE_TYPE_LENGTH) ?? 'server_action',
31
+ ipAddress: truncate(event.ipAddress, MAX_IP_LENGTH),
32
+ userAgent: truncate(event.userAgent, MAX_USER_AGENT_LENGTH),
33
+ changedFields: normalizeChangedFields(event.changedFields),
34
+ metadata: event.metadata ?? null,
35
+ createdAt: event.timestamp
36
+ })
37
+ }
@@ -0,0 +1,13 @@
1
+ import { registerAdminHook } from '@admin/lib/lifecycle-hooks/registry'
2
+ import { recordAuditEvent } from './record-audit-event'
3
+
4
+ let auditHooksRegistered = false
5
+
6
+ export function registerAuditHooks(): void {
7
+ if (auditHooksRegistered) return
8
+ auditHooksRegistered = true
9
+
10
+ registerAdminHook({}, async (event) => {
11
+ await recordAuditEvent(event)
12
+ })
13
+ }
@@ -0,0 +1,37 @@
1
+ import { headers } from 'next/headers'
2
+
3
+ const MAX_IP_LENGTH = 100
4
+ const MAX_USER_AGENT_LENGTH = 500
5
+
6
+ function truncate(value: string | null, maxLength: number): string | null {
7
+ if (!value) return null
8
+ return value.length > maxLength ? value.slice(0, maxLength) : value
9
+ }
10
+
11
+ function firstHeaderValue(value: string | null): string | null {
12
+ return value?.split(',')[0]?.trim() || null
13
+ }
14
+
15
+ export async function getAuditRequestMetadata(): Promise<{
16
+ ipAddress: string | null
17
+ userAgent: string | null
18
+ }> {
19
+ try {
20
+ const requestHeaders = await headers()
21
+ const ipAddress =
22
+ firstHeaderValue(requestHeaders.get('x-forwarded-for')) ??
23
+ firstHeaderValue(requestHeaders.get('x-real-ip')) ??
24
+ firstHeaderValue(requestHeaders.get('cf-connecting-ip'))
25
+ const userAgent = requestHeaders.get('user-agent')?.trim() || null
26
+
27
+ return {
28
+ ipAddress: truncate(ipAddress, MAX_IP_LENGTH),
29
+ userAgent: truncate(userAgent, MAX_USER_AGENT_LENGTH)
30
+ }
31
+ } catch {
32
+ return {
33
+ ipAddress: null,
34
+ userAgent: null
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,77 @@
1
+ export const AUDIT_LOG_CATEGORIES = [
2
+ 'entity',
3
+ 'form',
4
+ 'settings',
5
+ 'user',
6
+ 'auth',
7
+ 'media',
8
+ 'system'
9
+ ] as const
10
+ export const AUDIT_LOG_ACTIONS = [
11
+ 'create',
12
+ 'update',
13
+ 'delete',
14
+ 'publish',
15
+ 'unpublish',
16
+ 'cleanup'
17
+ ] as const
18
+ export const AUDIT_LOG_SOURCE_TYPES = ['admin_ui', 'server_action', 'system_job'] as const
19
+
20
+ export type AuditLogCategory = (typeof AUDIT_LOG_CATEGORIES)[number]
21
+ export type AuditLogAction = (typeof AUDIT_LOG_ACTIONS)[number]
22
+ export type AuditLogSourceType = (typeof AUDIT_LOG_SOURCE_TYPES)[number]
23
+ export type AuditLogSortField = 'createdAt' | 'event' | 'target' | 'actor' | 'sourceType'
24
+ export type AuditLogSortDirection = 'asc' | 'desc'
25
+
26
+ export interface AuditLogEntry {
27
+ id: string
28
+ category: AuditLogCategory
29
+ action: AuditLogAction
30
+ targetType: string
31
+ targetId: string | null
32
+ targetLabel: string | null
33
+ actorUserId: string | null
34
+ actorEmail: string | null
35
+ actorName: string | null
36
+ actorRole: string | null
37
+ sourceType: AuditLogSourceType
38
+ ipAddress: string | null
39
+ userAgent: string | null
40
+ changedFields: string[]
41
+ metadata: Record<string, unknown> | null
42
+ createdAt: string
43
+ }
44
+
45
+ export interface AuditLogQuery {
46
+ search?: string
47
+ category?: AuditLogCategory
48
+ action?: AuditLogAction
49
+ targetType?: string
50
+ actor?: string
51
+ createdAtFrom?: string
52
+ createdAtTo?: string
53
+ page?: number
54
+ pageSize?: number
55
+ sort?: AuditLogSortField
56
+ dir?: AuditLogSortDirection
57
+ }
58
+
59
+ export interface AuditLogPage {
60
+ auditLogs: AuditLogEntry[]
61
+ total: number
62
+ page: number
63
+ pageSize: number
64
+ totalPages: number
65
+ }
66
+
67
+ export interface CleanupAuditLogsResult {
68
+ success: boolean
69
+ error?: string
70
+ deletedCount?: number
71
+ cutoff?: string
72
+ skipped?: boolean
73
+ }
74
+
75
+ export const auditLogCacheTags = {
76
+ all: 'audit-log:all'
77
+ } as const
@@ -1,5 +1,6 @@
1
1
  'use server'
2
2
 
3
+ import { requireRole, UserRole } from '@admin/auth/middleware'
3
4
  import db from '@admin/db'
4
5
  import { formSettings } from '@admin/db/schema'
5
6
  import { updateTag } from 'next/cache'
@@ -10,6 +11,8 @@ export async function upsertFormSettings(
10
11
  formName: string,
11
12
  data: UpsertFormSettingsInput
12
13
  ): Promise<FormSettingsResult> {
14
+ await requireRole([UserRole.ADMIN, UserRole.EDITOR])
15
+
13
16
  try {
14
17
  const [settings] = await db
15
18
  .insert(formSettings)
@@ -1,8 +1,11 @@
1
1
  'use server'
2
2
 
3
+ import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
3
4
  import { getSession } from '@admin/auth/middleware'
4
5
  import db from '@admin/db'
5
6
  import { adminMedia } from '@admin/db/schema'
7
+ import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
8
+ import { getTouchedFieldNames } from '@admin/utils/audit/audit'
6
9
  import { updateTag } from 'next/cache'
7
10
  import { getMediaById } from './get-media-by-id'
8
11
  import { type CreateMediaInput, type CreateMediaResult, mediaCacheTags } from './types'
@@ -12,6 +15,7 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
12
15
  const session = await getSession()
13
16
  const actorId = session?.user?.id ?? null
14
17
  const now = new Date().toISOString()
18
+ const auditRequestMetadata = await getAuditRequestMetadata()
15
19
  const [row] = await db
16
20
  .insert(adminMedia)
17
21
  .values({
@@ -27,6 +31,21 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
27
31
  updateTag(mediaCacheTags.byId(row.id))
28
32
  }
29
33
  const media = row ? await getMediaById(row.id) : null
34
+ if (media) {
35
+ runAdminEventHooks({
36
+ category: 'media',
37
+ action: 'create',
38
+ targetType: 'media',
39
+ targetId: media.id,
40
+ targetLabel: media.filename,
41
+ user: session?.user ?? null,
42
+ timestamp: now,
43
+ sourceType: 'admin_ui' as const,
44
+ changedFields: getTouchedFieldNames(data as unknown as Record<string, unknown>),
45
+ metadata: { contentType: media.contentType, size: media.size },
46
+ ...auditRequestMetadata
47
+ })
48
+ }
30
49
  return media
31
50
  ? { success: true, media }
32
51
  : { success: false, error: 'Failed to create media record' }