@byline/host-tanstack-start 4.14.0 → 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 (39) hide show
  1. package/dist/admin-shell/chrome/admin-layout_module.css +1 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +3 -1
  3. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -2
  4. package/dist/admin-shell/chrome/admin-menu-drawer_module.css +7 -6
  5. package/dist/admin-shell/chrome/dashboard.js +2 -2
  6. package/dist/admin-shell/collections/edit.js +142 -2
  7. package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
  8. package/dist/admin-shell/scheduled-publications/list.js +295 -0
  9. package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
  10. package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
  11. package/dist/routes/create-admin-layout-route.js +8 -3
  12. package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
  13. package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
  14. package/dist/routes/index.d.ts +1 -0
  15. package/dist/routes/index.js +1 -0
  16. package/dist/server-fns/collections/get.d.ts +5 -0
  17. package/dist/server-fns/collections/get.js +19 -3
  18. package/dist/server-fns/collections/index.d.ts +1 -0
  19. package/dist/server-fns/collections/index.js +1 -0
  20. package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
  21. package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
  22. package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
  23. package/dist/server-fns/collections/scheduled-publication.js +157 -0
  24. package/package.json +8 -8
  25. package/src/admin-shell/chrome/admin-layout.module.css +6 -1
  26. package/src/admin-shell/chrome/admin-menu-drawer.module.css +19 -6
  27. package/src/admin-shell/chrome/admin-menu-drawer.tsx +23 -1
  28. package/src/admin-shell/chrome/dashboard.tsx +11 -4
  29. package/src/admin-shell/collections/edit.tsx +140 -1
  30. package/src/admin-shell/scheduled-publications/list.module.css +105 -0
  31. package/src/admin-shell/scheduled-publications/list.tsx +292 -0
  32. package/src/routes/create-admin-layout-route.tsx +8 -3
  33. package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
  34. package/src/routes/index.ts +1 -0
  35. package/src/server-fns/collections/get.ts +30 -1
  36. package/src/server-fns/collections/index.ts +1 -0
  37. package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
  38. package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
  39. package/src/server-fns/collections/scheduled-publication.ts +270 -0
