@byline/host-tanstack-start 4.14.1 → 4.15.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 (35) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +3 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -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/routes/create-admin-layout-route.js +8 -3
  10. package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
  11. package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
  12. package/dist/routes/index.d.ts +1 -0
  13. package/dist/routes/index.js +1 -0
  14. package/dist/server-fns/collections/get.d.ts +5 -0
  15. package/dist/server-fns/collections/get.js +19 -3
  16. package/dist/server-fns/collections/index.d.ts +1 -0
  17. package/dist/server-fns/collections/index.js +1 -0
  18. package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
  19. package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
  20. package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
  21. package/dist/server-fns/collections/scheduled-publication.js +157 -0
  22. package/package.json +8 -8
  23. package/src/admin-shell/chrome/admin-menu-drawer.module.css +7 -1
  24. package/src/admin-shell/chrome/admin-menu-drawer.tsx +23 -1
  25. package/src/admin-shell/collections/edit.tsx +140 -1
  26. package/src/admin-shell/scheduled-publications/list.module.css +105 -0
  27. package/src/admin-shell/scheduled-publications/list.tsx +292 -0
  28. package/src/routes/create-admin-layout-route.tsx +8 -3
  29. package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
  30. package/src/routes/index.ts +1 -0
  31. package/src/server-fns/collections/get.ts +30 -1
  32. package/src/server-fns/collections/index.ts +1 -0
  33. package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
  34. package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
  35. package/src/server-fns/collections/scheduled-publication.ts +270 -0
@@ -42,6 +42,7 @@ 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
44
  import { getCurrentAdminUser } from '../server-fns/auth/index.js'
45
+ import { getScheduledPublicationRuntime } from '../server-fns/collections/index.js'
45
46
  import { getActiveLocaleFn, setAdminLocaleFn } from '../server-fns/i18n/index.js'
46
47
  import { getSignInRoutePath } from './sign-in-path.js'
47
48
 
@@ -58,7 +59,8 @@ export function createAdminLayoutRoute(path: string) {
58
59
  // `@tanstack/react-start/server` out of the client bundle — the
59
60
  // same pattern `getCurrentAdminUser` uses for `getAdminRequestContext`.
60
61
  const activeLocale = await getActiveLocaleFn()
61
- return { user, activeLocale }
62
+ const scheduledPublicationRuntime = await getScheduledPublicationRuntime()
63
+ return { user, activeLocale, scheduledPublicationRuntime }
62
64
  } catch {
63
65
  // `getCurrentAdminUser` (via `getAdminRequestContext`) throws
64
66
  // `ERR_UNAUTHENTICATED` or a related auth error when no valid
@@ -71,9 +73,10 @@ export function createAdminLayoutRoute(path: string) {
71
73
  }
72
74
  },
