@pilotiq/pilotiq 0.21.0 → 0.23.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 (112) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +107 -0
  3. package/dist/Pilotiq.d.ts +72 -0
  4. package/dist/Pilotiq.d.ts.map +1 -1
  5. package/dist/Pilotiq.js +145 -0
  6. package/dist/Pilotiq.js.map +1 -1
  7. package/dist/PilotiqServiceProvider.d.ts +2 -0
  8. package/dist/PilotiqServiceProvider.d.ts.map +1 -1
  9. package/dist/PilotiqServiceProvider.js +60 -12
  10. package/dist/PilotiqServiceProvider.js.map +1 -1
  11. package/dist/actions/importFactory.d.ts +5 -0
  12. package/dist/actions/importFactory.d.ts.map +1 -1
  13. package/dist/actions/importFactory.js +20 -10
  14. package/dist/actions/importFactory.js.map +1 -1
  15. package/dist/orm/modelDefaults.d.ts +10 -1
  16. package/dist/orm/modelDefaults.d.ts.map +1 -1
  17. package/dist/orm/modelDefaults.js +7 -2
  18. package/dist/orm/modelDefaults.js.map +1 -1
  19. package/dist/pageData/forms.js +3 -3
  20. package/dist/pageData/forms.js.map +1 -1
  21. package/dist/pageData/misc.js +5 -5
  22. package/dist/pageData/misc.js.map +1 -1
  23. package/dist/pageData/navigation.d.ts.map +1 -1
  24. package/dist/pageData/navigation.js +11 -9
  25. package/dist/pageData/navigation.js.map +1 -1
  26. package/dist/pageData/relationPages.d.ts.map +1 -1
  27. package/dist/pageData/relationPages.js +7 -4
  28. package/dist/pageData/relationPages.js.map +1 -1
  29. package/dist/pageData/resourcePages.js +6 -6
  30. package/dist/pageData/resourcePages.js.map +1 -1
  31. package/dist/plugins/index.d.ts +3 -0
  32. package/dist/plugins/index.d.ts.map +1 -1
  33. package/dist/plugins/index.js +1 -0
  34. package/dist/plugins/index.js.map +1 -1
  35. package/dist/plugins/themeEditor.d.ts +20 -1
  36. package/dist/plugins/themeEditor.d.ts.map +1 -1
  37. package/dist/plugins/themeEditor.js +3 -1
  38. package/dist/plugins/themeEditor.js.map +1 -1
  39. package/dist/react/CollabRoomContext.d.ts +12 -0
  40. package/dist/react/CollabRoomContext.d.ts.map +1 -1
  41. package/dist/react/CollabRoomContext.js.map +1 -1
  42. package/dist/react/FormStateContext.d.ts +10 -0
  43. package/dist/react/FormStateContext.d.ts.map +1 -1
  44. package/dist/react/FormStateContext.js +12 -0
  45. package/dist/react/FormStateContext.js.map +1 -1
  46. package/dist/react/PendingSuggestionApplierRegistry.d.ts +12 -0
  47. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -1
  48. package/dist/react/PendingSuggestionApplierRegistry.js +21 -1
  49. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -1
  50. package/dist/react/index.d.ts +2 -1
  51. package/dist/react/index.d.ts.map +1 -1
  52. package/dist/react/index.js +2 -1
  53. package/dist/react/index.js.map +1 -1
  54. package/dist/react/useCollabSeed.d.ts +23 -0
  55. package/dist/react/useCollabSeed.d.ts.map +1 -0
  56. package/dist/react/useCollabSeed.js +67 -0
  57. package/dist/react/useCollabSeed.js.map +1 -0
  58. package/dist/routes/globals.d.ts.map +1 -1
  59. package/dist/routes/globals.js +8 -22
  60. package/dist/routes/globals.js.map +1 -1
  61. package/dist/routes/helpers.d.ts +13 -0
  62. package/dist/routes/helpers.d.ts.map +1 -1
  63. package/dist/routes/helpers.js +25 -8
  64. package/dist/routes/helpers.js.map +1 -1
  65. package/dist/routes/resources.d.ts.map +1 -1
  66. package/dist/routes/resources.js +12 -34
  67. package/dist/routes/resources.js.map +1 -1
  68. package/dist/routes/theme.d.ts +4 -2
  69. package/dist/routes/theme.d.ts.map +1 -1
  70. package/dist/routes/theme.js +27 -26
  71. package/dist/routes/theme.js.map +1 -1
  72. package/dist/routes.d.ts.map +1 -1
  73. package/dist/routes.js +65 -37
  74. package/dist/routes.js.map +1 -1
  75. package/dist/theme/index.d.ts +2 -0
  76. package/dist/theme/index.d.ts.map +1 -1
  77. package/dist/theme/index.js +1 -0
  78. package/dist/theme/index.js.map +1 -1
  79. package/dist/theme/storage.d.ts +86 -0
  80. package/dist/theme/storage.d.ts.map +1 -0
  81. package/dist/theme/storage.js +52 -0
  82. package/dist/theme/storage.js.map +1 -0
  83. package/package.json +1 -1
  84. package/src/Pilotiq.perf.test.ts +252 -0
  85. package/src/Pilotiq.test.ts +4 -0
  86. package/src/Pilotiq.ts +166 -0
  87. package/src/PilotiqServiceProvider.ts +63 -11
  88. package/src/actions/importFactory.ts +31 -10
  89. package/src/orm/modelDefaults.ts +15 -2
  90. package/src/pageData/forms.ts +3 -3
  91. package/src/pageData/misc.ts +5 -5
  92. package/src/pageData/navigation.ts +11 -9
  93. package/src/pageData/relationPages.ts +5 -3
  94. package/src/pageData/resourcePages.ts +6 -6
  95. package/src/plugins/index.ts +7 -0
  96. package/src/plugins/themeEditor.test.ts +36 -0
  97. package/src/plugins/themeEditor.ts +22 -1
  98. package/src/react/CollabRoomContext.ts +12 -0
  99. package/src/react/FormStateContext.tsx +13 -0
  100. package/src/react/PendingSuggestionApplierRegistry.test.ts +97 -0
  101. package/src/react/PendingSuggestionApplierRegistry.ts +19 -1
  102. package/src/react/index.ts +2 -0
  103. package/src/react/useCollabSeed.ts +73 -0
  104. package/src/routes/globals.ts +8 -16
  105. package/src/routes/guard.test.ts +325 -0
  106. package/src/routes/helpers.ts +30 -8
  107. package/src/routes/resources.ts +12 -22
  108. package/src/routes/theme.ts +26 -44
  109. package/src/routes.ts +65 -36
  110. package/src/theme/index.ts +6 -0
  111. package/src/theme/storage.test.ts +126 -0
  112. package/src/theme/storage.ts +106 -0
