betterstart-cli 0.0.95 → 0.0.96

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 (31) hide show
  1. package/dist/assets/adapters/next/integrations/_shared/actions/object-key.ts +16 -0
  2. package/dist/assets/adapters/next/integrations/r2/actions/r2.ts +2 -10
  3. package/dist/assets/adapters/next/integrations/r2/integration.ts +4 -0
  4. package/dist/assets/adapters/next/integrations/railway-bucket/actions/railway-bucket.ts +2 -10
  5. package/dist/assets/adapters/next/integrations/railway-bucket/integration.ts +4 -0
  6. package/dist/assets/adapters/next/integrations/vercel-blob/actions/vercel-blob.ts +2 -10
  7. package/dist/assets/adapters/next/integrations/vercel-blob/integration.ts +4 -0
  8. package/dist/assets/adapters/next/templates/init/api/upload-route.ts +37 -13
  9. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-color-highlight.ts +10 -64
  10. package/dist/assets/adapters/next/templates/init/hooks/content-editor/use-content-editor-source-mode.tsx +11 -4
  11. package/dist/assets/adapters/next/templates/init/lib/actions/audit/get-audit-logs.ts +1 -9
  12. package/dist/assets/adapters/next/templates/init/lib/actions/media/create-media.ts +6 -6
  13. package/dist/assets/adapters/next/templates/init/lib/actions/media/update-media.ts +5 -5
  14. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhook-deliveries.ts +1 -10
  15. package/dist/assets/adapters/next/templates/init/lib/actions/webhooks/get-webhooks.ts +1 -10
  16. package/dist/assets/adapters/next/templates/init/pages/settings/forms/forms-settings-page-content.tsx +1 -1
  17. package/dist/assets/adapters/next/templates/init/utils/date/date.ts +15 -0
  18. package/dist/assets/adapters/next/templates/init/utils/editor/content-editor.ts +98 -25
  19. package/dist/assets/adapters/next/templates/init/utils/editor/markdown.ts +5 -2
  20. package/dist/assets/shared-assets/react-admin/custom/content-editor/selection-bubble-menu.tsx +2 -2
  21. package/dist/assets/shared-assets/react-admin/custom/content-editor/source-mode.tsx +4 -2
  22. package/dist/assets/shared-assets/react-admin/custom/media-field.tsx +2 -2
  23. package/dist/assets/shared-assets/react-admin/schema.json +1143 -876
  24. package/dist/{chunk-3CLINNJL.js → chunk-LGZBMR7E.js} +33 -9
  25. package/dist/chunk-LGZBMR7E.js.map +1 -0
  26. package/dist/cli.js +2338 -2872
  27. package/dist/cli.js.map +1 -1
  28. package/dist/{template-reader-PVN53GS7.js → template-reader-WRTNGRZV.js} +2 -2
  29. package/package.json +1 -1
  30. package/dist/chunk-3CLINNJL.js.map +0 -1
  31. /package/dist/{template-reader-PVN53GS7.js.map → template-reader-WRTNGRZV.js.map} +0 -0
@@ -0,0 +1,16 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ function sanitizeFilename(filename: string): string {
4
+ return filename.replace(/[^a-zA-Z0-9.-]/g, '_')
5
+ }
6
+
7
+ /**
8
+ * Object-key naming scheme for every upload, shared by all storage providers.
9
+ *
10
+ * Single owner: a project that switches storage providers must keep producing
11
+ * consistent keys. This previously existed as an identical copy inside each of
12
+ * the R2, Railway Bucket, and Vercel Blob payloads.
13
+ */
14
+ export function buildStorageObjectKey(filename: string, prefix = 'uploads'): string {
15
+ return `${prefix}/${Date.now()}-${randomUUID()}-${sanitizeFilename(filename)}`
16
+ }
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto'
1
+ import { buildStorageObjectKey } from '@admin/actions/storage/object-key'
2
2
  import type { AdminStorageProvider, AdminStorageUploadInput } from '@admin/actions/storage/types'
3
3
  import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
4
4
 
@@ -14,14 +14,6 @@ function getConfig() {
14
14
  }
15
15
  }
16
16
 
