@byline/host-tanstack-start 4.14.1 → 4.16.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 (69) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +4 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.js +26 -2
  3. package/dist/admin-shell/chrome/admin-menu-drawer_module.css +1 -1
  4. package/dist/admin-shell/collections/edit.js +142 -2
  5. package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
  6. package/dist/admin-shell/scheduled-publications/list.js +295 -0
  7. package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
  8. package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
  9. package/dist/integrations/analytics-agent.d.ts +20 -0
  10. package/dist/integrations/analytics-agent.js +57 -0
  11. package/dist/integrations/analytics-events.d.ts +51 -0
  12. package/dist/integrations/analytics-events.js +129 -0
  13. package/dist/integrations/serve-analytics-agent.d.ts +8 -0
  14. package/dist/integrations/serve-analytics-agent.js +11 -0
  15. package/dist/routes/analytics-range.d.ts +12 -0
  16. package/dist/routes/analytics-range.js +35 -0
  17. package/dist/routes/create-admin-analytics-route.d.ts +8 -0
  18. package/dist/routes/create-admin-analytics-route.js +108 -0
  19. package/dist/routes/create-admin-layout-route.js +15 -4
  20. package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
  21. package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
  22. package/dist/routes/index.d.ts +2 -0
  23. package/dist/routes/index.js +2 -0
  24. package/dist/server-fns/analytics/index.d.ts +9 -0
  25. package/dist/server-fns/analytics/index.js +2 -0
  26. package/dist/server-fns/analytics/maintenance.d.ts +22 -0
  27. package/dist/server-fns/analytics/maintenance.js +42 -0
  28. package/dist/server-fns/analytics/queries.d.ts +25 -0
  29. package/dist/server-fns/analytics/queries.js +54 -0
  30. package/dist/server-fns/collections/get.d.ts +5 -0
  31. package/dist/server-fns/collections/get.js +19 -3
  32. package/dist/server-fns/collections/index.d.ts +1 -0
  33. package/dist/server-fns/collections/index.js +1 -0
  34. package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
  35. package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
  36. package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
  37. package/dist/server-fns/collections/scheduled-publication.js +157 -0
  38. package/dist/vite/analytics-dev.d.ts +22 -0
  39. package/dist/vite/analytics-dev.js +28 -0
  40. package/dist/vite/index.d.ts +1 -0
  41. package/dist/vite/index.js +1 -0
  42. package/package.json +14 -8
  43. package/src/admin-shell/chrome/admin-menu-drawer.module.css +7 -1
  44. package/src/admin-shell/chrome/admin-menu-drawer.tsx +37 -1
  45. package/src/admin-shell/collections/edit.tsx +140 -1
  46. package/src/admin-shell/scheduled-publications/list.module.css +105 -0
  47. package/src/admin-shell/scheduled-publications/list.tsx +292 -0
  48. package/src/integrations/analytics-agent.tsx +91 -0
  49. package/src/integrations/analytics-events.test.node.ts +245 -0
  50. package/src/integrations/analytics-events.ts +224 -0
  51. package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
  52. package/src/integrations/serve-analytics-agent.ts +19 -0
  53. package/src/routes/analytics-range.ts +55 -0
  54. package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
  55. package/src/routes/create-admin-analytics-route.tsx +109 -0
  56. package/src/routes/create-admin-layout-route.tsx +19 -7
  57. package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
  58. package/src/routes/index.ts +2 -0
  59. package/src/server-fns/analytics/index.ts +23 -0
  60. package/src/server-fns/analytics/maintenance.ts +54 -0
  61. package/src/server-fns/analytics/queries.ts +86 -0
  62. package/src/server-fns/collections/get.ts +30 -1
  63. package/src/server-fns/collections/index.ts +1 -0
  64. package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
  65. package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
  66. package/src/server-fns/collections/scheduled-publication.ts +270 -0
  67. package/src/vite/analytics-dev.test.node.ts +126 -0
  68. package/src/vite/analytics-dev.ts +56 -0
  69. package/src/vite/index.ts +1 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { buildAnalyticsDashboardRange } from './analytics-range.js'