@@ -0,0 +1,36 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Pilotiq } from '../Pilotiq.js'
4
+ import { themeEditor } from './themeEditor.js'
5
+ import type { ThemeStorageAdapter } from '../theme/storage.js'
6
+
7
+ function makeStubAdapter(): ThemeStorageAdapter {
8
+ return {
9
+ async load() { return null },
10
+ async save() { /* noop */ },
11
+ async clear() { /* noop */ },
12
+ }
13
+ }
14
+
15
+ describe('themeEditor() plugin', () => {
16
+ it('bare themeEditor() leaves the storage slot undefined (boot resolves it)', () => {
17
+ const panel = Pilotiq.make('admin').use(themeEditor())
18
+ assert.equal(panel.getConfig().themeEditor, true)
19
+ assert.equal(panel.getThemeStorage(), undefined)
20
+ })
21
+
22
+ it('themeEditor({ storage }) stamps the adapter onto the panel', () => {
23
+ const adapter = makeStubAdapter()
24
+ const panel = Pilotiq.make('admin').use(themeEditor({ storage: adapter }))
25
+ assert.equal(panel.getThemeStorage(), adapter)
26
+ })
27
+
28
+ it('themeEditor() can be called on multiple panels without leaking adapters', () => {
29
+ const a = makeStubAdapter()
30
+ const b = makeStubAdapter()
31
+ const panelA = Pilotiq.make('admin').use(themeEditor({ storage: a }))
32
+ const panelB = Pilotiq.make('billing').use(themeEditor({ storage: b }))
33
+ assert.equal(panelA.getThemeStorage(), a)
34
+ assert.equal(panelB.getThemeStorage(), b)
35
+ })
36
+ })
@@ -1,4 +1,24 @@
1
1
  import type { PilotiqPlugin } from '../Pilotiq.js'
