@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.
- package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +3 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -2
- package/dist/admin-shell/chrome/admin-menu-drawer_module.css +1 -1
- package/dist/admin-shell/collections/edit.js +142 -2
- package/dist/admin-shell/scheduled-publications/list.d.ts +4 -0
- package/dist/admin-shell/scheduled-publications/list.js +295 -0
- package/dist/admin-shell/scheduled-publications/list.module.js +25 -0
- package/dist/admin-shell/scheduled-publications/list_module.css +97 -0
- package/dist/routes/create-admin-layout-route.js +8 -3
- package/dist/routes/create-admin-scheduled-publications-route.d.ts +8 -0
- package/dist/routes/create-admin-scheduled-publications-route.js +66 -0
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/server-fns/collections/get.d.ts +5 -0
- package/dist/server-fns/collections/get.js +19 -3
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/scheduled-publication-response.d.ts +11 -0
- package/dist/server-fns/collections/scheduled-publication-response.js +5 -0
- package/dist/server-fns/collections/scheduled-publication.d.ts +111 -0
- package/dist/server-fns/collections/scheduled-publication.js +157 -0
- package/package.json +8 -8
- package/src/admin-shell/chrome/admin-menu-drawer.module.css +7 -1
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +23 -1
- package/src/admin-shell/collections/edit.tsx +140 -1
- package/src/admin-shell/scheduled-publications/list.module.css +105 -0
- package/src/admin-shell/scheduled-publications/list.tsx +292 -0
- package/src/routes/create-admin-layout-route.tsx +8 -3
- package/src/routes/create-admin-scheduled-publications-route.tsx +83 -0
- package/src/routes/index.ts +1 -0
- package/src/server-fns/collections/get.ts +30 -1
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/scheduled-publication-response.test.node.ts +45 -0
- package/src/server-fns/collections/scheduled-publication-response.ts +22 -0
- 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
|
-
|
|
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
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
})
|