17
- function sanitizeFilename(filename: string): string {
18
- return filename.replace(/[^a-zA-Z0-9.-]/g, '_')
19
- }
20
-
21
- function buildKey(filename: string, prefix = 'uploads'): string {
22
- return `${prefix}/${Date.now()}-${randomUUID()}-${sanitizeFilename(filename)}`
23
- }
24
-
25
17
  function getClient(): S3Client {
26
18
  if (r2Client) {
27
19
  return r2Client
@@ -58,7 +50,7 @@ export const r2StorageProvider: AdminStorageProvider = {
58
50
  throw new Error('Cloudflare R2 is not fully configured.')
59
51
  }
60
52
 
61
- const key = buildKey(input.filename, input.prefix)
53
+ const key = buildStorageObjectKey(input.filename, input.prefix)
62
54
  await getClient().send(
63
55
  new PutObjectCommand({
64
56
  Bucket: config.bucketName,
@@ -9,6 +9,10 @@ export const r2Integration: BetterstartIntegrationDefinition = {
9
9
  dependencies: [],
10
10
  conflicts: ['vercel-blob', 'railway-bucket'],
11
11
  files: [
12
+ {
13
+ outputPath: 'lib/actions/storage/object-key.ts',
14
+ templatePath: '../_shared/actions/object-key.ts'
15
+ },
12
16
  {
13
17
  outputPath: 'lib/actions/r2/provider.ts',
14
18
  templatePath: 'actions/r2.ts'
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto'
1
+ import { buildStorageObjectKey } from '@admin/actions/storage/object-key'
2
2
  import type { AdminStorageProvider, AdminStorageUploadInput } from '@admin/actions/storage/types'
3
3
  import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
4
4
  import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
@@ -16,14 +16,6 @@ function getConfig() {
16
16
  }
17
17
  }
18
18
 
19
- function sanitizeFilename(filename: string): string {
20
- return filename.replace(/[^a-zA-Z0-9.-]/g, '_')
21
- }
22
-
23
- function buildKey(filename: string, prefix = 'uploads'): string {
24
- return `${prefix}/${Date.now()}-${randomUUID()}-${sanitizeFilename(filename)}`
25
- }
26
-
27
19
  function stableReadUrl(key: string): string {
28
20
  return `/api/admin/storage/${Buffer.from(key, 'utf-8').toString('base64url')}`
29
21
  }
@@ -63,7 +55,7 @@ export const railwayBucketStorageProvider = {
63
55
  throw new Error('Railway Bucket is not fully configured.')
64
56
  }
65
57
 
66
- const key = buildKey(input.filename, input.prefix)
58
+ const key = buildStorageObjectKey(input.filename, input.prefix)
67
59
  await getClient().send(
68
60
  new PutObjectCommand({
69
61
  Bucket: config.bucketName,
@@ -9,6 +9,10 @@ export const railwayBucketIntegration: BetterstartIntegrationDefinition = {
9
9
  dependencies: [],
10
10
  conflicts: ['r2', 'vercel-blob'],
11
11
  files: [
12
+ {
13
+ outputPath: 'lib/actions/storage/object-key.ts',
14
+ templatePath: '../_shared/actions/object-key.ts'
15
+ },
12
16
  {
13
17
  outputPath: 'lib/actions/railway-bucket/provider.ts',
14
18
  templatePath: 'actions/railway-bucket.ts'
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto'
1
+ import { buildStorageObjectKey } from '@admin/actions/storage/object-key'
2
2
  import type { AdminStorageProvider, AdminStorageUploadInput } from '@admin/actions/storage/types'
3
3
  import { put } from '@vercel/blob'
4
4
 
@@ -6,14 +6,6 @@ function getToken(): string {
6
6
  return process.env.BLOB_READ_WRITE_TOKEN?.trim() ?? ''
7
7
  }
8
8
 
9
- function sanitizeFilename(filename: string): string {
10
- return filename.replace(/[^a-zA-Z0-9.-]/g, '_')
11
- }
12
-
13
- function buildKey(filename: string, prefix = 'uploads'): string {
14
- return `${prefix}/${Date.now()}-${randomUUID()}-${sanitizeFilename(filename)}`
15
- }
16
-
17
9
  export const vercelBlobStorageProvider: AdminStorageProvider = {
18
10
  id: 'vercel-blob',
19
11
  isConfigured() {
@@ -24,7 +16,7 @@ export const vercelBlobStorageProvider: AdminStorageProvider = {
24
16
  throw new Error('Vercel Blob is not configured.')
25
17
  }
26
18
 
27
- const key = buildKey(input.filename, input.prefix)
19
+ const key = buildStorageObjectKey(input.filename, input.prefix)
28
20
  const blob = await put(key, input.buffer, {
29
21
  access: 'public',
30
22
  contentType: input.contentType,
@@ -9,6 +9,10 @@ export const vercelBlobIntegration: BetterstartIntegrationDefinition = {
9
9
  dependencies: [],
10
10
  conflicts: ['r2', 'railway-bucket'],
11
11
  files: [
12
+ {
13
+ outputPath: 'lib/actions/storage/object-key.ts',
14
+ templatePath: '../_shared/actions/object-key.ts'
15
+ },
12
16
  {
13
17
  outputPath: 'lib/actions/vercel-blob/provider.ts',
14
18
  templatePath: 'actions/vercel-blob.ts'
@@ -1,9 +1,12 @@
1
+ import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
1
2
  import { mediaCacheTags } from '@admin/actions/media/types'
2
3
  import { saveUpload } from '@admin/actions/storage'
3
- import { getSession } from '@admin/auth/middleware'
4
+ import { requireRole, UserRole } 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'
6
8
  import type { UploadedFile } from '@admin/types'
9
+ import { getTouchedFieldNames } from '@admin/utils/audit/audit'
7
10
  import { mapMediaRow, selectMedia } from '@admin/utils/media/query'
8
11
  import { validateFiles } from '@admin/utils/validation/validation'
9
12
  import { eq } from 'drizzle-orm'
@@ -11,6 +14,8 @@ import { revalidateTag } from 'next/cache'
11
14
  import { type NextRequest, NextResponse } from 'next/server'
12
15
 
13
16
  export async function POST(request: NextRequest) {
17
+ const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
18
+
14
19
  try {
15
20
  const formData = await request.formData()
16
21
  const prefix = formData.get('prefix')?.toString() || 'uploads'
@@ -56,8 +61,7 @@ export async function POST(request: NextRequest) {
56
61
  }
57
62
 
58
63
  const uploadedFiles: UploadedFile[] = []
59
- const session = await getSession()
60
- const actorId = session?.user?.id ?? null
64
+ const auditRequestMetadata = await getAuditRequestMetadata()
61
65
 
62
66
  for (const [index, file] of files.entries()) {
63
67
  const arrayBuffer = await file.arrayBuffer()
@@ -74,21 +78,25 @@ export async function POST(request: NextRequest) {
74
78
  const heightStr = formData.get(`height${index}`)?.toString()
75
79
  const now = new Date().toISOString()
76
80
 
81
+ const mediaValues = {
82
+ url,
83
+ filename: file.name,
84
+ contentType: file.type,
85
+ size: file.size,
86
+ width: widthStr ? Number(widthStr) : null,
87
+ height: heightStr ? Number(heightStr) : null,
88
+ alt: null,
89
+ tags: null
90
+ }
91
+
77
92
  const [created] = await db
78
93
  .insert(adminMedia)
79
94
  .values({
80
- url,
81
- filename: file.name,
82
- contentType: file.type,
83
- size: file.size,
84
- width: widthStr ? Number(widthStr) : null,
85
- height: heightStr ? Number(heightStr) : null,
86
- alt: null,
87
- tags: null,
95
+ ...mediaValues,
88
96
  createdAt: now,
89
97
  updatedAt: now,
90
- createdBy: actorId,
91
- updatedBy: actorId
98
+ createdBy: actor.id,
99
+ updatedBy: actor.id
92
100
  })
93
101
  .returning({ id: adminMedia.id })
94
102
 
@@ -99,6 +107,8 @@ export async function POST(request: NextRequest) {
99
107
  )
100
108
  }
101
109
 
110
+ // Route handlers cannot call updateTag(), so revalidateTag() with an
111
+ // immediate expiry is the equivalent invalidation used by the actions.
102
112
  revalidateTag(mediaCacheTags.all, { expire: 0 })
103
113
  revalidateTag(mediaCacheTags.byId(created.id), { expire: 0 })
104
114
 
@@ -112,6 +122,20 @@ export async function POST(request: NextRequest) {
112
122
 
113
123
  const media = mapMediaRow(mediaRow)
114
124
 
125
+ runAdminEventHooks({
126
+ category: 'media',
127
+ action: 'create',
128
+ targetType: 'media',
129
+ targetId: media.id,
130
+ targetLabel: media.filename,
131
+ user: actor,
132
+ timestamp: now,
133
+ sourceType: 'admin_ui' as const,
134
+ changedFields: getTouchedFieldNames(mediaValues),
135
+ metadata: { contentType: media.contentType, size: media.size },
136
+ ...auditRequestMetadata
137
+ })
138
+
115
139
  uploadedFiles.push({
116
140
  id: media.id,
117
141
  key,
@@ -4,6 +4,11 @@ import { useIsBreakpoint } from '@admin/hooks/content-editor/use-is-breakpoint'
4
4
  // --- Hooks ---
5
5
  import { useTiptapEditor } from '@admin/hooks/content-editor/use-tiptap-editor'
6
6
  // --- Lib ---
7
+ import {
8
+ CONTENT_EDITOR_HIGHLIGHT_COLOR_OPTIONS,
9
+ CONTENT_EDITOR_HIGHLIGHT_COLORS,
10
+ type ContentEditorHighlightColorOption
11
+ } from '@admin/utils/editor/content-editor'
7
12
  import {
8
13
  isExtensionAvailable,
9
14
  isMarkInSchema,
@@ -16,69 +21,10 @@ import { useCallback, useEffect, useState } from 'react'
16
21
  import { useHotkeys } from 'react-hotkeys-hook'
17
22
 
18
23
  export const COLOR_HIGHLIGHT_SHORTCUT_KEY = 'mod+shift+h'
19
- export const HIGHLIGHT_COLORS = [
20
- {
21
- label: 'Default background',
22
- value: 'var(--tt-bg-color)',
23
- colorValue: '#ffffff',
24
- border: 'var(--tt-bg-color-contrast)'
25
- },
26
- {
27
- label: 'Gray background',
28
- value: 'var(--tt-color-highlight-gray)',
29
- colorValue: '#f8f8f7',
30
- border: 'var(--tt-color-highlight-gray-contrast)'
31
- },
32
- {
33
- label: 'Brown background',
34
- value: 'var(--tt-color-highlight-brown)',
35
- colorValue: '#f4eeee',
36
- border: 'var(--tt-color-highlight-brown-contrast)'
37
- },
38
- {
39
- label: 'Orange background',
40
- value: 'var(--tt-color-highlight-orange)',
41
- colorValue: '#fbecdd',
42
- border: 'var(--tt-color-highlight-orange-contrast)'
43
- },
44
- {
45
- label: 'Yellow background',
46
- value: 'var(--tt-color-highlight-yellow)',
47
- colorValue: '#fef9c3',
48
- border: 'var(--tt-color-highlight-yellow-contrast)'
49
- },
50
- {
51
- label: 'Green background',
52
- value: 'var(--tt-color-highlight-green)',
53
- colorValue: '#dcfce7',
54
- border: 'var(--tt-color-highlight-green-contrast)'
55
- },
56
- {
57
- label: 'Blue background',
58
- value: 'var(--tt-color-highlight-blue)',
59
- colorValue: '#e0f2fe',
60
- border: 'var(--tt-color-highlight-blue-contrast)'
61
- },
62
- {
63
- label: 'Purple background',
64
- value: 'var(--tt-color-highlight-purple)',
65
- colorValue: '#f3e8ff',
66
- border: 'var(--tt-color-highlight-purple-contrast)'
67
- },
68
- {
69
- label: 'Pink background',
70
- value: 'var(--tt-color-highlight-pink)',
71
- colorValue: '#fcf1f6',
72
- border: 'var(--tt-color-highlight-pink-contrast)'
73
- },
74
- {
75
- label: 'Red background',
76
- value: 'var(--tt-color-highlight-red)',
77
- colorValue: '#ffe4e6',
78
- border: 'var(--tt-color-highlight-red-contrast)'
79
- }
80
- ]
81
- export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]
24
+ export const HIGHLIGHT_COLORS = CONTENT_EDITOR_HIGHLIGHT_COLORS.map(
25
+ (name) => CONTENT_EDITOR_HIGHLIGHT_COLOR_OPTIONS[name]
26
+ )
27
+ export type HighlightColor = ContentEditorHighlightColorOption
82
28
 
83
29
  export type HighlightMode = 'mark' | 'node'
84
30
 
@@ -133,7 +79,7 @@ export function pickHighlightColorsByValue(values: string[]) {
133
79
  const colorMap = new Map(HIGHLIGHT_COLORS.map((color) => [color.value, color]))
134
80
  return values
135
81
  .map((value) => colorMap.get(value))
136
- .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
82
+ .filter((color): color is ContentEditorHighlightColorOption => !!color)
137
83
  }
138
84
 
139
85
  /**
@@ -26,7 +26,7 @@ import {
26
26
  } from '@admin/utils/editor/markdown'
27
27
  import { createMarkdownTextFromUrl } from '@admin/utils/editor/source-media'
28
28
  import { sanitizeUrl } from '@admin/utils/editor/tiptap'
29
- import { redo, undo } from '@codemirror/commands'
29
+ import { redo, redoDepth, undo, undoDepth } from '@codemirror/commands'
30
30
  import { markdown } from '@codemirror/lang-markdown'
31
31
  import { Decoration, EditorView, type ViewUpdate, WidgetType } from '@codemirror/view'
32
32
  import { type QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'
@@ -147,6 +147,8 @@ export function useContentEditorSourceMode({
147
147
  } | null>(null)
148
148
  const [markdownLinkUrl, setMarkdownLinkUrl] = React.useState<string | null>(null)
149
149
  const [isMarkdownLinkActive, setIsMarkdownLinkActive] = React.useState(false)
150
+ const [canUndoMarkdownChange, setCanUndoMarkdownChange] = React.useState(false)
151
+ const [canRedoMarkdownChange, setCanRedoMarkdownChange] = React.useState(false)
150
152
  const { resolvedTheme } = useTheme()
151
153
  const editorTheme = React.useMemo(
152
154
  () =>
@@ -313,8 +315,11 @@ export function useContentEditorSourceMode({
313
315
  [onChange, onSelectionChange]
314
316
  )
315
317
 
316
- const handleSelectionUpdate = React.useCallback(
318
+ const handleEditorUpdate = React.useCallback(
317
319
  (viewUpdate: ViewUpdate) => {
320
+ setCanUndoMarkdownChange(undoDepth(viewUpdate.state) > 0)
321
+ setCanRedoMarkdownChange(redoDepth(viewUpdate.state) > 0)
322
+
318
323
  if (!viewUpdate.selectionSet) return
319
324
  syncMarkdownLinkState(viewUpdate.view)
320
325
  onSelectionChange?.(viewUpdate.state.selection.main.from)
@@ -348,7 +353,7 @@ export function useContentEditorSourceMode({
348
353
  () => [
349
354
  markdown(),
350
355
  EditorView.lineWrapping,
351
- EditorView.updateListener.of(handleSelectionUpdate),
356
+ EditorView.updateListener.of(handleEditorUpdate),
352
357
  EditorView.theme({
353
358
  '&': {
354
359
  backgroundColor: 'var(--background)',
@@ -397,7 +402,7 @@ export function useContentEditorSourceMode({
397
402
  }),
398
403
  ...mediaGalleryExtensions
399
404
  ],
400
- [handleSelectionUpdate, mediaGalleryExtensions]
405
+ [handleEditorUpdate, mediaGalleryExtensions]
401
406
  )
402
407
 
403
408
  const insertInline = React.useCallback(
@@ -614,6 +619,8 @@ export function useContentEditorSourceMode({
614
619
  applyHighlight,
615
620
  applyStrike,
616
621
  applyUnderline,
622
+ canRedoMarkdownChange,
623
+ canUndoMarkdownChange,
617
624
  codeMirrorExtensions,
618
625
  editorRef,
619
626
  editorTheme,
@@ -3,6 +3,7 @@
3
3
  import { requireRole, UserRole } from '@admin/auth/middleware'
4
4
  import db from '@admin/db'
5
5
  import { auditLog } from '@admin/db/schema'
6
+ import { getExclusiveDateUpperBound } from '@admin/utils/date/date'
6
7
  import { and, asc, desc, eq, gte, ilike, lt, or, sql } from 'drizzle-orm'
7
8
  import type { AuditLogEntry, AuditLogPage, AuditLogQuery } from './types'
8
9
 
@@ -47,15 +48,6 @@ function getAuditLogOrderBy(query: AuditLogQuery) {
47
48
  }
48
49
  }
49
50
 
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
51
  async function queryAuditLogs(query: AuditLogQuery = {}): Promise<AuditLogPage> {
60
52
  const page = clampPage(query.page)
61
53
  const pageSize = normalizePageSize(query.pageSize)
@@ -1,7 +1,7 @@
1
1
  'use server'
2
2
 
3
3
  import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
4
- import { getSession } from '@admin/auth/middleware'
4
+ import { requireRole, UserRole } from '@admin/auth/middleware'
5
5
  import db from '@admin/db'
6
6
  import { adminMedia } from '@admin/db/schema'
7
7
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
@@ -11,9 +11,9 @@ import { getMediaById } from './get-media-by-id'
11
11
  import { type CreateMediaInput, type CreateMediaResult, mediaCacheTags } from './types'
12
12
 
13
13
  export async function createMedia(data: CreateMediaInput): Promise<CreateMediaResult> {
14
+ const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
15
+
14
16
  try {
15
- const session = await getSession()
16
- const actorId = session?.user?.id ?? null
17
17
  const now = new Date().toISOString()
18
18
  const auditRequestMetadata = await getAuditRequestMetadata()
19
19
  const [row] = await db
@@ -22,8 +22,8 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
22
22
  ...data,
23
23
  createdAt: now,
24
24
  updatedAt: now,
25
- createdBy: actorId,
26
- updatedBy: actorId
25
+ createdBy: actor.id,
26
+ updatedBy: actor.id
27
27
  })
28
28
  .returning({ id: adminMedia.id })
29
29
  if (row?.id) {
@@ -38,7 +38,7 @@ export async function createMedia(data: CreateMediaInput): Promise<CreateMediaRe
38
38
  targetType: 'media',
39
39
  targetId: media.id,
40
40
  targetLabel: media.filename,
41
- user: session?.user ?? null,
41
+ user: actor,
42
42
  timestamp: now,
43
43
  sourceType: 'admin_ui' as const,
44
44
  changedFields: getTouchedFieldNames(data as unknown as Record<string, unknown>),
@@ -1,7 +1,7 @@
1
1
  'use server'
2
2
 
3
3
  import { getAuditRequestMetadata } from '@admin/actions/audit/request-metadata'
4
- import { getSession } from '@admin/auth/middleware'
4
+ import { requireRole, UserRole } from '@admin/auth/middleware'
5
5
  import db from '@admin/db'
6
6
  import { adminMedia } from '@admin/db/schema'
7
7
  import { runAdminEventHooks } from '@admin/lib/lifecycle-hooks'
@@ -15,9 +15,9 @@ export async function updateMedia(
15
15
  id: string,
16
16
  data: { alt?: string; tags?: string[] }
17
17
  ): Promise<UpdateMediaResult> {
18
+ const actor = await requireRole([UserRole.ADMIN, UserRole.EDITOR])
19
+
18
20
  try {
19
- const session = await getSession()
20
- const actorId = session?.user?.id ?? null
21
21
  const now = new Date().toISOString()
22
22
  const auditRequestMetadata = await getAuditRequestMetadata()
23
23
  const [previousMedia] = await db.select().from(adminMedia).where(eq(adminMedia.id, id)).limit(1)
@@ -26,7 +26,7 @@ export async function updateMedia(
26
26
  .set({
27
27
  ...data,
28
28
  updatedAt: now,
29
- updatedBy: actorId
29
+ updatedBy: actor.id
30
30
  })
31
31
  .where(eq(adminMedia.id, id))
32
32
  updateTag(mediaCacheTags.all)
@@ -37,7 +37,7 @@ export async function updateMedia(
37
37
  targetType: 'media',
38
38
  targetId: id,
39
39
  targetLabel: previousMedia?.filename ?? id,
40
- user: session?.user ?? null,
40
+ user: actor,
41
41
  timestamp: now,
42
42
  sourceType: 'admin_ui' as const,
43
43
  changedFields: getChangedFieldNames(
@@ -3,19 +3,10 @@
3
3
  import { requireRole, UserRole } from '@admin/auth/middleware'
4
4
  import db from '@admin/db'
5
5
  import { webhookDeliveries, webhooks } from '@admin/db/schema'
6
+ import { getExclusiveDateUpperBound } from '@admin/utils/date/date'
6
7
  import { and, asc, desc, eq, gte, ilike, lt, or, sql } from 'drizzle-orm'
7
8
  import type { WebhookDeliveriesPage, WebhookDeliveriesQuery } from './types'
8
9
 
9
- function getExclusiveDateUpperBound(value?: string): string | null {
10
- if (!value) return null
11
-
12
- const date = new Date(`${value}T00:00:00.000Z`)
13
- if (Number.isNaN(date.getTime())) return null
14
-
15
- date.setUTCDate(date.getUTCDate() + 1)
16
- return date.toISOString().slice(0, 10)
17
- }
18
-
19
10
  const SORTABLE_COLUMNS = {
20
11
  createdAt: webhookDeliveries.createdAt,
21
12
  event: webhookDeliveries.event,
@@ -3,19 +3,10 @@
3
3
  import { requireRole, UserRole } from '@admin/auth/middleware'
4
4
  import db from '@admin/db'
5
5
  import { webhookDeliveries, webhookSubscriptions, webhooks } from '@admin/db/schema'
6
+ import { getExclusiveDateUpperBound } from '@admin/utils/date/date'
6
7
  import { and, asc, count, desc, eq, gte, ilike, isNull, lt, or, sql } from 'drizzle-orm'
7
8
  import type { WebhooksPage, WebhooksQuery } from './types'
8
9
 
9
- function getExclusiveDateUpperBound(value?: string): string | null {
10
- if (!value) return null
11
-
12
- const date = new Date(`${value}T00:00:00.000Z`)
13
- if (Number.isNaN(date.getTime())) return null
14
-
15
- date.setUTCDate(date.getUTCDate() + 1)
16
- return date.toISOString().slice(0, 10)
17
- }
18
-
19
10
  // Deliberately uncached: filters and sorting depend on delivery rows written
20
11
  // inside after(), where cache invalidation is unavailable, so cached reads
21
12
  // could never be refreshed.
@@ -36,7 +36,7 @@ export function FormsSettingsPageContent() {
36
36
  return (
37
37
  <React.Fragment>
38
38
  <PageHeader title="Form Settings" />
39
- <main className="space-y-4 px-4 pb-4 flex-1">
39
+ <main className="space-y-4 px-4 pb-4 flex-1 pt-4">
40
40
  <FormsSettingsTable
41
41
  columns={columns}
42
42
  rows={rows}
@@ -8,6 +8,21 @@ const DATE_RANGE_TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
8
8
  export const DATE_RANGE_START_TIME = '00:00'
9
9
  export const DATE_RANGE_END_TIME = '23:59'
10
10
 
11
+ /**
12
+ * Turn an inclusive `YYYY-MM-DD` "to date" filter into the exclusive upper bound
13
+ * that follows it. The input is always read as UTC so the same filter selects the
14
+ * same rows regardless of the viewer's timezone.
15
+ */
16
+ export function getExclusiveDateUpperBound(value?: string): string | null {
17
+ if (!value) return null
18
+
19
+ const date = new Date(`${value}T00:00:00.000Z`)
20
+ if (Number.isNaN(date.getTime())) return null
21
+
22
+ date.setUTCDate(date.getUTCDate() + 1)
23
+ return date.toISOString().slice(0, 10)
24
+ }
25
+
11
26
  export function parseDateInputValue(value?: string): Date | undefined {
12
27
  if (!value) return undefined
13
28