@@ -0,0 +1,292 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useMemo, useState } from 'react'
12
+ import { useRouterState } from '@tanstack/react-router'
13
+
14
+ import { deriveScheduledPublicationState } from '@byline/admin/react'
15
+ import { useTranslation } from '@byline/i18n/react'
16
+ import {
17
+ Alert,
18
+ Badge,
19
+ Button,
20
+ Container,
21
+ Section,
22
+ Select,
23
+ Table,
24
+ useToastManager,
25
+ } from '@byline/ui/react'
26
+ import cx from 'clsx'
27
+
28
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
29
+ import { cancelCollectionDocumentScheduledPublish } from '../../server-fns/collections/index.js'
30
+ import { Link, useNavigate } from '../chrome/loose-router.js'
31
+ import { RouterPager } from '../chrome/router-pager.js'
32
+ import styles from './list.module.css'
33
+ import type { ScheduledPublicationListResponse } from '../../server-fns/collections/index.js'
34
+
35
+ /**
36
+ * Badge intent per state. Armed is informational; the two states that mean
37
+ * automatic publication is not going to happen unaided are warnings.
38
+ */
39
+ const STATE_INTENT = {
40
+ none: 'noeffect',
41
+ armed: 'info',
42
+ needs_reconfirm: 'warning',
43
+ overdue: 'warning',
44
+ } as const
45
+
46
+ export function ScheduledPublicationsView({ data }: { data: ScheduledPublicationListResponse }) {
47
+ const { t, locale } = useTranslation('byline-admin')
48
+ // One clock reading for the whole render, so every row's armed/overdue
49
+ // boundary is decided against the same instant.
50
+ const now = Date.now()
51
+ const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', [])
52
+ const instantFormat = useMemo(
53
+ () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone }),
54
+ [locale, timeZone]
55
+ )
56
+ const navigate = useNavigate()
57
+ const toastManager = useToastManager()
58
+ const location = useRouterState({ select: (state) => state.location })
59
+ const search = location.search as {
60
+ state?: 'armed' | 'needs_reconfirm'
61
+ page?: number
62
+ }
63
+ const [cancelling, setCancelling] = useState<string | null>(null)
64
+
65
+ const applySearch = (patch: Record<string, unknown>) => {
66
+ navigate({
67
+ to: getAdminRoutePath('scheduled-publications'),
68
+ search: { ...(location.search as Record<string, unknown>), ...patch, page: undefined },
69
+ })
70
+ }
71
+
72
+ const cancel = async (collection: string, documentId: string) => {
73
+ setCancelling(documentId)
74
+ try {
75
+ const result = await cancelCollectionDocumentScheduledPublish({
76
+ data: { collection, id: documentId },
77
+ })
78
+ const cancelled = result.status === 'cancelled'
79
+ toastManager.add({
80
+ title: cancelled
81
+ ? t('scheduledPublication.toast.cancelledTitle')
82
+ : t('scheduledPublication.toast.cancelLostTitle'),
83
+ description: cancelled
84
+ ? t('scheduledPublication.toast.cancelledDescription')
85
+ : t('scheduledPublication.toast.cancelLostDescription'),
86
+ data: {
87
+ intent: cancelled ? 'success' : 'warning',
88
+ iconType: cancelled ? 'success' : 'warning',
89
+ icon: true,
90
+ close: true,
91
+ },
92
+ })
93
+ navigate({
94
+ to: getAdminRoutePath('scheduled-publications'),
95
+ search: location.search as Record<string, unknown>,
96
+ })
97
+ } catch (error) {
98
+ toastManager.add({
99
+ title: t('scheduledPublication.toast.failedTitle'),
100
+ description: (error as Error).message,
101
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
102
+ })
103
+ } finally {
104
+ setCancelling(null)
105
+ }
106
+ }
107
+
108
+ const unhealthy = data.runtime.health?.status !== 'healthy'
109
+
110
+ return (
111
+ <Section>
112
+ <Container>
113
+ <div className={cx('byline-scheduled-list-head', styles.head)}>
114
+ <h1 className={styles.title}>{t('scheduledPublication.list.title')}</h1>
115
+ <p className={styles.intro}>{t('scheduledPublication.list.intro')}</p>
116
+ </div>
117
+
118
+ {unhealthy && (
119
+ <Alert
120
+ className={styles.health}
121
+ intent="warning"
122
+ icon={true}
123
+ close={false}
124
+ title={t('scheduledPublication.list.schedulerWarningTitle')}
125
+ >
126
+ {t('scheduledPublication.list.schedulerWarningBody', {
127
+ status: data.runtime.health?.status ?? 'not_started',
128
+ })}
129
+ </Alert>
130
+ )}
131
+
132
+ <div className={styles.controls}>
133
+ <div className={styles.filters}>
134
+ <div className={styles.filter}>
135
+ <Select<string>
136
+ id="scheduled-publication-state"
137
+ name="scheduled-publication-state"
138
+ ariaLabel={t('scheduledPublication.list.filters.state')}
139
+ size="sm"
140
+ value={search.state ?? '_all'}
141
+ items={[
142
+ { value: '_all', label: t('scheduledPublication.list.filters.allStates') },
143
+ { value: 'armed', label: t('scheduledPublication.list.states.armed') },
144
+ {
145
+ value: 'needs_reconfirm',
146
+ label: t('scheduledPublication.list.states.needsReconfirm'),
147
+ },
148
+ ]}
149
+ onValueChange={(value) =>
150
+ applySearch({ state: value === '_all' ? undefined : value })
151
+ }
152
+ />
153
+ </div>
154
+ </div>
155
+ <RouterPager
156
+ page={data.meta.page}
157
+ count={data.meta.totalPages}
158
+ showFirstButton
159
+ showLastButton
160
+ componentName="scheduledPublicationsPagerTop"
161
+ aria-label={t('scheduledPublication.list.pagerAriaLabel')}
162
+ />
163
+ </div>
164
+
165
+ {data.schedules.length === 0 ? (
166
+ <p className={styles.empty}>{t('scheduledPublication.list.empty')}</p>
167
+ ) : (
168
+ <Table.Container className={styles.tableWrap}>
169
+ <Table>
170
+ <Table.Header>
171
+ <Table.Row>
172
+ <Table.HeadingCell scope="col">
173
+ {t('scheduledPublication.list.columns.document')}
174
+ </Table.HeadingCell>
175
+ <Table.HeadingCell scope="col">
176
+ {t('scheduledPublication.list.columns.publishAt')}
177
+ </Table.HeadingCell>
178
+ <Table.HeadingCell scope="col">
179
+ {t('scheduledPublication.list.columns.state')}
180
+ </Table.HeadingCell>
181
+ <Table.HeadingCell scope="col">
182
+ {t('scheduledPublication.list.columns.authorizer')}
183
+ </Table.HeadingCell>
184
+ <Table.HeadingCell scope="col">
185
+ {t('scheduledPublication.list.columns.attempt')}
186
+ </Table.HeadingCell>
187
+ <Table.HeadingCell scope="col">
188
+ {t('scheduledPublication.list.columns.actions')}
189
+ </Table.HeadingCell>
190
+ </Table.Row>
191
+ </Table.Header>
192
+ <Table.Body>
193
+ {data.schedules.map((schedule) => {
194
+ // Same derivation the document editor uses, so a row here and
195
+ // the document it links to can never disagree about whether a
196
+ // schedule has gone overdue.
197
+ const state = deriveScheduledPublicationState(
198
+ schedule,
199
+ { canSchedule: false, canConfirm: false, canCancel: true },
200
+ now
201
+ )
202
+ const publishAt = new Date(schedule.publishAt)
203
+ return (
204
+ <Table.Row key={schedule.documentId}>
205
+ <Table.Cell>
206
+ <Link
207
+ to={getAdminRoutePath('collections', '$collection', '$id')}
208
+ params={{
209
+ collection: schedule.collectionPath,
210
+ id: schedule.documentId,
211
+ }}
212
+ >
213
+ {schedule.documentPath || schedule.documentId}
214
+ </Link>
215
+ <span className={styles.collection}>{schedule.collectionLabel}</span>
216
+ </Table.Cell>
217
+ <Table.Cell>
218
+ <time className={styles.instant} dateTime={publishAt.toISOString()}>
219
+ {instantFormat.format(publishAt)}
220
+ </time>
221
+ <span className={styles.zone}>{timeZone}</span>
222
+ </Table.Cell>
223
+ <Table.Cell>
224
+ <Badge intent={STATE_INTENT[state.kind]}>
225
+ {state.kind === 'needs_reconfirm'
226
+ ? t('scheduledPublication.list.states.needsReconfirm')
227
+ : state.kind === 'overdue'
228
+ ? t('scheduledPublication.list.states.overdue')
229
+ : t('scheduledPublication.list.states.armed')}
230
+ </Badge>
231
+ </Table.Cell>
232
+ <Table.Cell>
233
+ {schedule.lastAuthorizedByName != null ? (
234
+ <span>{schedule.lastAuthorizedByName}</span>
235
+ ) : schedule.lastAuthorizedBy != null ? (
236
+ // The account is gone — the schedule outlives it by
237
+ // design — so the id is all there is left to identify
238
+ // who authorized this.
239
+ <span
240
+ className={styles.authorizer}
241
+ title={t('scheduledPublication.list.authorizerMissing')}
242
+ >
243
+ {schedule.lastAuthorizedBy}
244
+ </span>
245
+ ) : (
246
+ <span>—</span>
247
+ )}
248
+ </Table.Cell>
249
+ <Table.Cell>
250
+ <span>{schedule.attemptCount}</span>
251
+ {schedule.lastError != null && (
252
+ <span className={styles.error}>{schedule.lastError}</span>
253
+ )}
254
+ </Table.Cell>
255
+ <Table.Cell>
256
+ <Button
257
+ size="xs"
258
+ type="button"
259
+ intent="danger"
260
+ disabled={cancelling === schedule.documentId}
261
+ // The column header already says Actions and the row
262
+ // is a schedule, so the short label carries here —
263
+ // unlike the editor's menu, where "Cancel schedule"
264
+ // has to distinguish itself from cancelling an edit.
265
+ aria-label={t('scheduledPublication.actions.cancel')}
266
+ onClick={() => cancel(schedule.collectionPath, schedule.documentId)}
267
+ >
268
+ {t('common.actions.cancel')}
269
+ </Button>
270
+ </Table.Cell>
271
+ </Table.Row>
272
+ )
273
+ })}
274
+ </Table.Body>
275
+ </Table>
276
+ </Table.Container>
277
+ )}
278
+
279
+ <div className={styles.pagerBottom}>
280
+ <RouterPager
281
+ page={data.meta.page}
282
+ count={data.meta.totalPages}
283
+ showFirstButton
284
+ showLastButton
285
+ componentName="scheduledPublicationsPagerBottom"
286
+ aria-label={t('scheduledPublication.list.pagerAriaLabel')}
287
+ />
288
+ </div>
289
+ </Container>
290
+ </Section>
291
+ )
292
+ }
@@ -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
+ }