73
75
  component: function AdminLayoutComponent() {
74
- const { user, activeLocale } = Route.useRouteContext() as {
76
+ const { user, activeLocale, scheduledPublicationRuntime } = Route.useRouteContext() as {
75
77
  user: Awaited<ReturnType<typeof getCurrentAdminUser>>
76
78
  activeLocale: LocaleCode
79
+ scheduledPublicationRuntime: Awaited<ReturnType<typeof getScheduledPublicationRuntime>>
77
80
  }
78
81
  const { i18n } = getAdminConfig()
79
82
  const localeDefinitions = buildLocaleDefinitions(
@@ -109,7 +112,9 @@ export function createAdminLayoutRoute(path: string) {
109
112
  <RouteProgressBar />
110
113
  <AdminAppBar user={user} />
111
114
  <main className={cx('byline-admin-layout-main', layoutStyles.main)}>
112
- <AdminMenuDrawer />
115
+ <AdminMenuDrawer
116
+ scheduledPublicationEnabled={scheduledPublicationRuntime.enabled}
117
+ />
113
118
  <Content>
114
119
  <Outlet />
115
120
  </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
+ }
@@ -38,6 +38,7 @@ export { createAdminLayoutRoute } from './create-admin-layout-route.js'
38
38
  export { createAdminPermissionsRoute } from './create-admin-permissions-route.js'
39
39
  export { createAdminRoleEditRoute } from './create-admin-role-edit-route.js'
40
40
  export { createAdminRolesListRoute } from './create-admin-roles-list-route.js'
41
+ export { createAdminScheduledPublicationsRoute } from './create-admin-scheduled-publications-route.js'
41
42
  export { createAdminUserEditRoute } from './create-admin-user-edit-route.js'
42
43
  export { createAdminUsersListRoute } from './create-admin-users-list-route.js'
43
44
  export { createCollectionApiRoute } from './create-collection-api-route.js'
@@ -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
+ }
@@ -0,0 +1,270 @@
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 type { DocumentPublishScheduleInfo } from '@byline/client'
12
+ import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
13
+ import {
14
+ type DocumentPublishSchedule,
15
+ type DocumentPublishScheduleState,
16
+ ERR_NOT_FOUND,
17
+ ERR_VALIDATION,
18
+ type RecurringTaskHealth,
19
+ } from '@byline/core'
20
+ import { listDocumentPublishSchedules } from '@byline/core/services'
21
+
22
+ import { bylineCore } from '../../integrations/byline-core.js'
23
+ import { omitScheduleExecutionState } from './scheduled-publication-response.js'
24
+ import { serialise } from './utils.js'
25
+
26
+ const TASK_NAME = 'documents.publish-scheduled'
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
28
+
29
+ export interface ScheduledPublicationRuntime {
30
+ enabled: boolean
31
+ health: {
32
+ status: 'healthy' | 'failed' | 'stale' | 'not_started'
33
+ task: SerializedRecurringTaskHealth | null
34
+ } | null
35
+ }
36
+
37
+ type SerializedRecurringTaskHealth = Omit<
38
+ RecurringTaskHealth,
39
+ 'nextRunAt' | 'lastStartedAt' | 'lastSucceededAt' | 'lastFailedAt' | 'databaseNow'
40
+ > & {
41
+ nextRunAt: string
42
+ lastStartedAt: string | null
43
+ lastSucceededAt: string | null
44
+ lastFailedAt: string | null
45
+ databaseNow: string
46
+ }
47
+
48
+ export type SerializedDocumentPublishSchedule = Omit<
49
+ DocumentPublishScheduleInfo,
50
+ | 'publishAt'
51
+ | 'suspendedAt'
52
+ | 'lastAuthorizedAt'
53
+ | 'scheduledAt'
54
+ | 'updatedAt'
55
+ | 'lastAttemptAt'
56
+ | 'nextAttemptAt'
57
+ > & {
58
+ publishAt: string
59
+ suspendedAt: string | null
60
+ lastAuthorizedAt: string
61
+ scheduledAt: string
62
+ updatedAt: string
63
+ lastAttemptAt: string | null
64
+ nextAttemptAt: string
65
+ }
66
+
67
+ export interface ScheduledPublicationListItem extends SerializedDocumentPublishSchedule {
68
+ collectionPath: string
69
+ collectionLabel: string
70
+ documentPath: string | null
71
+ /**
72
+ * Display name for `lastAuthorizedBy` — full name, or email when the account
73
+ * has no name set. `null` when the account no longer exists, which is a real
74
+ * state rather than an error: the authorizer column is not a foreign key, so
75
+ * a schedule outlives the account that authorized it. Callers fall back to
76
+ * showing the id.
77
+ */
78
+ lastAuthorizedByName: string | null
79
+ }
80
+
81
+ export interface ScheduledPublicationListResponse {
82
+ schedules: ScheduledPublicationListItem[]
83
+ meta: {
84
+ page: number
85
+ pageSize: number
86
+ total: number
87
+ totalPages: number
88
+ }
89
+ runtime: ScheduledPublicationRuntime
90
+ }
91
+
92
+ function assertFeatureEnabled(): void {
93
+ if (bylineCore().config.scheduledPublication?.enabled === true) return
94
+ throw ERR_NOT_FOUND({ message: 'Scheduled publication is not enabled for this installation' })
95
+ }
96
+
97
+ function serializeSchedule(
98
+ schedule: DocumentPublishScheduleInfo | DocumentPublishSchedule
99
+ ): SerializedDocumentPublishSchedule {
100
+ return serialise(
101
+ omitScheduleExecutionState(schedule)
102
+ ) as unknown as SerializedDocumentPublishSchedule
103
+ }
104
+
105
+ async function scheduledPublicationRuntime(): Promise<ScheduledPublicationRuntime> {
106
+ const core = bylineCore()
107
+ if (core.config.scheduledPublication?.enabled !== true) {
108
+ return { enabled: false, health: null }
109
+ }
110
+
111
+ const scheduler = core.db.scheduler
112
+ if (scheduler == null) {
113
+ return { enabled: true, health: { status: 'not_started', task: null } }
114
+ }
115
+ const [task] = await scheduler.health([TASK_NAME])
116
+ if (task == null) {
117
+ return { enabled: true, health: { status: 'not_started', task: null } }
118
+ }
119
+
120
+ const staleAfter = task.nextRunAt.getTime() + task.intervalMs * 2
121
+ const status = task.leaseExpired
122
+ ? 'stale'
123
+ : task.lastStatus === 'failed'
124
+ ? 'failed'
125
+ : staleAfter < task.databaseNow.getTime()
126
+ ? 'stale'
127
+ : 'healthy'
128
+ return {
129
+ enabled: true,
130
+ health: {
131
+ status,
132
+ task: serialise(task) as unknown as SerializedRecurringTaskHealth,
133
+ },
134
+ }
135
+ }
136
+
137
+ /** Authenticated feature discovery for the admin shell and health warning. */
138
+ export const getScheduledPublicationRuntime = createServerFn({ method: 'GET' }).handler(
139
+ async () => {
140
+ await getAdminRequestContext()
141
+ return scheduledPublicationRuntime()
142
+ }
143
+ )
144
+
145
+ export const scheduleCollectionDocumentPublish = createServerFn({ method: 'POST' })
146
+ .validator(
147
+ (input: { collection: string; id: string; publishAt: string; expectedVersionId: string }) =>
148
+ input
149
+ )
150
+ .handler(async ({ data }) => {
151
+ assertFeatureEnabled()
152
+ const schedule = await getAdminBylineClient()
153
+ .collection(data.collection)
154
+ .schedulePublish(data.id, {
155
+ publishAt: data.publishAt,
156
+ expectedVersionId: data.expectedVersionId,
157
+ })
158
+ return serializeSchedule(schedule)
159
+ })
160
+
161
+ export const confirmCollectionDocumentScheduledPublish = createServerFn({ method: 'POST' })
162
+ .validator((input: { collection: string; id: string; expectedVersionId: string }) => input)
163
+ .handler(async ({ data }) => {
164
+ assertFeatureEnabled()
165
+ const schedule = await getAdminBylineClient()
166
+ .collection(data.collection)
167
+ .confirmScheduledPublish(data.id, { expectedVersionId: data.expectedVersionId })
168
+ return serializeSchedule(schedule)
169
+ })
170
+
171
+ export const cancelCollectionDocumentScheduledPublish = createServerFn({ method: 'POST' })
172
+ .validator((input: { collection: string; id: string }) => input)
173
+ .handler(async ({ data }) => {
174
+ assertFeatureEnabled()
175
+ const schedule = await getAdminBylineClient()
176
+ .collection(data.collection)
177
+ .cancelScheduledPublish(data.id)
178
+ return schedule == null
179
+ ? { status: 'not_found' as const }
180
+ : { status: 'cancelled' as const, schedule: serializeSchedule(schedule) }
181
+ })
182
+
183
+ export const listScheduledPublications = createServerFn({ method: 'GET' })
184
+ .validator(
185
+ (input: {
186
+ states?: readonly DocumentPublishScheduleState[]
187
+ lastAuthorizedBy?: string
188
+ page?: number
189
+ pageSize?: number
190
+ }) => input
191
+ )
192
+ .handler(async ({ data }) => {
193
+ assertFeatureEnabled()
194
+ if (data.states?.some((state) => state !== 'armed' && state !== 'needs_reconfirm') === true) {
195
+ throw ERR_VALIDATION({ message: 'state must be armed or needs_reconfirm' })
196
+ }
197
+ if (data.lastAuthorizedBy != null && !UUID_RE.test(data.lastAuthorizedBy)) {
198
+ throw ERR_VALIDATION({ message: 'lastAuthorizedBy must be a UUID' })
199
+ }
200
+ const core = bylineCore()
201
+ const page = data.page ?? 1
202
+ const pageSize = data.pageSize ?? 25
203
+ const requestContext = await getAdminRequestContext()
204
+ const result = await listDocumentPublishSchedules(core, requestContext, {
205
+ states: data.states,
206
+ lastAuthorizedBy: data.lastAuthorizedBy,
207
+ page,
208
+ pageSize,
209
+ })
210
+ const collections = new Map(
211
+ core.collections.map((definition) => {
212
+ const record = core.getCollectionRecord(definition.path)
213
+ return [record.collectionId, definition] as const
214
+ })
215
+ )
216
+ // Resolve authorizer names in one query for the whole page rather than one
217
+ // per row. `last_authorized_by` is deliberately not a foreign key — deleting
218
+ // an admin account must not revoke a schedule it authorized — so an id here
219
+ // may simply have no user row left, and the caller falls back to the id.
220
+ const authorizerIds = [
221
+ ...new Set(
222
+ result.schedules
223
+ .map((schedule) => schedule.lastAuthorizedBy)
224
+ .filter((id): id is string => id != null)
225
+ ),
226
+ ]
227
+ const authorizerNames = new Map<string, string>()
228
+ // `adminStore` is optional on core — an installation can run without the
229
+ // admin subsystem — so a missing store simply means no names to resolve and
230
+ // the column falls back to ids.
231
+ const adminUsers = core.adminStore?.adminUsers
232
+ if (authorizerIds.length > 0 && adminUsers != null) {
233
+ const users = await adminUsers.getByIds(authorizerIds)
234
+ for (const user of users) {
235
+ const fullName = [user.given_name, user.family_name].filter(Boolean).join(' ').trim()
236
+ authorizerNames.set(user.id, fullName.length > 0 ? fullName : user.email)
237
+ }
238
+ }
239
+
240
+ const schedules = await Promise.all(
241
+ result.schedules.map(async (schedule): Promise<ScheduledPublicationListItem> => {
242
+ const definition = collections.get(schedule.collectionId)
243
+ const documentPath = await core.db.queries.documents.getCurrentPath({
244
+ collection_id: schedule.collectionId,
245
+ document_id: schedule.documentId,
246
+ })
247
+ return {
248
+ ...serializeSchedule(schedule),
249
+ collectionPath: definition?.path ?? schedule.collectionId,
250
+ collectionLabel: definition?.labels.plural ?? definition?.path ?? schedule.collectionId,
251
+ documentPath,
252
+ lastAuthorizedByName:
253
+ schedule.lastAuthorizedBy == null
254
+ ? null
255
+ : (authorizerNames.get(schedule.lastAuthorizedBy) ?? null),
256
+ }
257
+ })
258
+ )
259
+
260
+ return {
261
+ schedules,
262
+ meta: {
263
+ page,
264
+ pageSize,
265
+ total: result.total,
266
+ totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
267
+ },
268
+ runtime: await scheduledPublicationRuntime(),
269
+ } satisfies ScheduledPublicationListResponse
270
+ })