12
+
13
+ describe('buildAnalyticsDashboardRange', () => {
14
+ it('builds inclusive UTC periods across month and year boundaries', () => {
15
+ expect(buildAnalyticsDashboardRange(7, new Date('2027-01-03T23:59:59.999Z'))).toEqual({
16
+ from: '2026-12-28',
17
+ to: '2027-01-03',
18
+ })
19
+ })
20
+
21
+ it('builds year-to-date from January 1 in UTC', () => {
22
+ expect(buildAnalyticsDashboardRange('ytd', new Date('2027-06-03T23:59:59.999Z'))).toEqual({
23
+ from: '2027-01-01',
24
+ to: '2027-06-03',
25
+ })
26
+ })
27
+
28
+ it('builds all-time from the earliest reportable day', () => {
29
+ expect(
30
+ buildAnalyticsDashboardRange('all', new Date('2027-06-03T23:59:59.999Z'), '2024-02-29')
31
+ ).toEqual({ from: '2024-02-29', to: '2027-06-03' })
32
+ })
33
+
34
+ it('uses today for an empty all-time report', () => {
35
+ expect(buildAnalyticsDashboardRange('all', new Date('2027-06-03T23:59:59.999Z'))).toEqual({
36
+ from: '2027-06-03',
37
+ to: '2027-06-03',
38
+ })
39
+ })
40
+
41
+ it('rejects an invalid earliest report day before constructing a range', () => {
42
+ expect(() =>
43
+ buildAnalyticsDashboardRange('all', new Date('2027-06-03T23:59:59.999Z'), '2027-02-30')
44
+ ).toThrow(/real UTC calendar day/u)
45
+ })
46
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createFileRoute, notFound } from '@tanstack/react-router'
10
+
11
+ import type { AnalyticsDashboardData, AnalyticsDashboardPeriod } from '@byline/admin/analytics'
12
+ import { AnalyticsDashboard } from '@byline/admin/analytics/components/dashboard'
13
+ import { isAnalyticsDashboardPeriod } from '@byline/analytics/config'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { z } from 'zod'
16
+
17
+ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
18
+ import { useNavigate } from '../admin-shell/chrome/loose-router.js'
19
+ import {
20
+ getAnalyticsCountries,
21
+ getAnalyticsReferrers,
22
+ getAnalyticsReportCoverage,
23
+ getAnalyticsRuntime,
24
+ getAnalyticsSummary,
25
+ getAnalyticsTop,
26
+ } from '../server-fns/analytics/index.js'
27
+ import { getAdminRoutePath } from './admin-path.js'
28
+ import { buildAnalyticsDashboardRange } from './analytics-range.js'
29
+
30
+ const periodSchema = z.preprocess((value) => {
31
+ if (value == null) return undefined
32
+ if (value === 'ytd' || value === 'all') return value
33
+ return Number(value)
34
+ }, z.custom<AnalyticsDashboardPeriod>((value) => isAnalyticsDashboardPeriod(value)).optional())
35
+
36
+ const searchSchema = z.object({
37
+ period: periodSchema.catch(undefined),
38
+ })
39
+
40
+ interface AnalyticsSearch {
41
+ period?: AnalyticsDashboardPeriod
42
+ }
43
+
44
+ export function createAdminAnalyticsRoute(path: string) {
45
+ const Route: any = createFileRoute(path as never)({
46
+ validateSearch: searchSchema,
47
+ loaderDeps: ({ search }: { search: AnalyticsSearch }) => ({
48
+ period: search.period ?? 30,
49
+ }),
50
+ loader: async ({ deps }: { deps: { period: AnalyticsDashboardPeriod } }) => {
51
+ const runtime = await getAnalyticsRuntime()
52
+ if (!runtime.enabled) throw notFound()
53
+
54
+ const coverage = await getAnalyticsReportCoverage()
55
+ const range = buildAnalyticsDashboardRange(deps.period, new Date(), coverage.summaryFrom)
56
+ const [summary, pages, downloads, referrers, countries] = await Promise.all([
57
+ getAnalyticsSummary({ data: range }),
58
+ getAnalyticsTop({ data: { ...range, kind: 'page', limit: 20 } }),
59
+ getAnalyticsTop({ data: { ...range, kind: 'download', limit: 20 } }),
60
+ getAnalyticsReferrers({ data: { ...range, limit: 20 } }),
61
+ getAnalyticsCountries({ data: range }),
62
+ ])
63
+
64
+ return {
65
+ data: {
66
+ summary,
67
+ pages,
68
+ downloads,
69
+ referrers,
70
+ countries,
71
+ range,
72
+ coverage,
73
+ } satisfies AnalyticsDashboardData,
74
+ period: deps.period,
75
+ }
76
+ },
77
+ component: function AdminAnalyticsComponent() {
78
+ const { data, period } = Route.useLoaderData() as {
79
+ data: AnalyticsDashboardData
80
+ period: AnalyticsDashboardPeriod
81
+ }
82
+ const navigate = useNavigate()
83
+ const { t } = useTranslation('byline-admin')
84
+
85
+ return (
86
+ <>
87
+ <BreadcrumbsClient
88
+ breadcrumbs={[
89
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
90
+ { label: t('analytics.title'), href: getAdminRoutePath('analytics') },
91
+ ]}
92
+ />
93
+ <AnalyticsDashboard
94
+ data={data}
95
+ period={period}
96
+ onPeriodChange={(nextPeriod) => {
97
+ navigate({
98
+ to: getAdminRoutePath('analytics'),
99
+ search: { period: nextPeriod },
100
+ })
101
+ }}
102
+ />
103
+ </>
104
+ )
105
+ },
106
+ })
107
+
108
+ return Route
109
+ }
@@ -41,7 +41,9 @@ import { buildLocaleDefinitions } from '../i18n/locale-definitions.js'
41
41
  import { bylineAdminServices } from '../integrations/byline-admin-services.js'
42
42
  import { BylineAiAdminProvider } from '../integrations/byline-ai.js'
43
43
  import { bylineFieldServices } from '../integrations/byline-field-services.js'
44
+ import { getAnalyticsRuntime } from '../server-fns/analytics/index.js'
44
45
  import { getCurrentAdminUser } from '../server-fns/auth/index.js'
46
+ import { getScheduledPublicationRuntime } from '../server-fns/collections/index.js'
45
47
  import { getActiveLocaleFn, setAdminLocaleFn } from '../server-fns/i18n/index.js'
46
48
  import { getSignInRoutePath } from './sign-in-path.js'
47
49
 
@@ -57,8 +59,12 @@ export function createAdminLayoutRoute(path: string) {
57
59
  // than calling `resolveRequestLocale` directly) keeps
58
60
  // `@tanstack/react-start/server` out of the client bundle — the
59
61
  // same pattern `getCurrentAdminUser` uses for `getAdminRequestContext`.
60
- const activeLocale = await getActiveLocaleFn()
61
- return { user, activeLocale }
62
+ const [activeLocale, scheduledPublicationRuntime, analyticsRuntime] = await Promise.all([
63
+ getActiveLocaleFn(),
64
+ getScheduledPublicationRuntime(),
65
+ getAnalyticsRuntime(),
66
+ ])
67
+ return { user, activeLocale, scheduledPublicationRuntime, analyticsRuntime }
62
68
  } catch {
63
69
  // `getCurrentAdminUser` (via `getAdminRequestContext`) throws
64
70
  // `ERR_UNAUTHENTICATED` or a related auth error when no valid
@@ -71,10 +77,13 @@ export function createAdminLayoutRoute(path: string) {
71
77
  }
72
78
  },
73
79
  component: function AdminLayoutComponent() {
74
- const { user, activeLocale } = Route.useRouteContext() as {
75
- user: Awaited<ReturnType<typeof getCurrentAdminUser>>
76
- activeLocale: LocaleCode
77
- }
80
+ const { user, activeLocale, scheduledPublicationRuntime, analyticsRuntime } =
81
+ Route.useRouteContext() as {
82
+ user: Awaited<ReturnType<typeof getCurrentAdminUser>>
83
+ activeLocale: LocaleCode
84
+ scheduledPublicationRuntime: Awaited<ReturnType<typeof getScheduledPublicationRuntime>>
85
+ analyticsRuntime: Awaited<ReturnType<typeof getAnalyticsRuntime>>
86
+ }
78
87
  const { i18n } = getAdminConfig()
79
88
  const localeDefinitions = buildLocaleDefinitions(
80
89
  i18n.admin.locales,
@@ -109,7 +118,10 @@ export function createAdminLayoutRoute(path: string) {
109
118
  <RouteProgressBar />
110
119
  <AdminAppBar user={user} />
111
120
  <main className={cx('byline-admin-layout-main', layoutStyles.main)}>
112
- <AdminMenuDrawer />
121
+ <AdminMenuDrawer
122
+ analyticsEnabled={analyticsRuntime.enabled}
123
+ scheduledPublicationEnabled={scheduledPublicationRuntime.enabled}
124
+ />
113
125
  <Content>
114
126
  <Outlet />
115
127
  </Content>
@@ -0,0 +1,83 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createFileRoute, notFound } from '@tanstack/react-router'
10
+
11
+ import { useTranslation } from '@byline/i18n/react'
12
+ import { z } from 'zod'
13
+
14
+ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
15
+ import { ScheduledPublicationsView } from '../admin-shell/scheduled-publications/list.js'
16
+ import {
17
+ getScheduledPublicationRuntime,
18
+ listScheduledPublications,
19
+ } from '../server-fns/collections/index.js'
20
+ import { getAdminRoutePath } from './admin-path.js'
21
+ import type { ScheduledPublicationListResponse } from '../server-fns/collections/index.js'
22
+
23
+ // Every member is an optional filter, so an unusable value should narrow to
24
+ // "no filter" rather than take the page down. Without the `.catch`, a hand-typed
25
+ // or stale `?lastAuthorizedBy=…` that is not a UUID replaced the whole view with
26
+ // an error screen quoting the raw regex. `lastAuthorizedBy` has no UI control
27
+ // any more — the authorizer is a column, not a filter — but the parameter and
28
+ // the server fn behind it still work for a caller that constructs the URL.
29
+ const searchSchema = z.object({
30
+ page: z.coerce.number().int().min(1).optional().catch(undefined),
31
+ page_size: z.coerce.number().int().min(1).max(100).optional().catch(undefined),
32
+ state: z.enum(['armed', 'needs_reconfirm']).optional().catch(undefined),
33
+ lastAuthorizedBy: z
34
+ .string()
35
+ .trim()
36
+ .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
37
+ .optional()
38
+ .catch(undefined),
39
+ })
40
+
41
+ type ScheduledPublicationsSearch = z.infer<typeof searchSchema>
42
+
43
+ export function createAdminScheduledPublicationsRoute(path: string) {
44
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
45
+ const Route: any = createFileRoute(path as never)({
46
+ shouldReload: true,
47
+ validateSearch: searchSchema,
48
+ loaderDeps: ({ search }: { search: ScheduledPublicationsSearch }) => search,
49
+ loader: async ({ deps }: { deps: ScheduledPublicationsSearch }) => {
50
+ const runtime = await getScheduledPublicationRuntime()
51
+ if (!runtime.enabled) throw notFound()
52
+ const data = await listScheduledPublications({
53
+ data: {
54
+ page: deps.page,
55
+ pageSize: deps.page_size,
56
+ states: deps.state == null ? undefined : [deps.state],
57
+ lastAuthorizedBy: deps.lastAuthorizedBy,
58
+ },
59
+ })
60
+ return { data }
61
+ },
62
+ component: function AdminScheduledPublicationsComponent() {
63
+ const { data } = Route.useLoaderData() as { data: ScheduledPublicationListResponse }
64
+ const { t } = useTranslation('byline-admin')
65
+ return (
66
+ <>
67
+ <BreadcrumbsClient
68
+ breadcrumbs={[
69
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
70
+ {
71
+ label: t('scheduledPublication.list.title'),
72
+ href: getAdminRoutePath('scheduled-publications'),
73
+ },
74
+ ]}
75
+ />
76
+ <ScheduledPublicationsView data={data} />
77
+ </>
78
+ )
79
+ },
80
+ })
81
+
82
+ return Route
83
+ }
@@ -33,11 +33,13 @@ export { getSignInRoutePath } from './sign-in-path.js'
33
33
 
34
34
  export { createAdminAccountRoute } from './create-admin-account-route.js'
35
35
  export { createAdminActivityRoute } from './create-admin-activity-route.js'
36
+ export { createAdminAnalyticsRoute } from './create-admin-analytics-route.js'
36
37
  export { createAdminDashboardRoute } from './create-admin-dashboard-route.js'
37
38
  export { createAdminLayoutRoute } from './create-admin-layout-route.js'
38
39
  export { createAdminPermissionsRoute } from './create-admin-permissions-route.js'
39
40
  export { createAdminRoleEditRoute } from './create-admin-role-edit-route.js'
40
41
  export { createAdminRolesListRoute } from './create-admin-roles-list-route.js'
42
+ export { createAdminScheduledPublicationsRoute } from './create-admin-scheduled-publications-route.js'
41
43
  export { createAdminUserEditRoute } from './create-admin-user-edit-route.js'
42
44
  export { createAdminUsersListRoute } from './create-admin-users-list-route.js'
43
45
  export { createCollectionApiRoute } from './create-collection-api-route.js'
@@ -0,0 +1,23 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ export {
10
+ type DeleteAnalyticsEventsInput,
11
+ deleteAnalyticsEvents,
12
+ rebuildAnalyticsDay,
13
+ } from './maintenance.js'
14
+ export {
15
+ type AnalyticsRuntimeState,
16
+ type AnalyticsTopInput,
17
+ getAnalyticsCountries,
18
+ getAnalyticsReferrers,
19
+ getAnalyticsReportCoverage,
20
+ getAnalyticsRuntime,
21
+ getAnalyticsSummary,
22
+ getAnalyticsTop,
23
+ } from './queries.js'
@@ -0,0 +1,54 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createServerFn } from '@tanstack/react-start'
10
+
11
+ import { assertAdminActor } from '@byline/admin'
12
+ import { ANALYTICS_ABILITIES } from '@byline/admin/analytics'
13
+ import { getAdminRequestContext } from '@byline/client/server'
14
+
15
+ export interface DeleteAnalyticsEventsInput {
16
+ from: string
17
+ to: string
18
+ visitorHash?: string
19
+ }
20
+
21
+ export const deleteAnalyticsEvents = createServerFn({ method: 'POST' })
22
+ .validator((input: DeleteAnalyticsEventsInput) => input)
23
+ .handler(async ({ data }): Promise<{ deleted: number }> => {
24
+ await assertAnalyticsMaintenance()
25
+ const { getAnalytics } = await import('@byline/analytics')
26
+ const from = parseInstant(data.from, 'from')
27
+ const to = parseInstant(data.to, 'to')
28
+ const deleted = await getAnalytics().deleteEvents({
29
+ from,
30
+ to,
31
+ ...(data.visitorHash == null ? {} : { visitorHash: data.visitorHash }),
32
+ })
33
+ return { deleted }
34
+ })
35
+
36
+ export const rebuildAnalyticsDay = createServerFn({ method: 'POST' })
37
+ .validator((input: { day: string }) => input)
38
+ .handler(async ({ data }): Promise<{ rebuilt: string }> => {
39
+ await assertAnalyticsMaintenance()
40
+ const { getAnalytics } = await import('@byline/analytics')
41
+ await getAnalytics().rebuildDay(data.day)
42
+ return { rebuilt: data.day }
43
+ })
44
+
45
+ async function assertAnalyticsMaintenance(): Promise<void> {
46
+ const context = await getAdminRequestContext()
47
+ assertAdminActor(context, ANALYTICS_ABILITIES.maintain)
48
+ }
49
+
50
+ function parseInstant(value: string, label: string): Date {
51
+ const date = new Date(value)
52
+ if (Number.isNaN(date.valueOf())) throw new Error(`analytics ${label} must be an ISO instant`)
53
+ return date
54
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createServerFn } from '@tanstack/react-start'
10
+
11
+ import { assertAdminActor, requireAdminActor } from '@byline/admin'
12
+ import { ANALYTICS_ABILITIES } from '@byline/admin/analytics'
13
+ import type {
14
+ AnalyticsCountryTotal,
15
+ AnalyticsDateRange,
16
+ AnalyticsEventKind,
17
+ AnalyticsPathTotal,
18
+ AnalyticsRankedTotals,
19
+ AnalyticsReferrerTotal,
20
+ AnalyticsReportCoverage,
21
+ AnalyticsSummary,
22
+ } from '@byline/analytics'
23
+ import { getAdminRequestContext } from '@byline/client/server'
24
+
25
+ export interface AnalyticsTopInput extends AnalyticsDateRange {
26
+ kind: AnalyticsEventKind
27
+ limit?: number
28
+ }
29
+
30
+ export interface AnalyticsRuntimeState {
31
+ enabled: boolean
32
+ }
33
+
34
+ export const getAnalyticsRuntime = createServerFn({ method: 'GET' }).handler(
35
+ async (): Promise<AnalyticsRuntimeState> => {
36
+ const context = await getAdminRequestContext()
37
+ requireAdminActor(context, 'reading analytics runtime state')
38
+ const { isAnalyticsRegistered } = await import('@byline/analytics')
39
+ return { enabled: isAnalyticsRegistered() }
40
+ }
41
+ )
42
+
43
+ export const getAnalyticsSummary = createServerFn({ method: 'GET' })
44
+ .validator((input: AnalyticsDateRange) => input)
45
+ .handler(async ({ data }): Promise<AnalyticsSummary> => {
46
+ await assertAnalyticsRead()
47
+ const { getAnalytics } = await import('@byline/analytics')
48
+ return getAnalytics().getSummary(data)
49
+ })
50
+
51
+ export const getAnalyticsReportCoverage = createServerFn({ method: 'GET' }).handler(
52
+ async (): Promise<AnalyticsReportCoverage> => {
53
+ await assertAnalyticsRead()
54
+ const { getAnalytics } = await import('@byline/analytics')
55
+ return getAnalytics().getReportCoverage()
56
+ }
57
+ )
58
+
59
+ export const getAnalyticsTop = createServerFn({ method: 'GET' })
60
+ .validator((input: AnalyticsTopInput) => input)
61
+ .handler(async ({ data }): Promise<AnalyticsRankedTotals<AnalyticsPathTotal>> => {
62
+ await assertAnalyticsRead()
63
+ const { getAnalytics } = await import('@byline/analytics')
64
+ return getAnalytics().getTopPaths(data)
65
+ })
66
+
67
+ export const getAnalyticsReferrers = createServerFn({ method: 'GET' })
68
+ .validator((input: AnalyticsDateRange & { limit?: number }) => input)
69
+ .handler(async ({ data }): Promise<AnalyticsRankedTotals<AnalyticsReferrerTotal>> => {
70
+ await assertAnalyticsRead()
71
+ const { getAnalytics } = await import('@byline/analytics')
72
+ return getAnalytics().getReferrers(data)
73
+ })
74
+
75
+ export const getAnalyticsCountries = createServerFn({ method: 'GET' })
76
+ .validator((input: AnalyticsDateRange) => input)
77
+ .handler(async ({ data }): Promise<AnalyticsCountryTotal[]> => {
78
+ await assertAnalyticsRead()
79
+ const { getAnalytics } = await import('@byline/analytics')
80
+ return getAnalytics().getCountries(data)
81
+ })
82
+
83
+ async function assertAnalyticsRead(): Promise<void> {
84
+ const context = await getAdminRequestContext()
85
+ assertAdminActor(context, ANALYTICS_ABILITIES.read)
86
+ }
@@ -8,10 +8,11 @@
8
8
 
9
9
  import { createServerFn } from '@tanstack/react-start'
10
10
 
11
- import { getAdminBylineClient } from '@byline/client/server'
11
+ import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
12
12
  import {
13
13
  BylineError,
14
14
  buildRelationSummaryPopulateMap,
15
+ collectionAbilityKey,
15
16
  ERR_NOT_FOUND,
16
17
  ErrorCodes,
17
18
  getCollectionAdminConfig,
@@ -165,6 +166,25 @@ const getDocumentFn = createServerFn({ method: 'GET' })
165
166
  | string[]
166
167
  | undefined
167
168
 
169
+ // Scheduled publication is editorial system metadata, not authored
170
+ // content. Do not join it into ordinary client reads: that would leak
171
+ // timing to public callers or make every read require publish abilities.
172
+ // The authenticated admin loader adds it only when the installation is
173
+ // enabled and this actor holds both schedule abilities for this collection.
174
+ const scheduledPublicationEnabled = getServerConfig().scheduledPublication?.enabled === true
175
+ let canSchedulePublication = false
176
+ let scheduledPublish: Awaited<ReturnType<typeof handle.getScheduledPublish>> = null
177
+ if (scheduledPublicationEnabled) {
178
+ const actor = (await getAdminRequestContext()).actor
179
+ canSchedulePublication =
180
+ actor?.hasAbility(collectionAbilityKey(path, 'changeStatus')) === true &&
181
+ actor.hasAbility(collectionAbilityKey(path, 'publish'))
182
+ if (canSchedulePublication) {
183
+ scheduledPublish = await handle.getScheduledPublish(id)
184
+ }
185
+ }
186
+ const serializedSchedule = scheduledPublish == null ? null : serialise(scheduledPublish)
187
+
168
188
  return {
169
189
  ...(parsed as Record<string, any>),
170
190
  _publishedVersion: publishedVersion,
@@ -175,6 +195,15 @@ const getDocumentFn = createServerFn({ method: 'GET' })
175
195
  ...(Array.isArray(availableVersionLocales)
176
196
  ? { _availableVersionLocales: availableVersionLocales }
177
197
  : {}),
198
+ _scheduledPublicationEnabled: scheduledPublicationEnabled,
199
+ _canSchedulePublication: canSchedulePublication,
200
+ _scheduledPublish: serializedSchedule,
201
+ ...(serializedSchedule == null
202
+ ? {}
203
+ : {
204
+ scheduledPublishAt: serializedSchedule.publishAt,
205
+ scheduledPublishVersionId: serializedSchedule.targetVersionId,
206
+ }),
178
207
  }
179
208
  })
180
209
 
@@ -18,6 +18,7 @@ export * from './list'
18
18
  export * from './reindex'
19
19
  export * from './reorder'
20
20
  export * from './restore-version'
21
+ export * from './scheduled-publication'
21
22
  export * from './set-list-view-preference'
22
23
  export * from './stats'
23
24
  export * from './status'
@@ -0,0 +1,45 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { DocumentPublishSchedule } from '@byline/core'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import { omitScheduleExecutionState } from './scheduled-publication-response.js'
13
+
14
+ describe('scheduled-publication transport response', () => {
15
+ it('does not expose the execution fencing token or its lease', () => {
16
+ const now = new Date('2026-08-22T12:00:00.000Z')
17
+ const schedule: DocumentPublishSchedule = {
18
+ documentId: 'doc-1',
19
+ collectionId: 'collection-1',
20
+ targetVersionId: 'version-1',
21
+ publishAt: now,
22
+ state: 'armed',
23
+ suspendedAt: null,
24
+ suspendedReason: null,
25
+ scheduledBy: null,
26
+ lastAuthorizedBy: null,
27
+ lastAuthorizedAt: now,
28
+ scheduledAt: now,
29
+ updatedAt: now,
30
+ executionToken: 'sweep-fencing-secret',
31
+ executionExpiresAt: new Date('2026-08-22T12:05:00.000Z'),
32
+ lastAttemptAt: now,
33
+ nextAttemptAt: now,
34
+ attemptCount: 1,
35
+ lastError: null,
36
+ }
37
+
38
+ const response = omitScheduleExecutionState(schedule)
39
+
40
+ expect(response).not.toHaveProperty('executionToken')
41
+ expect(response).not.toHaveProperty('executionExpiresAt')
42
+ expect(JSON.stringify(response)).not.toContain('sweep-fencing-secret')
43
+ expect(response).toMatchObject({ documentId: 'doc-1', state: 'armed', attemptCount: 1 })
44
+ })
45
+ })
@@ -0,0 +1,22 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { DocumentPublishScheduleInfo } from '@byline/client'
10
+ import type { DocumentPublishSchedule } from '@byline/core'
11
+
12
+ /** Remove sweep-owned fencing credentials before a schedule crosses a transport boundary. */
13
+ export function omitScheduleExecutionState(
14
+ schedule: DocumentPublishScheduleInfo | DocumentPublishSchedule
15
+ ): DocumentPublishScheduleInfo {
16
+ const {
17
+ executionToken: _executionToken,
18
+ executionExpiresAt: _executionExpiresAt,
19
+ ...editorialSchedule
20
+ } = schedule as DocumentPublishSchedule
21
+ return editorialSchedule
22
+ }