2
+ import type { ThemeStorageAdapter } from '../theme/storage.js'
3
+
4
+ export interface ThemeEditorOptions {
5
+ /**
6
+ * Override persistence adapter. Defaults to the implicit Prisma
7
+ * fallback (auto-resolved from `app.make('prisma')` against the
8
+ * `panelGlobal` row) — that fallback is deprecated; pass an explicit
9
+ * adapter to opt out and silence the deprecation warning.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { prismaThemeStorage, themeEditor } from '@pilotiq/pilotiq/plugins'
14
+ *
15
+ * .use(themeEditor({
16
+ * storage: prismaThemeStorage(prisma, { slug: 'admin__theme' }),
17
+ * }))
18
+ * ```
19
+ */
20
+ storage?: ThemeStorageAdapter
21
+ }
2
22
 
3
23
  /**
4
24
  * Theme editor plugin — adds an interactive theme customization page
@@ -14,11 +34,12 @@ import type { PilotiqPlugin } from '../Pilotiq.js'
14
34
  * .use(themeEditor())
15
35
  * ```
16
36
  */
17
- export function themeEditor(): PilotiqPlugin {
37
+ export function themeEditor(opts: ThemeEditorOptions = {}): PilotiqPlugin {
18
38
  return {
19
39
  name: 'theme-editor',
20
40
  register(panel) {
21
41
  panel.enableThemeEditor()
42
+ if (opts.storage) panel._setThemeStorage(opts.storage)
22
43
  },
23
44
  }
24
45
  }
@@ -22,6 +22,18 @@ export interface CollabRoom {
22
22
  ydoc: unknown
23
23
  /** `WebsocketProvider` instance. Opaque to pilotiq core. */
24
24
  provider: unknown
25
+ /**
26
+ * Resolves on the provider's first sync. Present when the room is
27
+ * wired through `@rudderjs/sync/react`'s `CollabRoomManager` (which is
28
+ * what `@pilotiq-pro/collab@>=0.2`'s `<RecordCollabRoom>` does);
29
+ * absent for legacy / hand-rolled providers. `useCollabSeed` gates
30
+ * its seed callback on this Promise — adapters that need a
31
+ * "fragment-empty?" check after first sync should consume the hook
32
+ * rather than calling `onProviderSynced` themselves.
33
+ */
34
+ synced?: Promise<void>
35
+ /** IndexedDB persistence handle, when the room wraps `y-indexeddb`. Opaque. */
36
+ persistence?: unknown
25
37
  /** Presence info for cursors / avatars. Forwarded to the extension factory. */
26
38
  user?: {
27
39
  name?: string
@@ -72,6 +72,19 @@ export function useFormState(): FormStateApi | null {
72
72
  */
73
73
  export const FormIdContext = createContext<string>('')
74
74
 
75
+ /**
76
+ * Public accessor for the surrounding form's id, normalized to
77
+ * `undefined` when no `FormRenderer` is mounted up-tree (the sentinel
78
+ * empty string maps to `undefined`). Adapter packages — `@pilotiq/tiptap`,
79
+ * `@pilotiq/codemirror` — consume this via the package's `react` re-export
80
+ * to scope per-field registries (pending-suggestion appliers, focus
81
+ * reporters) by form so multi-form pages route correctly to the matching
82
+ * editor instance.
83
+ */
84
+ export function useFormId(): string | undefined {
85
+ return useContext(FormIdContext) || undefined
86
+ }
87
+
75
88
  export interface UseFieldStateResult {
76
89
  /** True when the field is inside a controlled form (live fields enabled).
77
90
  * Renderers should fall back to their `defaultValue` path when false.
@@ -0,0 +1,97 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ registerPendingSuggestionApplier,
6
+ getPendingSuggestionApplier,
7
+ _clearAppliersForTests,
8
+ } from './PendingSuggestionApplierRegistry.js'
9
+
10
+ describe('PendingSuggestionApplierRegistry', () => {
11
+ beforeEach(() => { _clearAppliersForTests() })
12
+
13
+ it('returns the scoped applier when both formId and fieldName match', () => {
14
+ const apply = (): void => {}
15
+ registerPendingSuggestionApplier('form-1', 'bio', apply)
16
+ assert.equal(getPendingSuggestionApplier('form-1', 'bio'), apply)
17
+ })
18
+
19
+ it('routes scoped lookups to the matching scoped entry across multi-form pages', () => {
20
+ // Two editors in two forms register under the same field name but
21
+ // different formIds — the routing has to disambiguate.
22
+ const applyA = (): void => {}
23
+ const applyB = (): void => {}
24
+ registerPendingSuggestionApplier('form-A', 'summary', applyA)
25
+ registerPendingSuggestionApplier('form-B', 'summary', applyB)
26
+ assert.equal(getPendingSuggestionApplier('form-A', 'summary'), applyA)
27
+ assert.equal(getPendingSuggestionApplier('form-B', 'summary'), applyB)
28
+ })
29
+
30
+ it('falls through to the wildcard slot when no scoped match exists', () => {
31
+ const wild = (): void => {}
32
+ registerPendingSuggestionApplier(undefined, 'bio', wild)
33
+ // formId provided but the registry only has a wildcard entry —
34
+ // the lookup should still resolve so historic single-form pages keep
35
+ // working even when consumers thread a formId.
36
+ assert.equal(getPendingSuggestionApplier('form-1', 'bio'), wild)
37
+ })
38
+
39
+ it('global producer + scoped consumer: undefined-formId lookup finds the scoped entry', () => {
40
+ // Regression guard for the multi-form fix: after threading `formId`
41
+ // through the Tiptap adapter hooks, every editor registers scoped
42
+ // by its surrounding `FormRenderer`'s id. A global producer (no
43
+ // `formId` stamped on the suggestion) calls
44
+ // `getPendingSuggestionApplier(undefined, fieldName)` — without the
45
+ // fallback, the wildcard slot is empty and the suggestion silently
46
+ // never applies. The fallback returns any matching scoped entry.
47
+ const scopedApply = (): void => {}
48
+ registerPendingSuggestionApplier('form-edit', 'bio', scopedApply)
49
+ assert.equal(getPendingSuggestionApplier(undefined, 'bio'), scopedApply)
50
+ })
51
+
52
+ it('global lookup prefers the explicit wildcard slot over scoped fallback', () => {
53
+ const wild = (): void => {}
54
+ const scoped = (): void => {}
55
+ registerPendingSuggestionApplier('form-edit', 'bio', scoped)
56
+ registerPendingSuggestionApplier(undefined, 'bio', wild)
57
+ // Wildcard wins — it was the intent of the original API ("formId
58
+ // defaults to '*'") and a deliberately-registered wildcard applier
59
+ // is presumed authoritative.
60
+ assert.equal(getPendingSuggestionApplier(undefined, 'bio'), wild)
61
+ })
62
+
63
+ it('scoped lookup prefers exact match over wildcard slot', () => {
64
+ const wild = (): void => {}
65
+ const scoped = (): void => {}
66
+ registerPendingSuggestionApplier(undefined, 'bio', wild)
67
+ registerPendingSuggestionApplier('form-edit', 'bio', scoped)
68
+ assert.equal(getPendingSuggestionApplier('form-edit', 'bio'), scoped)
69
+ })
70
+
71
+ it('returns undefined when no entry matches the fieldName at all', () => {
72
+ registerPendingSuggestionApplier('form-A', 'bio', () => {})
73
+ assert.equal(getPendingSuggestionApplier('form-A', 'subtitle'), undefined)
74
+ assert.equal(getPendingSuggestionApplier(undefined, 'subtitle'), undefined)
75
+ })
76
+
77
+ it('unregister cleanup drops the entry', () => {
78
+ const apply = (): void => {}
79
+ const unregister = registerPendingSuggestionApplier('form-1', 'bio', apply)
80
+ assert.equal(getPendingSuggestionApplier('form-1', 'bio'), apply)
81
+ unregister()
82
+ assert.equal(getPendingSuggestionApplier('form-1', 'bio'), undefined)
83
+ })
84
+
85
+ it('re-registering replaces the previous entry and its unregister no-ops', () => {
86
+ const first = (): void => {}
87
+ const second = (): void => {}
88
+ const off1 = registerPendingSuggestionApplier('form-1', 'bio', first)
89
+ registerPendingSuggestionApplier('form-1', 'bio', second) // wins
90
+ assert.equal(getPendingSuggestionApplier('form-1', 'bio'), second)
91
+ // First's unregister must NOT delete the second's entry — the
92
+ // registry tracks identity to defend against unmount-after-remount
93
+ // racing the cleanup of the just-replaced entry.
94
+ off1()
95
+ assert.equal(getPendingSuggestionApplier('form-1', 'bio'), second)
96
+ })
97
+ })
@@ -58,6 +58,18 @@ export function registerPendingSuggestionApplier(
58
58
  * key first; falls back to the wildcard form ('*') so a producer that
59
59
  * pushed a suggestion without `formId` still resolves an applier from
60
60
  * a single-form page.
61
+ *
62
+ * Global-producer fallback: when the lookup `formId` is `undefined` AND
63
+ * no wildcard entry is registered, return any single scoped entry
64
+ * matching `fieldName`. This mirrors the consumer-side filter in
65
+ * `usePendingSuggestionsForField` which lets undefined formId on either
66
+ * side pass-through. Editors today register scoped by their surrounding
67
+ * `FormRenderer`'s id (`useFormId()`), so the wildcard slot is almost
68
+ * always empty — without this fallback, a producer that pushes without
69
+ * a formId on a single-form page would silently fail to resolve any
70
+ * applier. We pick the first scoped match (Map insertion order); when
71
+ * the page genuinely has multiple forms with the same field name,
72
+ * producers SHOULD stamp `formId` to disambiguate.
61
73
  */
62
74
  export function getPendingSuggestionApplier(
63
75
  formId: string | undefined,
@@ -68,7 +80,13 @@ export function getPendingSuggestionApplier(
68
80
  if (scoped) return scoped.apply
69
81
  }
70
82
  const wild = _entries.get(keyFor(undefined, fieldName))
71
- return wild?.apply
83
+ if (wild) return wild.apply
84
+ if (formId === undefined) {
85
+ for (const entry of _entries.values()) {
86
+ if (entry.fieldName === fieldName) return entry.apply
87
+ }
88
+ }
89
+ return undefined
72
90
  }
73
91
 
74
92
  /**
@@ -39,6 +39,7 @@ export {
39
39
  type CollabRoom,
40
40
  type SyncedProviderLike,
41
41
  } from './CollabRoomContext.js'
42
+ export { useCollabSeed } from './useCollabSeed.js'
42
43
  export {
43
44
  registerCollabExtensions,
44
45
  getCollabExtensions,
@@ -113,6 +114,7 @@ export {
113
114
  FormStateProvider,
114
115
  useFieldState,
115
116
  useFormState,
117
+ useFormId,
116
118
  useRowBinding,
117
119
  type FormStateApi,
118
120
  type FormStateProviderProps,
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { CollabRoom } from './CollabRoomContext.js'
3
+
4
+ const SEED_ORIGIN = 'pilotiq-collab-seed'
5
+
6
+ /**
7
+ * Run `seedFn` exactly once after the collab room's first sync.
8
+ *
9
+ * Mirrors `@rudderjs/sync/react`'s `useCollabSeed` shape — same purpose,
10
+ * reimplemented here so pilotiq core stays free of any hard runtime dep
11
+ * on Yjs. The `room` parameter is pilotiq's opaque `CollabRoom`, so the
12
+ * seed callback receives `doc: unknown` and is responsible for its own
13
+ * share-type lookup (`doc.getXmlFragment(key)` for Tiptap consumers,
14
+ * `doc.getText(key)` for CodeMirror, etc.) and its own emptiness check.
15
+ *
16
+ * Returns `true` once the seed callback has run (or was skipped because
17
+ * the room has no `.synced` Promise — i.e. a legacy non-framework
18
+ * provider), so consumers can gate editor mount / placeholder swap.
19
+ *
20
+ * The hook wraps `seedFn` in `doc.transact(..., 'pilotiq-collab-seed')`
21
+ * so downstream observers can filter the synthetic write. Two peers
22
+ * mounting against a brand-new record may both see "empty" and both
23
+ * seed — same race window as the legacy `onProviderSynced` path; the
24
+ * fix is server-side seed handoff, deferred.
25
+ */
26
+ export function useCollabSeed(
27
+ room: CollabRoom | null,
28
+ key: string,
29
+ seedFn: (doc: unknown) => void,
30
+ ): boolean {
31
+ const [seeded, setSeeded] = useState(false)
32
+ const seedFnRef = useRef(seedFn)
33
+ seedFnRef.current = seedFn
34
+
35
+ useEffect(() => {
36
+ if (!room) {
37
+ setSeeded(false)
38
+ return
39
+ }
40
+ // Legacy rooms without `.synced` — the room owner is on the hook
41
+ // for whatever first-sync gating they used to do via
42
+ // `onProviderSynced`. Mark seeded immediately so the consumer
43
+ // can mount without a placeholder. No seedFn runs.
44
+ const syncedPromise = room.synced
45
+ if (!syncedPromise) {
46
+ setSeeded(true)
47
+ return
48
+ }
49
+
50
+ let cancelled = false
51
+ syncedPromise.then(() => {
52
+ if (cancelled) return
53
+ try {
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ const ydoc = room.ydoc as any
56
+ if (ydoc && typeof ydoc.transact === 'function') {
57
+ ydoc.transact(() => seedFnRef.current(ydoc), SEED_ORIGIN)
58
+ } else {
59
+ seedFnRef.current(ydoc)
60
+ }
61
+ } catch { /* ignore — seed is best-effort */ }
62
+ setSeeded(true)
63
+ }).catch(() => {
64
+ // synced rejects when the room is torn down before first sync.
65
+ // Don't surface as a seed failure — the room is gone.
66
+ if (!cancelled) setSeeded(false)
67
+ })
68
+
69
+ return () => { cancelled = true }
70
+ }, [room, key])
71
+
72
+ return seeded
73
+ }
@@ -15,8 +15,7 @@ import {
15
15
  normalizeRedirect,
16
16
  splitMeta,
17
17
  forbidden,
18
- checkPolicy,
19
- policyAccess,
18
+ policyGate,
20
19
  handleFormState,
21
20
  handleFormWizard,
22
21
  handleFormCreateOption,
@@ -49,8 +48,7 @@ export function registerGlobalRoutes(
49
48
  // POST ${editUrl}/_form/:formId/state
50
49
  router.post(`${editUrl}/_form/:formId/state`, async (req, res) => {
51
50
  const user = await pilotiq.resolveUser(req)
52
- if (!await policyAccess(G, user)) return forbidden(res, true)
53
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
51
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
54
52
  const formId = req.params['formId']!
55
53
  return handleFormState(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
56
54
  })
@@ -58,8 +56,7 @@ export function registerGlobalRoutes(
58
56
  // Plan #8 wizard step-validate endpoint for the global's edit form.
59
57
  router.post(`${editUrl}/_form/:formId/wizard`, async (req, res) => {
60
58
  const user = await pilotiq.resolveUser(req)
61
- if (!await policyAccess(G, user)) return forbidden(res, true)
62
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
59
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
63
60
  const formId = req.params['formId']!
64
61
  return handleFormWizard(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
65
62
  })
@@ -67,8 +64,7 @@ export function registerGlobalRoutes(
67
64
  // Async-mention endpoint for the global's edit form.
68
65
  router.post(`${editUrl}/_form/:formId/mentions`, async (req, res) => {
69
66
  const user = await pilotiq.resolveUser(req)
70
- if (!await policyAccess(G, user)) return forbidden(res, true)
71
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
67
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
72
68
  const formId = req.params['formId']!
73
69
  return handleFormMentions(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
74
70
  })
@@ -76,8 +72,7 @@ export function registerGlobalRoutes(
76
72
  // SelectField inline-create modal endpoint for the global's edit form.
77
73
  router.post(`${editUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
78
74
  const user = await pilotiq.resolveUser(req)
79
- if (!await policyAccess(G, user)) return forbidden(res, true)
80
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
75
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
81
76
  const formId = req.params['formId']!
82
77
  const fieldName = req.params['fieldName']!
83
78
  return handleFormCreateOption(req, res, pilotiq, { kind: 'global-edit', slug }, formId, fieldName)
@@ -85,11 +80,10 @@ export function registerGlobalRoutes(
85
80
 
86
81
  router.get(editUrl, async (req, res) => {
87
82
  const user = await pilotiq.resolveUser(req)
88
- if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
89
83
  // Globals carry their record on the singleton form's `loadRecord`;
90
84
  // we don't pre-load here — pass a stub so canEdit's signature is
91
85
  // honored, and let user code decide whether to consult it.
92
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
86
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
93
87
  const data = await globalEditData(pilotiq, slug, undefined, req)
94
88
  return view('pilotiq.slug', data ?? {})
95
89
  })
@@ -100,8 +94,7 @@ export function registerGlobalRoutes(
100
94
  const json = wantsJson(req)
101
95
 
102
96
  const user = await pilotiq.resolveUser(req)
103
- if (!await policyAccess(G, user)) return forbidden(res, json)
104
- if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, json)
97
+ if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, json)
105
98
 
106
99
  const ctx: SchemaContext = { mode: 'edit', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
107
100
  const elements = await callPageSchema(PageClass, ctx)
@@ -147,8 +140,7 @@ export function registerGlobalRoutes(
147
140
  if (pages.view) {
148
141
  router.get(`${editUrl}/view`, async (req, res) => {
149
142
  const user = await pilotiq.resolveUser(req)
150
- if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
151
- if (!await checkPolicy(() => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
143
+ if (!await policyGate(G, user, () => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
152
144
  const data = await globalViewData(pilotiq, slug, req)
153
145
  return view('pilotiq.resource-view', data ?? {})
154
146
  })