@anthusai/papyrus 0.0.0 → 1.0.0-next.10

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 (282) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2069 -0
  5. package/amplify/data/resource.ts +2185 -0
  6. package/amplify/data/schema.js +1882 -0
  7. package/amplify/data/schema.ts +1990 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/content-actions/handler.py +149 -0
  22. package/amplify/functions/content-actions/requirements.txt +4 -0
  23. package/amplify/functions/content-actions/resource.js +40 -0
  24. package/amplify/functions/content-actions/resource.ts +54 -0
  25. package/amplify/functions/email-submission-processor/handler.py +46 -0
  26. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  27. package/amplify/functions/email-submission-processor/resource.js +56 -0
  28. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  29. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  30. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  31. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  32. package/amplify/functions/knowledge-query/handler.py +1 -0
  33. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  34. package/amplify/functions/knowledge-query/resource.js +50 -0
  35. package/amplify/functions/knowledge-query/resource.ts +61 -0
  36. package/amplify/functions/manage-user-role/handler.ts +799 -0
  37. package/amplify/functions/manage-user-role/resource.js +8 -0
  38. package/amplify/functions/manage-user-role/resource.ts +9 -0
  39. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  40. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  41. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  42. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  43. package/amplify/functions/newsroom-summary/resource.js +8 -0
  44. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  45. package/amplify/functions/procedure-action/handler.ts +621 -0
  46. package/amplify/functions/procedure-action/resource.js +8 -0
  47. package/amplify/functions/procedure-action/resource.ts +9 -0
  48. package/amplify/functions/reader-settings/handler.ts +322 -0
  49. package/amplify/functions/reader-settings/resource.js +8 -0
  50. package/amplify/functions/reader-settings/resource.ts +9 -0
  51. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  52. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  53. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  54. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  55. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  56. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  57. package/amplify/functions/shared/email-submission.ts +415 -0
  58. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  59. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  60. package/amplify/functions/shared/model-payloads.ts +186 -0
  61. package/amplify/functions/shared/python-bundle.js +130 -0
  62. package/amplify/functions/shared/python-bundle.ts +162 -0
  63. package/amplify/functions/shared/slack-events.ts +111 -0
  64. package/amplify/functions/slack-delivery/handler.py +112 -0
  65. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  66. package/amplify/functions/slack-delivery/resource.js +38 -0
  67. package/amplify/functions/slack-delivery/resource.ts +51 -0
  68. package/amplify/functions/slack-events/handler.ts +155 -0
  69. package/amplify/functions/slack-events/resource.js +11 -0
  70. package/amplify/functions/slack-events/resource.ts +12 -0
  71. package/amplify/inbound-email/stack.js +46 -0
  72. package/amplify/inbound-email/stack.ts +61 -0
  73. package/amplify/package.json +3 -0
  74. package/amplify/rebuild-trigger-settings.js +22 -0
  75. package/amplify/rebuild-trigger-settings.ts +42 -0
  76. package/amplify/site-backend-config.js +1 -0
  77. package/amplify/site-backend-config.ts +36 -0
  78. package/amplify/site-backend.js +615 -0
  79. package/amplify/site-backend.ts +739 -0
  80. package/amplify/storage/resource.js +21 -0
  81. package/amplify/storage/resource.ts +22 -0
  82. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  83. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  84. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  85. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  86. package/app/[year]/[month]/[day]/page.tsx +22 -0
  87. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  88. package/app/api/archive/editions/route.ts +18 -0
  89. package/app/api/media/[...path]/route.ts +35 -0
  90. package/app/api/revalidate/route.ts +94 -0
  91. package/app/archive/page.tsx +24 -0
  92. package/app/articles/[slug]/page.tsx +35 -0
  93. package/app/globals.css +10345 -0
  94. package/app/layout.tsx +142 -0
  95. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  96. package/app/newsroom/[section]/page.tsx +47 -0
  97. package/app/newsroom/articles/[id]/page.tsx +15 -0
  98. package/app/newsroom/articles/page.tsx +13 -0
  99. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  100. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  101. package/app/newsroom/insights/page.tsx +14 -0
  102. package/app/newsroom/layout.tsx +12 -0
  103. package/app/newsroom/page.tsx +31 -0
  104. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  105. package/app/page.tsx +272 -0
  106. package/app/papyrus-theme.css +11 -0
  107. package/app/robots.ts +11 -0
  108. package/app/settings/page.tsx +14 -0
  109. package/app/tailwind.css +65 -0
  110. package/bin/papyrus-app.mjs +20 -0
  111. package/bin/papyrus-infra.mjs +95 -0
  112. package/components/ai-elements/conversation.tsx +168 -0
  113. package/components/ai-elements/message.tsx +360 -0
  114. package/components/ai-elements/model-selector.tsx +30 -0
  115. package/components/ai-elements/prompt-input.tsx +1461 -0
  116. package/components/ai-elements/shimmer.tsx +76 -0
  117. package/components/ai-elements/suggestion.tsx +54 -0
  118. package/components/ai-elements/tool.tsx +173 -0
  119. package/components/amplify-client-provider.tsx +140 -0
  120. package/components/archive-grid.tsx +89 -0
  121. package/components/archive-shell.tsx +41 -0
  122. package/components/article-video.tsx +156 -0
  123. package/components/blog-page-background.tsx +20 -0
  124. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  125. package/components/markus-ir-preview.tsx +300 -0
  126. package/components/news-desk-client-provider.tsx +211 -0
  127. package/components/news-desk-page.tsx +116 -0
  128. package/components/news-desk-taxonomy-client.ts +3684 -0
  129. package/components/newspaper.tsx +1929 -0
  130. package/components/newsroom-access-gate.tsx +66 -0
  131. package/components/newsroom-article-editor.tsx +741 -0
  132. package/components/newsroom-articles-view.tsx +129 -0
  133. package/components/newsroom-assignments-view.tsx +664 -0
  134. package/components/newsroom-ops-overview.tsx +226 -0
  135. package/components/newsroom-ops-shell.tsx +321 -0
  136. package/components/newsroom-references-view.tsx +598 -0
  137. package/components/newsroom-topics-view.tsx +425 -0
  138. package/components/papyrus-console-shell.tsx +1912 -0
  139. package/components/pictogram-figure.tsx +65 -0
  140. package/components/presentation-footer.tsx +146 -0
  141. package/components/reader-auth-control.tsx +162 -0
  142. package/components/reader-auth-state.ts +122 -0
  143. package/components/reader-settings.ts +332 -0
  144. package/components/reader-theme-control.tsx +85 -0
  145. package/components/reference-source-preview.tsx +163 -0
  146. package/components/settings-page.tsx +239 -0
  147. package/components/staging-banner.tsx +11 -0
  148. package/components/topic-steering-workspace.tsx +18005 -0
  149. package/components/ui/badge.tsx +30 -0
  150. package/components/ui/button-group.tsx +87 -0
  151. package/components/ui/button.tsx +58 -0
  152. package/components/ui/card.tsx +32 -0
  153. package/components/ui/command.tsx +194 -0
  154. package/components/ui/dialog.tsx +158 -0
  155. package/components/ui/dropdown-menu.tsx +266 -0
  156. package/components/ui/hover-card.tsx +51 -0
  157. package/components/ui/input-group.tsx +158 -0
  158. package/components/ui/input.tsx +20 -0
  159. package/components/ui/scroll-area.tsx +16 -0
  160. package/components/ui/select.tsx +199 -0
  161. package/components/ui/separator.tsx +23 -0
  162. package/components/ui/sheet.tsx +92 -0
  163. package/components/ui/spinner.tsx +10 -0
  164. package/components/ui/tabs.tsx +113 -0
  165. package/components/ui/textarea.tsx +18 -0
  166. package/components/ui/tooltip.tsx +66 -0
  167. package/components/use-resolved-papyrus-theme.ts +47 -0
  168. package/infra/amplify-app-shell.js +234 -0
  169. package/infra/amplify-app-shell.ts +284 -0
  170. package/infra/build-specs.js +85 -0
  171. package/infra/build-specs.ts +131 -0
  172. package/infra/github-oidc-provider.js +24 -0
  173. package/infra/github-oidc-provider.ts +30 -0
  174. package/infra/index.js +15 -0
  175. package/infra/site-config.js +186 -0
  176. package/infra/site-config.ts +235 -0
  177. package/lib/amplify-outputs-guard.ts +24 -0
  178. package/lib/amplify-outputs-path.ts +16 -0
  179. package/lib/amplify-server-runtime.ts +63 -0
  180. package/lib/archive-data.ts +30 -0
  181. package/lib/archive-types.ts +15 -0
  182. package/lib/articles.ts +320 -0
  183. package/lib/cached-content-repository.ts +90 -0
  184. package/lib/category-dashboard.ts +37 -0
  185. package/lib/category-repository.ts +1907 -0
  186. package/lib/console-chat-client.ts +1316 -0
  187. package/lib/console-web-context.ts +118 -0
  188. package/lib/content-actions-client.ts +98 -0
  189. package/lib/content-actions-demo.ts +190 -0
  190. package/lib/content-load-timing.ts +23 -0
  191. package/lib/content-repository.ts +43 -0
  192. package/lib/content-source-context.ts +26 -0
  193. package/lib/content-types.ts +104 -0
  194. package/lib/define-site.js +3 -0
  195. package/lib/define-site.ts +21 -0
  196. package/lib/demo-amplify-outputs.ts +15 -0
  197. package/lib/dev-sandbox-editor-auth.ts +18 -0
  198. package/lib/doctrine.ts +128 -0
  199. package/lib/edition-routes.ts +155 -0
  200. package/lib/edition-sections.ts +111 -0
  201. package/lib/empty-edition-layout-plan.ts +109 -0
  202. package/lib/empty-theme.css +1 -0
  203. package/lib/excerpts.ts +8 -0
  204. package/lib/graphql-content-repository.ts +1140 -0
  205. package/lib/image-url.ts +10 -0
  206. package/lib/layout-plan.ts +981 -0
  207. package/lib/layout-scenarios.ts +564 -0
  208. package/lib/markus-body.ts +168 -0
  209. package/lib/markus-ir.ts +235 -0
  210. package/lib/markus-projection.ts +243 -0
  211. package/lib/markus-to-article.ts +93 -0
  212. package/lib/news-desk-session.ts +160 -0
  213. package/lib/newspaper-layout.ts +3459 -0
  214. package/lib/newsroom-articles.ts +325 -0
  215. package/lib/newsroom-assignments.ts +173 -0
  216. package/lib/newsroom-base-path.ts +48 -0
  217. package/lib/newsroom-card-layout.ts +102 -0
  218. package/lib/newsroom-category-drilldown.ts +133 -0
  219. package/lib/newsroom-demo-articles.ts +90 -0
  220. package/lib/newsroom-demo-dashboard.ts +267 -0
  221. package/lib/newsroom-demo-profile.ts +46 -0
  222. package/lib/newsroom-forum-routes.ts +153 -0
  223. package/lib/newsroom-index-filters.ts +202 -0
  224. package/lib/newsroom-knowledge-query-request.ts +51 -0
  225. package/lib/newsroom-list-selection.ts +42 -0
  226. package/lib/newsroom-media-backend.ts +142 -0
  227. package/lib/newsroom-media.ts +182 -0
  228. package/lib/newsroom-nav.ts +44 -0
  229. package/lib/newsroom-references.ts +116 -0
  230. package/lib/newsroom-sections.ts +52 -0
  231. package/lib/newsroom-topics.ts +118 -0
  232. package/lib/papyrus-object-kinds.ts +24 -0
  233. package/lib/papyrus-web-locations.ts +480 -0
  234. package/lib/pilobol-sample.ts +167 -0
  235. package/lib/presentation-footer.ts +92 -0
  236. package/lib/pretext-layout.ts +179 -0
  237. package/lib/public-placeholder-config.ts +102 -0
  238. package/lib/publication-items.ts +181 -0
  239. package/lib/reader-route-config.ts +1 -0
  240. package/lib/reader-static-params.ts +56 -0
  241. package/lib/reader-storage-url.ts +63 -0
  242. package/lib/reference-display.ts +74 -0
  243. package/lib/reference-policy.ts +97 -0
  244. package/lib/reference-source-preview.ts +175 -0
  245. package/lib/renderer-config.ts +50 -0
  246. package/lib/renderer.ts +87 -0
  247. package/lib/reporting-story-budget.ts +638 -0
  248. package/lib/semantic-graph.ts +554 -0
  249. package/lib/site-brand-tenant.ts +22 -0
  250. package/lib/site-brand.ts +194 -0
  251. package/lib/site-env.ts +52 -0
  252. package/lib/site-renderer.ts +17 -0
  253. package/lib/site-stack.ts +195 -0
  254. package/lib/staging-gate.ts +37 -0
  255. package/lib/staging-preview-object.ts +98 -0
  256. package/lib/themed-image.ts +30 -0
  257. package/lib/utils.ts +6 -0
  258. package/lib/video-mode.ts +42 -0
  259. package/lib/video-script.ts +63 -0
  260. package/middleware.ts +103 -0
  261. package/package.json +318 -1
  262. package/renderers/markus/index.ts +25 -0
  263. package/renderers/markus/stubs.tsx +33 -0
  264. package/renderers/pretext/article-page.tsx +169 -0
  265. package/renderers/pretext/index.ts +30 -0
  266. package/renderers/pretext/layouts.ts +16 -0
  267. package/renderers/pretext/presentation-shell.tsx +470 -0
  268. package/routes.manifest.json +208 -0
  269. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  270. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  271. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  272. package/scripts/videoml/bundle-common.mjs +129 -0
  273. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  274. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  275. package/scripts/videoml/preview/preview-timing.ts +42 -0
  276. package/scripts/videoml/preview.html +77 -0
  277. package/scripts/videoml/standard-entry.tsx +46 -0
  278. package/src/backend.js +1 -0
  279. package/src/backend.ts +3 -0
  280. package/src/sync.mjs +87 -0
  281. package/src/with-papyrus.d.mts +2 -0
  282. package/src/with-papyrus.mjs +60 -0
@@ -0,0 +1,3684 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { downloadData, getUrl } from "aws-amplify/storage";
5
+ import type { Schema } from "../amplify/data/resource";
6
+ import type { NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
7
+ import { createEmptyCategorySteeringDashboard } from "../lib/category-dashboard";
8
+ import type {
9
+ CategorySteeringArtifact,
10
+ CategorySteeringCorpus,
11
+ CategorySteeringDashboard,
12
+ CategorySteeringImportRun,
13
+ CategorySteeringProposal,
14
+ CategorySteeringCategoryTree,
15
+ CategorySteeringCategoryTreeNode,
16
+ CategorySteeringCategory,
17
+ CategorySteeringCategorySet,
18
+ CategoryKeywordRecord,
19
+ MessageRecord,
20
+ ModelAttachmentRecord,
21
+ HydratedModelPayload,
22
+ NewsroomSummaryRecord,
23
+ LexicalSteeringRuleRecord,
24
+ AssignmentEventRecord,
25
+ AssignmentRecord,
26
+ EditionRecord,
27
+ EditionSlotRecord,
28
+ MessageThreadRecord,
29
+ DoctrineRecord,
30
+ NewsroomSectionRecord,
31
+ ProcedureDefinitionRecord,
32
+ ProcedureVersionRecord,
33
+ ProcedureRunRecord,
34
+ ReferenceAttachmentRecord,
35
+ ReferenceRecord,
36
+ SemanticNodeRecord,
37
+ SemanticRelationRecord,
38
+ UserDirectoryEntry,
39
+ } from "../lib/category-repository";
40
+ import { relationTypeKey } from "../lib/semantic-graph";
41
+ import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
42
+ import { configureAmplifyClient } from "./amplify-client-provider";
43
+ import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
44
+
45
+ const USER_POOL_AUTH_MODE = "userPool";
46
+ const USER_POOL_LIST_LIMIT = 500;
47
+ const USER_POOL_PAGE_LIMIT = 50;
48
+ const TEST_EDITOR_STORAGE_KEY = "papyrus:test-editor";
49
+ const TEST_EDITOR_NEWSROOM_MOCK_STORAGE_KEY = "papyrus:test-newsroom-mock";
50
+ const NEWSROOM_PAGE_LIMIT = 50;
51
+ const REFERENCE_REVIEWED_FEED_KEY = "references#reviewed";
52
+ const REFERENCE_STATUS_KEYS = ["pending", "accepted", "rejected", "archived"] as const;
53
+
54
+ const NEWSROOM_MESSAGE_FEED_QUERY = `
55
+ query ListMessagesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelMessageFilterInput) {
56
+ listMessagesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
57
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
58
+ nextToken
59
+ }
60
+ }
61
+ `;
62
+
63
+ const LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY = `
64
+ query ListMessageThreadsByKindAndUpdatedAt($threadKind: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
65
+ listMessageThreadsByKindAndUpdatedAt(threadKind: $threadKind, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
66
+ items {
67
+ id
68
+ threadKind
69
+ status
70
+ title
71
+ summary
72
+ primaryAnchorKind
73
+ primaryAnchorId
74
+ primaryAnchorLineageId
75
+ primaryAnchorKey
76
+ createdBySub
77
+ createdByUserProfileId
78
+ createdByLabel
79
+ messageCount
80
+ lastMessageId
81
+ lastMessageAt
82
+ contextDigest
83
+ activeResponseMessageId
84
+ responseLockOwner
85
+ responseLockExpiresAt
86
+ metadata
87
+ createdAt
88
+ updatedAt
89
+ newsroomFeedKey
90
+ }
91
+ nextToken
92
+ }
93
+ }
94
+ `;
95
+
96
+ const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
97
+ query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
98
+ listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
99
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
100
+ nextToken
101
+ }
102
+ }
103
+ `;
104
+
105
+ const LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY = `
106
+ query ListMessagesByKindAndCreatedAt($messageKind: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
107
+ listMessagesByKindAndCreatedAt(messageKind: $messageKind, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
108
+ items { id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey }
109
+ nextToken
110
+ }
111
+ }
112
+ `;
113
+
114
+ const GET_MESSAGE_QUERY = `
115
+ query GetMessage($id: ID!) {
116
+ getMessage(id: $id) {
117
+ id messageKind messageDomain status summary source importRunId authorSub authorUserProfileId authorLabel threadId parentMessageId sequenceNumber role messageType content semanticLayer searchVisibility responseTarget responseStatus responseOwner responseStartedAt responseCompletedAt responseError metadata createdAt updatedAt newsroomFeedKey
118
+ }
119
+ }
120
+ `;
121
+
122
+ const CREATE_MESSAGE_THREAD_MUTATION = `
123
+ mutation CreateMessageThread($input: CreateMessageThreadInput!) {
124
+ createMessageThread(input: $input) {
125
+ id
126
+ threadKind
127
+ status
128
+ title
129
+ summary
130
+ primaryAnchorKind
131
+ primaryAnchorId
132
+ primaryAnchorLineageId
133
+ primaryAnchorKey
134
+ createdBySub
135
+ createdByUserProfileId
136
+ createdByLabel
137
+ messageCount
138
+ lastMessageId
139
+ lastMessageAt
140
+ contextDigest
141
+ activeResponseMessageId
142
+ responseLockOwner
143
+ responseLockExpiresAt
144
+ metadata
145
+ createdAt
146
+ updatedAt
147
+ newsroomFeedKey
148
+ }
149
+ }
150
+ `;
151
+
152
+ const UPDATE_MESSAGE_THREAD_MUTATION = `
153
+ mutation UpdateMessageThread($input: UpdateMessageThreadInput!) {
154
+ updateMessageThread(input: $input) {
155
+ id
156
+ threadKind
157
+ status
158
+ title
159
+ summary
160
+ primaryAnchorKind
161
+ primaryAnchorId
162
+ primaryAnchorLineageId
163
+ primaryAnchorKey
164
+ createdBySub
165
+ createdByUserProfileId
166
+ createdByLabel
167
+ messageCount
168
+ lastMessageId
169
+ lastMessageAt
170
+ contextDigest
171
+ activeResponseMessageId
172
+ responseLockOwner
173
+ responseLockExpiresAt
174
+ metadata
175
+ createdAt
176
+ updatedAt
177
+ newsroomFeedKey
178
+ }
179
+ }
180
+ `;
181
+
182
+ const CREATE_MESSAGE_MUTATION = `
183
+ mutation CreateMessage($input: CreateMessageInput!) {
184
+ createMessage(input: $input) {
185
+ id
186
+ messageKind
187
+ messageDomain
188
+ status
189
+ summary
190
+ source
191
+ importRunId
192
+ authorSub
193
+ authorUserProfileId
194
+ authorLabel
195
+ threadId
196
+ parentMessageId
197
+ sequenceNumber
198
+ role
199
+ messageType
200
+ content
201
+ semanticLayer
202
+ searchVisibility
203
+ responseTarget
204
+ responseStatus
205
+ responseOwner
206
+ responseStartedAt
207
+ responseCompletedAt
208
+ responseError
209
+ metadata
210
+ createdAt
211
+ updatedAt
212
+ newsroomFeedKey
213
+ }
214
+ }
215
+ `;
216
+
217
+ const UPDATE_MESSAGE_MUTATION = `
218
+ mutation UpdateMessage($input: UpdateMessageInput!) {
219
+ updateMessage(input: $input) {
220
+ id
221
+ messageKind
222
+ messageDomain
223
+ status
224
+ summary
225
+ source
226
+ importRunId
227
+ authorSub
228
+ authorUserProfileId
229
+ authorLabel
230
+ threadId
231
+ parentMessageId
232
+ sequenceNumber
233
+ role
234
+ messageType
235
+ content
236
+ semanticLayer
237
+ searchVisibility
238
+ responseTarget
239
+ responseStatus
240
+ responseOwner
241
+ responseStartedAt
242
+ responseCompletedAt
243
+ responseError
244
+ metadata
245
+ createdAt
246
+ updatedAt
247
+ newsroomFeedKey
248
+ }
249
+ }
250
+ `;
251
+
252
+ const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
253
+ query ListAssignmentsByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelAssignmentFilterInput) {
254
+ listAssignmentsByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
255
+ items { id assignmentTypeKey queueKey queueStatusKey status priority title summary assigneeType assigneeId assigneeKey claimedAt claimExpiresAt completedAt canceledAt corpusId categorySetId classifierId sectionId sectionKey sectionType sectionStatusKey sectionQueueStatusKey primaryFocusCategoryKey topicScopeCategoryKeys sourceSnapshotId importRunId createdBy createdAt updatedAt newsroomFeedKey }
256
+ nextToken
257
+ }
258
+ }
259
+ `;
260
+
261
+ const NEWSROOM_REFERENCE_FEED_QUERY = `
262
+ query ListReferencesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
263
+ listReferencesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
264
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
265
+ nextToken
266
+ }
267
+ }
268
+ `;
269
+
270
+ const NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY = `
271
+ query ListReferencesByNewsroomFeedAndImportedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
272
+ listReferencesByNewsroomFeedAndImportedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
273
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
274
+ nextToken
275
+ }
276
+ }
277
+ `;
278
+
279
+ const LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY = `
280
+ query ListReferencesByCurationStatusAndUpdatedAt($curationStatus: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
281
+ listReferencesByCurationStatusAndUpdatedAt(curationStatus: $curationStatus, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
282
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
283
+ nextToken
284
+ }
285
+ }
286
+ `;
287
+
288
+ const LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY = `
289
+ query ListReferencesByReviewedFeedAndUpdatedAt($reviewedFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelReferenceFilterInput) {
290
+ listReferencesByReviewedFeedAndUpdatedAt(reviewedFeedKey: $reviewedFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
291
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
292
+ nextToken
293
+ }
294
+ }
295
+ `;
296
+
297
+ const LIST_REFERENCES_FALLBACK_QUERY = `
298
+ query ListReferencesFallback($limit: Int, $nextToken: String) {
299
+ listReferences(limit: $limit, nextToken: $nextToken) {
300
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt }
301
+ nextToken
302
+ }
303
+ }
304
+ `;
305
+
306
+ const NEWSROOM_SEMANTIC_NODE_FEED_QUERY = `
307
+ query ListSemanticNodesByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelSemanticNodeFilterInput) {
308
+ listSemanticNodesByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
309
+ items { id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash nodeKey nodeKind corpusId categorySetId categoryLineageId categoryKey displayName description aliases authorityScore authorityRank acceptedReferenceMentionCount distinctSourceKindCount relationCount status importRunId createdAt newsroomFeedKey updatedAt }
310
+ nextToken
311
+ }
312
+ }
313
+ `;
314
+
315
+ const NEWSROOM_SEMANTIC_RELATION_FEED_QUERY = `
316
+ query ListSemanticRelationsByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelSemanticRelationFilterInput) {
317
+ listSemanticRelationsByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
318
+ items { id relationState predicate relationTypeId relationTypeKey relationDomain subjectKind subjectId subjectLineageId subjectVersionNumber objectKind objectId objectLineageId objectVersionNumber subjectStateKey objectStateKey objectSubjectStateKey predicateObjectStateKey subjectVersionKey objectVersionKey score confidence rank classifierId modelVersion reviewRecommended sourceSnapshotId importRunId importedAt createdAt updatedAt newsroomFeedKey metadata }
319
+ nextToken
320
+ }
321
+ }
322
+ `;
323
+
324
+ const LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY = `
325
+ query ListSemanticRelationsBySubjectState($subjectStateKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelSemanticRelationFilterInput) {
326
+ listSemanticRelationsBySubjectState(subjectStateKey: $subjectStateKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
327
+ items { id relationState predicate relationTypeId relationTypeKey relationDomain subjectKind subjectId subjectLineageId subjectVersionNumber objectKind objectId objectLineageId objectVersionNumber subjectStateKey objectStateKey objectSubjectStateKey predicateObjectStateKey subjectVersionKey objectVersionKey score confidence rank classifierId modelVersion reviewRecommended sourceSnapshotId importRunId importedAt createdAt updatedAt newsroomFeedKey metadata }
328
+ nextToken
329
+ }
330
+ }
331
+ `;
332
+
333
+ const LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY = `
334
+ query ListSemanticRelationsByObjectState($objectStateKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelSemanticRelationFilterInput) {
335
+ listSemanticRelationsByObjectState(objectStateKey: $objectStateKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
336
+ items { id relationState predicate relationTypeId relationTypeKey relationDomain subjectKind subjectId subjectLineageId subjectVersionNumber objectKind objectId objectLineageId objectVersionNumber subjectStateKey objectStateKey objectSubjectStateKey predicateObjectStateKey subjectVersionKey objectVersionKey score confidence rank classifierId modelVersion reviewRecommended sourceSnapshotId importRunId importedAt createdAt updatedAt newsroomFeedKey metadata }
337
+ nextToken
338
+ }
339
+ }
340
+ `;
341
+
342
+ const NEWSROOM_SECTION_LIST_QUERY = `
343
+ query ListNewsroomSections($limit: Int, $nextToken: String) {
344
+ listNewsroomSections(limit: $limit, nextToken: $nextToken) {
345
+ items {
346
+ id
347
+ title
348
+ shortTitle
349
+ type
350
+ editorialMission
351
+ editorialPolicy
352
+ enabled
353
+ enabledStatus
354
+ sortOrder
355
+ defaultArticleTypes
356
+ defaultPageBudget
357
+ assignmentGuidance
358
+ killCriteria
359
+ visualGuidance
360
+ createdAt
361
+ updatedAt
362
+ }
363
+ nextToken
364
+ }
365
+ }
366
+ `;
367
+
368
+ const LIST_STEERING_PROPOSALS_QUERY = `
369
+ query ListSteeringProposals($limit: Int, $nextToken: String) {
370
+ listSteeringProposals(limit: $limit, nextToken: $nextToken) {
371
+ items {
372
+ id
373
+ categorySetId
374
+ corpusId
375
+ importRunId
376
+ proposalKind
377
+ steeringDomain
378
+ status
379
+ title
380
+ summary
381
+ categoryKey
382
+ targetCategoryKey
383
+ graphEntityId
384
+ relationshipType
385
+ displayName
386
+ shortTitle
387
+ subtitle
388
+ description
389
+ evidenceItemIds
390
+ suggestedSeedItemIds
391
+ suggestedHoldoutItemIds
392
+ sourceSnapshotId
393
+ proposedAt
394
+ reviewedAt
395
+ reviewedBy
396
+ updatedAt
397
+ }
398
+ nextToken
399
+ }
400
+ }
401
+ `;
402
+
403
+ const MODEL_ATTACHMENTS_BY_OWNER_QUERY = `
404
+ query ListModelAttachmentsByOwnerRoleAndSortKey($ownerId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
405
+ listModelAttachmentsByOwnerRoleAndSortKey(ownerId: $ownerId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
406
+ items {
407
+ id
408
+ ownerKind
409
+ ownerId
410
+ ownerLineageId
411
+ ownerVersionNumber
412
+ ownerVersionKey
413
+ role
414
+ sortKey
415
+ storagePath
416
+ filename
417
+ mediaType
418
+ byteSize
419
+ sha256
420
+ etag
421
+ importRunId
422
+ createdAt
423
+ updatedAt
424
+ status
425
+ }
426
+ nextToken
427
+ }
428
+ }
429
+ `;
430
+
431
+ /** Matches Reference model fields in amplify/data/resource.ts (no metadata on Reference). */
432
+ const REFERENCE_RECORD_GRAPHQL_FIELDS =
433
+ "id lineageId versionNumber previousVersionId versionState versionCreatedAt versionCreatedBy changeReason contentHash corpusId externalItemId title authors sourceUri storagePath mediaType byteSize sha256 sourcePublishedAt sourceUpdatedAt retrievedAt inboundCitationCount outboundCitationCount importRunId importedAt createdAt curationStatus curationStatusKey curationStatusUpdatedAt curationStatusUpdatedBy curationStatusReason newsroomFeedKey reviewedFeedKey updatedAt";
434
+
435
+ const LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY = `
436
+ query ListReferencesByLineageAndVersion($lineageId: ID!, $limit: Int, $nextToken: String) {
437
+ listReferencesByLineageAndVersion(lineageId: $lineageId, limit: $limit, nextToken: $nextToken) {
438
+ items { ${REFERENCE_RECORD_GRAPHQL_FIELDS} }
439
+ nextToken
440
+ }
441
+ }
442
+ `;
443
+
444
+ const GET_REFERENCE_QUERY = `
445
+ query GetReference($id: ID!) {
446
+ getReference(id: $id) {
447
+ ${REFERENCE_RECORD_GRAPHQL_FIELDS}
448
+ }
449
+ }
450
+ `;
451
+
452
+ const LIST_REFERENCE_ATTACHMENTS_BY_REFERENCE_LINEAGE_AND_SORT_KEY_QUERY = `
453
+ query ListReferenceAttachmentsByReferenceLineageAndSortKey($referenceLineageId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
454
+ listReferenceAttachmentsByReferenceLineageAndSortKey(referenceLineageId: $referenceLineageId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
455
+ items {
456
+ id
457
+ referenceId
458
+ referenceLineageId
459
+ referenceVersionNumber
460
+ referenceVersionKey
461
+ role
462
+ sortKey
463
+ storagePath
464
+ sourceUri
465
+ filename
466
+ mediaType
467
+ byteSize
468
+ sha256
469
+ etag
470
+ importRunId
471
+ importedAt
472
+ metadata
473
+ }
474
+ nextToken
475
+ }
476
+ }
477
+ `;
478
+
479
+ const CREATE_MODEL_ATTACHMENT_UPLOAD_MUTATION = `
480
+ mutation CreateModelAttachmentUpload(
481
+ $ownerKind: String!
482
+ $ownerId: ID!
483
+ $ownerLineageId: ID
484
+ $ownerVersionNumber: Int
485
+ $ownerVersionKey: String
486
+ $role: String!
487
+ $sortKey: String
488
+ $filename: String!
489
+ $mediaType: String!
490
+ $byteSize: Int!
491
+ $sha256: String
492
+ $importRunId: ID
493
+ $status: String
494
+ ) {
495
+ createModelAttachmentUpload(
496
+ ownerKind: $ownerKind
497
+ ownerId: $ownerId
498
+ ownerLineageId: $ownerLineageId
499
+ ownerVersionNumber: $ownerVersionNumber
500
+ ownerVersionKey: $ownerVersionKey
501
+ role: $role
502
+ sortKey: $sortKey
503
+ filename: $filename
504
+ mediaType: $mediaType
505
+ byteSize: $byteSize
506
+ sha256: $sha256
507
+ importRunId: $importRunId
508
+ status: $status
509
+ ) {
510
+ ok uploadId attachmentId ownerKind ownerId role sortKey method uploadUrl storagePath mediaType byteSize sha256 expiresAt requiredHeaders
511
+ }
512
+ }
513
+ `;
514
+
515
+ const CREATE_MODEL_ATTACHMENT_DOWNLOAD_MUTATION = `
516
+ mutation CreateModelAttachmentDownload($attachmentId: ID!) {
517
+ createModelAttachmentDownload(attachmentId: $attachmentId) {
518
+ ok
519
+ attachmentId
520
+ method
521
+ downloadUrl
522
+ storagePath
523
+ mediaType
524
+ byteSize
525
+ sha256
526
+ expiresAt
527
+ requiredHeaders
528
+ }
529
+ }
530
+ `;
531
+
532
+ const COMPLETE_MODEL_ATTACHMENT_UPLOAD_MUTATION = `
533
+ mutation CompleteModelAttachmentUpload(
534
+ $uploadId: String!
535
+ $ownerKind: String!
536
+ $ownerId: ID!
537
+ $ownerLineageId: ID
538
+ $ownerVersionNumber: Int
539
+ $ownerVersionKey: String
540
+ $role: String!
541
+ $sortKey: String
542
+ $filename: String!
543
+ $mediaType: String!
544
+ $byteSize: Int!
545
+ $sha256: String
546
+ $importRunId: ID
547
+ $status: String
548
+ ) {
549
+ completeModelAttachmentUpload(
550
+ uploadId: $uploadId
551
+ ownerKind: $ownerKind
552
+ ownerId: $ownerId
553
+ ownerLineageId: $ownerLineageId
554
+ ownerVersionNumber: $ownerVersionNumber
555
+ ownerVersionKey: $ownerVersionKey
556
+ role: $role
557
+ sortKey: $sortKey
558
+ filename: $filename
559
+ mediaType: $mediaType
560
+ byteSize: $byteSize
561
+ sha256: $sha256
562
+ importRunId: $importRunId
563
+ status: $status
564
+ ) {
565
+ id ownerKind ownerId ownerLineageId ownerVersionNumber ownerVersionKey role sortKey storagePath filename mediaType byteSize sha256 etag importRunId createdAt updatedAt status
566
+ }
567
+ }
568
+ `;
569
+
570
+ const LIST_PROCEDURE_DEFINITIONS_QUERY = `
571
+ query ListProcedureDefinitions {
572
+ listNewsroomProcedureDefinitions
573
+ }
574
+ `;
575
+
576
+ const GET_PROCEDURE_DEFINITION_QUERY = `
577
+ query GetProcedureDefinition($id: ID, $procedureKey: String) {
578
+ getNewsroomProcedureDefinition(id: $id, procedureKey: $procedureKey)
579
+ }
580
+ `;
581
+
582
+ const SAVE_PROCEDURE_DEFINITION_MUTATION = `
583
+ mutation SaveProcedureDefinition($input: AWSJSON!) {
584
+ saveNewsroomProcedureDefinition(input: $input)
585
+ }
586
+ `;
587
+
588
+ const SAVE_PROCEDURE_VERSION_DRAFT_MUTATION = `
589
+ mutation SaveProcedureVersionDraft($input: AWSJSON!) {
590
+ saveNewsroomProcedureVersionDraft(input: $input)
591
+ }
592
+ `;
593
+
594
+ const PUBLISH_PROCEDURE_VERSION_MUTATION = `
595
+ mutation PublishProcedureVersion($versionId: ID!) {
596
+ publishNewsroomProcedureVersion(versionId: $versionId)
597
+ }
598
+ `;
599
+
600
+ const START_PROCEDURE_RUN_MUTATION = `
601
+ mutation StartProcedureRun(
602
+ $procedureId: ID
603
+ $procedureKey: String
604
+ $procedureVersionId: ID
605
+ $title: String
606
+ $summary: String
607
+ $input: AWSJSON
608
+ $actorLabel: String
609
+ ) {
610
+ startNewsroomProcedureRun(
611
+ procedureId: $procedureId
612
+ procedureKey: $procedureKey
613
+ procedureVersionId: $procedureVersionId
614
+ title: $title
615
+ summary: $summary
616
+ input: $input
617
+ actorLabel: $actorLabel
618
+ )
619
+ }
620
+ `;
621
+
622
+ type GraphQLListResponse<T> = {
623
+ data?: T[] | null;
624
+ nextToken?: string | null;
625
+ errors?: unknown[] | null;
626
+ };
627
+
628
+ type ListableModel<T> = {
629
+ list: (input?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<GraphQLListResponse<T>>;
630
+ };
631
+
632
+ export type NewsroomRecordPage<T> = {
633
+ items: T[];
634
+ nextToken?: string | null;
635
+ hasMore: boolean;
636
+ };
637
+
638
+ type GraphQLConnectionResponse<T> = {
639
+ data?: Record<string, { items?: Array<T | null> | null; nextToken?: string | null } | null> | null;
640
+ errors?: unknown[] | null;
641
+ };
642
+
643
+ type ModelAttachmentUploadSlot = {
644
+ ok: boolean;
645
+ uploadId: string;
646
+ requiredHeaders?: Record<string, string> | string | null;
647
+ method?: string | null;
648
+ uploadUrl: string;
649
+ };
650
+
651
+ type NewsroomPageOptions = {
652
+ limit?: number;
653
+ nextToken?: string | null;
654
+ };
655
+
656
+ type NewsroomMessagePageOptions = NewsroomPageOptions & {
657
+ kind?: string;
658
+ domain?: string;
659
+ status?: string;
660
+ };
661
+
662
+ type NewsroomAssignmentPageOptions = NewsroomPageOptions & {
663
+ type?: string;
664
+ status?: string;
665
+ };
666
+
667
+ export type NewsroomReferencePageOrder = "published" | "imported";
668
+
669
+ type NewsroomReferencePageOptions = NewsroomPageOptions & {
670
+ status?: string;
671
+ excludePending?: boolean;
672
+ corpusId?: string;
673
+ order?: NewsroomReferencePageOrder | string;
674
+ };
675
+
676
+ type NewsroomSemanticNodePageOptions = NewsroomPageOptions & {
677
+ nodeKind?: string;
678
+ status?: string;
679
+ };
680
+
681
+ type NewsroomSemanticRelationPageOptions = NewsroomPageOptions & {
682
+ relationTypeKey?: string;
683
+ relationDomain?: string;
684
+ };
685
+
686
+ export type ForumThreadWithMessages = MessageThreadRecord & {
687
+ messages: MessageRecord[];
688
+ scope: "edition" | "section" | "insight";
689
+ sectionKey?: string | null;
690
+ sectionTitle?: string | null;
691
+ };
692
+
693
+ export type InsightForumThreadsPage = {
694
+ threads: ForumThreadWithMessages[];
695
+ nextToken: string | null;
696
+ };
697
+
698
+ export type EditionForumThreadsResult = {
699
+ editionId: string;
700
+ editionThreads: ForumThreadWithMessages[];
701
+ sectionThreads: ForumThreadWithMessages[];
702
+ threadCount: number;
703
+ };
704
+
705
+ type SlugQueryableModel<T> = {
706
+ itemBySlug?: (args: { slug: string }, options: Record<string, unknown>) => Promise<GraphQLListResponse<T>>;
707
+ };
708
+
709
+ type NewsroomSummaryResponse = {
710
+ data?: unknown;
711
+ errors?: unknown[] | null;
712
+ };
713
+
714
+ export type KnowledgeQueryResponse = {
715
+ structured?: unknown;
716
+ context?: {
717
+ format?: string;
718
+ text?: string;
719
+ maxTokens?: number | null;
720
+ totalTokens?: number | null;
721
+ tokenizer?: unknown;
722
+ } | null;
723
+ warnings?: string[];
724
+ provenance?: Record<string, unknown>;
725
+ debug?: Record<string, unknown>;
726
+ };
727
+
728
+ export type EditorCategoryTreeState = {
729
+ isEditor: boolean;
730
+ appendix: NewsDeskAppendix | null;
731
+ categoryTrees: CategorySteeringCategoryTree[];
732
+ categoryNodes: CategorySteeringCategoryTreeNode[];
733
+ error: string | null;
734
+ };
735
+
736
+ export type EditorNewsDeskState =
737
+ | { status: "loading"; dashboard: null; error: null }
738
+ | { status: "signedOut"; dashboard: null; error: null }
739
+ | { status: "forbidden"; dashboard: null; error: null }
740
+ | { status: "ready"; dashboard: CategorySteeringDashboard; error: null }
741
+ | { status: "error"; dashboard: null; error: string };
742
+
743
+ export async function loadEditorAccessState(): Promise<{ isEditor: boolean; status: EditorAccessState["status"]; error: string | null }> {
744
+ const auth = await loadEditorResolvedAccessState();
745
+ return { isEditor: auth.isEditor, status: auth.status, error: auth.error };
746
+ }
747
+
748
+ export type EditorAccessState =
749
+ | { status: "signedOut"; isEditor: false; isAdmin: false; auth: ReaderAuthSnapshot; error: null }
750
+ | { status: "forbidden"; isEditor: false; isAdmin: false; auth: ReaderAuthSnapshot; error: null }
751
+ | { status: "ready"; isEditor: true; isAdmin: boolean; auth: ReaderAuthSnapshot; error: null }
752
+ | { status: "error"; isEditor: false; isAdmin: false; auth: ReaderAuthSnapshot; error: string };
753
+
754
+ export async function loadEditorNewsDeskState(): Promise<EditorNewsDeskState> {
755
+ const auth = await loadEditorResolvedAccessState();
756
+ if (auth.status === "signedOut" || auth.status === "forbidden") return { status: auth.status, dashboard: null, error: null };
757
+ if (auth.status === "error") return { status: "error", dashboard: null, error: auth.error };
758
+
759
+ try {
760
+ return { status: "ready", dashboard: await loadEditorNewsDeskDashboard({ isAdmin: auth.isAdmin }), error: null };
761
+ } catch (error) {
762
+ return {
763
+ status: "error",
764
+ dashboard: null,
765
+ error: error instanceof Error ? error.message : "Could not load Newsroom data.",
766
+ };
767
+ }
768
+ }
769
+
770
+ export async function loadEditorCategoryTreeState(options?: {
771
+ scenarioAppendix?: NewsDeskAppendix | null;
772
+ allowScenarioEditorOverride?: boolean;
773
+ }): Promise<EditorCategoryTreeState> {
774
+ configureAmplifyClient();
775
+
776
+ if (options?.allowScenarioEditorOverride && hasTestEditorOverride()) {
777
+ const appendix = options.scenarioAppendix ?? null;
778
+ return {
779
+ isEditor: true,
780
+ appendix,
781
+ categoryTrees: appendix ? [categoryTreeFromAppendix(appendix)] : [],
782
+ categoryNodes: appendix ? appendix.nodes.map(categoryTreeNodeFromAppendixNode) : [],
783
+ error: null,
784
+ };
785
+ }
786
+
787
+ const auth = await loadEditorResolvedAccessState();
788
+ if (!auth.isEditor) {
789
+ return { isEditor: false, appendix: null, categoryTrees: [], categoryNodes: [], error: auth.error };
790
+ }
791
+
792
+ try {
793
+ const [categoryTrees, categoryNodes] = await Promise.all([
794
+ listUserPoolModel<CategorySteeringCategoryTree>("CategorySet"),
795
+ listUserPoolModel<CategorySteeringCategoryTreeNode>("Category"),
796
+ ]);
797
+ const sortedTaxonomies = sortTaxonomies(categoryTrees);
798
+ const sortedNodes = sortCategoryTreeNodes(categoryNodes);
799
+ const selectedCategoryTree = selectCurrentAcceptedCategoryTree(sortedTaxonomies);
800
+ return {
801
+ isEditor: true,
802
+ appendix: selectedCategoryTree ? appendixFromCategoryTree(selectedCategoryTree, sortedNodes) : null,
803
+ categoryTrees: sortedTaxonomies,
804
+ categoryNodes: sortedNodes,
805
+ error: null,
806
+ };
807
+ } catch (error) {
808
+ if (isUnauthenticatedError(error)) {
809
+ return { isEditor: false, appendix: null, categoryTrees: [], categoryNodes: [], error: null };
810
+ }
811
+ return {
812
+ isEditor: false,
813
+ appendix: null,
814
+ categoryTrees: [],
815
+ categoryNodes: [],
816
+ error: error instanceof Error ? error.message : "Could not load editor categoryTree state.",
817
+ };
818
+ }
819
+ }
820
+
821
+ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState> {
822
+ if (hasTestEditorOverride()) {
823
+ return {
824
+ status: "ready",
825
+ isEditor: true,
826
+ isAdmin: true,
827
+ auth: { status: "signedIn", label: "Test Editor" },
828
+ error: null,
829
+ };
830
+ }
831
+ try {
832
+ const snapshot = await loadReaderSessionSnapshot();
833
+ if (!snapshot.hasSession || snapshot.auth.status === "signedOut") {
834
+ return { status: "signedOut", isEditor: false, isAdmin: false, auth: snapshot.auth, error: null };
835
+ }
836
+ const refreshed = await loadReaderSessionSnapshot({ forceRefresh: true });
837
+ if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
838
+ return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
839
+ }
840
+ const isEditor = refreshed.groups.some((group) => group === "editor" || group === "admin");
841
+ if (!isEditor) {
842
+ return {
843
+ status: "forbidden",
844
+ isEditor: false,
845
+ isAdmin: false,
846
+ auth: refreshed.auth,
847
+ error: null,
848
+ };
849
+ }
850
+ return {
851
+ status: "ready",
852
+ isEditor: true,
853
+ isAdmin: refreshed.groups.includes("admin"),
854
+ auth: refreshed.auth,
855
+ error: null,
856
+ };
857
+ } catch (error) {
858
+ return {
859
+ status: "error",
860
+ isEditor: false,
861
+ isAdmin: false,
862
+ auth: { status: "signedOut", label: "Signed out" },
863
+ error: error instanceof Error ? error.message : "Could not verify editor session.",
864
+ };
865
+ }
866
+ }
867
+
868
+ export async function loadEditorNewsDeskDashboard({ isAdmin }: { isAdmin: boolean }): Promise<CategorySteeringDashboard> {
869
+ const testMock = getTestEditorNewsroomMock();
870
+ if (testMock?.summary) {
871
+ return {
872
+ ...createSummaryCategorySteeringDashboard(normalizeNewsroomSummary(testMock.summary)),
873
+ canManageUsers: isAdmin,
874
+ userDirectory: [],
875
+ loadError: null,
876
+ };
877
+ }
878
+ const [summaryResult, userDirectoryResult] = await Promise.allSettled([
879
+ loadNewsroomSummary(),
880
+ isAdmin ? loadUserDirectory() : Promise.resolve([]),
881
+ ]);
882
+ const summaryError = summaryResult.status === "rejected" ? summaryResult.reason : null;
883
+ if (summaryError && isUnauthenticatedError(summaryError)) {
884
+ throw summaryError;
885
+ }
886
+ const summary = summaryResult.status === "fulfilled" ? summaryResult.value : null;
887
+ const userDirectory = userDirectoryResult.status === "fulfilled" ? userDirectoryResult.value : [];
888
+
889
+ if (summaryError || newsroomSummaryIsMissing(summary)) {
890
+ return {
891
+ ...createEmptyCategorySteeringDashboard(),
892
+ isPublicSkeleton: false,
893
+ summaryStatus: "missing",
894
+ canManageUsers: isAdmin,
895
+ userDirectory,
896
+ loadError: null,
897
+ };
898
+ }
899
+
900
+ return {
901
+ ...createSummaryCategorySteeringDashboard(summary!),
902
+ canManageUsers: isAdmin,
903
+ userDirectory,
904
+ loadError: null,
905
+ };
906
+ }
907
+
908
+ export async function loadEditorFullNewsDeskDashboard({ isAdmin }: { isAdmin: boolean }): Promise<CategorySteeringDashboard> {
909
+ const procedureDataPromise = isAdmin
910
+ ? loadEditorProcedureData().catch(() => ({ definitions: [], versions: [], runs: [] }))
911
+ : Promise.resolve<{ definitions: ProcedureDefinitionRecord[]; versions: ProcedureVersionRecord[]; runs: ProcedureRunRecord[] }>({
912
+ definitions: [],
913
+ versions: [],
914
+ runs: [],
915
+ });
916
+ const [
917
+ corpora,
918
+ importRuns,
919
+ categorySets,
920
+ categorys,
921
+ categoryKeywords,
922
+ lexicalSteeringRules,
923
+ proposals,
924
+ artifacts,
925
+ references,
926
+ referenceAttachments,
927
+ semanticNodes,
928
+ messages,
929
+ semanticRelations,
930
+ assignmentState,
931
+ editionSlots,
932
+ newsroomSections,
933
+ procedureData,
934
+ userDirectory,
935
+ ] = await Promise.all([
936
+ loadDashboardSlice("KnowledgeCorpus", () => listUserPoolModel<CategorySteeringCorpus>("KnowledgeCorpus"), [] as CategorySteeringCorpus[]),
937
+ loadDashboardSlice("KnowledgeImportRun", () => listUserPoolModel<CategorySteeringImportRun>("KnowledgeImportRun"), [] as CategorySteeringImportRun[]),
938
+ loadDashboardSlice("CategorySet", () => listUserPoolModel<CategorySteeringCategorySet>("CategorySet"), [] as CategorySteeringCategorySet[]),
939
+ loadDashboardSlice("Category", () => listUserPoolModel<CategorySteeringCategory>("Category"), [] as CategorySteeringCategory[]),
940
+ loadDashboardSlice("CategoryKeyword", () => listOptionalUserPoolModel<CategoryKeywordRecord>("CategoryKeyword"), [] as CategoryKeywordRecord[]),
941
+ loadDashboardSlice("LexicalSteeringRule", () => listOptionalUserPoolModel<LexicalSteeringRuleRecord>("LexicalSteeringRule"), [] as LexicalSteeringRuleRecord[]),
942
+ loadDashboardSlice("SteeringProposal", () => listSteeringProposalsViaGraphql(), [] as CategorySteeringProposal[]),
943
+ loadDashboardSlice("KnowledgeArtifact", () => listUserPoolModel<CategorySteeringArtifact>("KnowledgeArtifact"), [] as CategorySteeringArtifact[]),
944
+ loadDashboardSlice("Reference", () => listUserPoolModel<ReferenceRecord>("Reference"), [] as ReferenceRecord[]),
945
+ loadDashboardSlice("ReferenceAttachment", () => listUserPoolModel<ReferenceAttachmentRecord>("ReferenceAttachment"), [] as ReferenceAttachmentRecord[]),
946
+ loadDashboardSlice("SemanticNode", () => listUserPoolModel<SemanticNodeRecord>("SemanticNode"), [] as SemanticNodeRecord[]),
947
+ loadDashboardSlice("Message", () => listUserPoolModel<MessageRecord>("Message"), [] as MessageRecord[]),
948
+ loadDashboardSlice("SemanticRelation", () => listUserPoolModel<SemanticRelationRecord>("SemanticRelation"), [] as SemanticRelationRecord[]),
949
+ loadDashboardSlice("Assignments", () => loadEditorAssignmentsData(), { assignments: [], assignmentEvents: [] }),
950
+ loadDashboardSlice("EditionSlot", () => listOptionalUserPoolModel<EditionSlotRecord>("EditionSlot"), [] as EditionSlotRecord[]),
951
+ loadDashboardSlice("NewsroomSection", () => listOptionalUserPoolModel<NewsroomSectionRecord>("NewsroomSection"), [] as NewsroomSectionRecord[]),
952
+ procedureDataPromise,
953
+ loadDashboardSlice("UserDirectory", () => (isAdmin ? loadUserDirectory() : Promise.resolve([])), [] as UserDirectoryEntry[]),
954
+ ]);
955
+
956
+ const sortedCorpora = corpora.sort((left, right) => left.name.localeCompare(right.name));
957
+ const sortedImportRuns = importRuns.sort((left, right) => right.importedAt.localeCompare(left.importedAt));
958
+ const sortedCategorySets = sortCategorySets(categorySets, sortedImportRuns);
959
+ const canonicalCategorySet = selectCanonicalCategorySet(sortedCorpora, sortedCategorySets);
960
+ const sortedCategorys = sortCategorys(categorys);
961
+ const sortedCategoryNodes = sortCategoryTreeNodes(categorys);
962
+ const acceptedDoctrineCategories = selectAcceptedCategoriesForDoctrine({
963
+ categorys: sortedCategorys,
964
+ categoryNodes: sortedCategoryNodes,
965
+ categorySetId: canonicalCategorySet?.id ?? null,
966
+ });
967
+ const doctrineRecords = await loadDoctrineRecords(acceptedDoctrineCategories);
968
+ const sortedNewsroomSections = sortNewsroomSections(newsroomSections);
969
+ const sortedEditionSlots = [...editionSlots].sort((left, right) => (
970
+ (left.editionId ?? "").localeCompare(right.editionId ?? "")
971
+ || (left.sectionKey ?? "").localeCompare(right.sectionKey ?? "")
972
+ || Number(left.slotRank ?? 0) - Number(right.slotRank ?? 0)
973
+ || (left.id ?? "").localeCompare(right.id ?? "")
974
+ ));
975
+
976
+ return {
977
+ canonicalCorpusId: canonicalCategorySet?.corpusId ?? selectCanonicalCorpus(sortedCorpora)?.id ?? null,
978
+ canonicalCategorySetId: canonicalCategorySet?.id ?? null,
979
+ canManageUsers: isAdmin,
980
+ userDirectory,
981
+ corpora: sortedCorpora,
982
+ importRuns: sortedImportRuns,
983
+ categorySets: sortedCategorySets,
984
+ categorys: sortedCategorys,
985
+ categoryTrees: sortTaxonomies(categorySets),
986
+ categoryNodes: sortedCategoryNodes,
987
+ categoryKeywords: categoryKeywords.sort((left, right) => keywordSortKey(left).localeCompare(keywordSortKey(right))),
988
+ lexicalSteeringRules: lexicalSteeringRules.sort((left, right) => lexicalRuleSortKey(left).localeCompare(lexicalRuleSortKey(right))),
989
+ proposals: sortProposals(proposals),
990
+ artifacts: artifacts.sort((left, right) => (right.createdAt ?? "").localeCompare(left.createdAt ?? "")),
991
+ references: references.sort((left, right) => (right.importedAt ?? "").localeCompare(left.importedAt ?? "")),
992
+ referenceAttachments: referenceAttachments.sort((left, right) => left.sortKey.localeCompare(right.sortKey)),
993
+ semanticNodes: semanticNodes.sort((left, right) => (left.displayName ?? left.nodeKey).localeCompare(right.displayName ?? right.nodeKey)),
994
+ messages: messages.sort((left, right) => right.createdAt.localeCompare(left.createdAt)),
995
+ semanticRelations: semanticRelations.sort((left, right) => (right.score ?? 0) - (left.score ?? 0)),
996
+ assignments: assignmentState.assignments,
997
+ assignmentEvents: assignmentState.assignmentEvents,
998
+ editionSlots: sortedEditionSlots,
999
+ doctrineRecords,
1000
+ newsroomSections: sortedNewsroomSections,
1001
+ procedureDefinitions: procedureData.definitions,
1002
+ procedureVersions: procedureData.versions,
1003
+ procedureRuns: procedureData.runs,
1004
+ loadError: null,
1005
+ };
1006
+ }
1007
+
1008
+ async function loadDashboardSlice<T>(label: string, loader: () => Promise<T>, fallback: T): Promise<T> {
1009
+ try {
1010
+ return await loader();
1011
+ } catch (error) {
1012
+ console.warn(`Newsroom dashboard slice failed: ${label}`, error);
1013
+ return fallback;
1014
+ }
1015
+ }
1016
+
1017
+ export async function loadEditorProcedureData(): Promise<{
1018
+ definitions: ProcedureDefinitionRecord[];
1019
+ versions: ProcedureVersionRecord[];
1020
+ runs: ProcedureRunRecord[];
1021
+ }> {
1022
+ const dataClient = generateClient<Schema>();
1023
+ const graphClient = dataClient as unknown as {
1024
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, unknown> | null; errors?: unknown[] | null }>;
1025
+ };
1026
+ const response = await graphClient.graphql({
1027
+ query: LIST_PROCEDURE_DEFINITIONS_QUERY,
1028
+ authMode: USER_POOL_AUTH_MODE,
1029
+ });
1030
+ assertNoGraphQLErrors(response.errors);
1031
+ const payload = normalizeJsonValue(response.data?.listNewsroomProcedureDefinitions);
1032
+ const items = Array.isArray(payload?.items) ? payload.items : [];
1033
+ const definitions: ProcedureDefinitionRecord[] = [];
1034
+ const versions: ProcedureVersionRecord[] = [];
1035
+ const runs: ProcedureRunRecord[] = [];
1036
+ const versionIds = new Set<string>();
1037
+ const runIds = new Set<string>();
1038
+ const hydrateDefinitionIds: string[] = [];
1039
+
1040
+ const pushVersion = (value: unknown) => {
1041
+ const normalized = normalizeJsonValue(value);
1042
+ if (!normalized?.id || !normalized?.procedureId) return;
1043
+ const id = String(normalized.id);
1044
+ if (versionIds.has(id)) return;
1045
+ versionIds.add(id);
1046
+ versions.push(normalized as ProcedureVersionRecord);
1047
+ };
1048
+
1049
+ const pushRun = (value: unknown) => {
1050
+ const normalized = normalizeJsonValue(value);
1051
+ if (!normalized?.id || !normalized?.procedureId) return;
1052
+ const id = String(normalized.id);
1053
+ if (runIds.has(id)) return;
1054
+ runIds.add(id);
1055
+ runs.push(normalized as ProcedureRunRecord);
1056
+ };
1057
+
1058
+ for (const item of items) {
1059
+ const row = normalizeJsonValue(item);
1060
+ if (!row || !row.id || !row.procedureKey) continue;
1061
+ definitions.push(row as ProcedureDefinitionRecord);
1062
+ pushVersion(row.currentVersion);
1063
+ const rowVersions = Array.isArray(row.versions) ? row.versions : [];
1064
+ for (const version of rowVersions) pushVersion(version);
1065
+ if (rowVersions.length === 0) hydrateDefinitionIds.push(String(row.id));
1066
+ const rowRuns = Array.isArray(row.recentRuns) ? row.recentRuns : [];
1067
+ for (const run of rowRuns) pushRun(run);
1068
+ }
1069
+
1070
+ if (hydrateDefinitionIds.length > 0) {
1071
+ await Promise.all(hydrateDefinitionIds.map(async (definitionId) => {
1072
+ const detailResponse = await graphClient.graphql({
1073
+ query: GET_PROCEDURE_DEFINITION_QUERY,
1074
+ variables: { id: definitionId },
1075
+ authMode: USER_POOL_AUTH_MODE,
1076
+ });
1077
+ assertNoGraphQLErrors(detailResponse.errors);
1078
+ const detail = normalizeJsonValue(detailResponse.data?.getNewsroomProcedureDefinition);
1079
+ if (!detail) return;
1080
+ pushVersion(detail.currentVersion);
1081
+ const detailVersions = Array.isArray(detail.versions) ? detail.versions : [];
1082
+ for (const version of detailVersions) pushVersion(version);
1083
+ const detailRuns = Array.isArray(detail.recentRuns) ? detail.recentRuns : [];
1084
+ for (const run of detailRuns) pushRun(run);
1085
+ }));
1086
+ }
1087
+
1088
+ return {
1089
+ definitions: definitions.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "")),
1090
+ versions: versions.sort((left, right) => (right.versionNumber ?? 0) - (left.versionNumber ?? 0)),
1091
+ runs: runs.sort((left, right) => (right.requestedAt ?? "").localeCompare(left.requestedAt ?? "")),
1092
+ };
1093
+ }
1094
+
1095
+ export async function saveProcedureDefinitionRecord(input: Record<string, unknown>) {
1096
+ const dataClient = generateClient<Schema>();
1097
+ const graphClient = dataClient as unknown as {
1098
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, unknown> | null; errors?: unknown[] | null }>;
1099
+ };
1100
+ const response = await graphClient.graphql({
1101
+ query: SAVE_PROCEDURE_DEFINITION_MUTATION,
1102
+ variables: { input },
1103
+ authMode: USER_POOL_AUTH_MODE,
1104
+ });
1105
+ assertNoGraphQLErrors(response.errors);
1106
+ return normalizeJsonValue(response.data?.saveNewsroomProcedureDefinition);
1107
+ }
1108
+
1109
+ export async function saveProcedureVersionDraftRecord(input: Record<string, unknown>) {
1110
+ const dataClient = generateClient<Schema>();
1111
+ const graphClient = dataClient as unknown as {
1112
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, unknown> | null; errors?: unknown[] | null }>;
1113
+ };
1114
+ const response = await graphClient.graphql({
1115
+ query: SAVE_PROCEDURE_VERSION_DRAFT_MUTATION,
1116
+ variables: { input },
1117
+ authMode: USER_POOL_AUTH_MODE,
1118
+ });
1119
+ assertNoGraphQLErrors(response.errors);
1120
+ return normalizeJsonValue(response.data?.saveNewsroomProcedureVersionDraft);
1121
+ }
1122
+
1123
+ export async function publishProcedureVersionRecord(versionId: string) {
1124
+ const dataClient = generateClient<Schema>();
1125
+ const graphClient = dataClient as unknown as {
1126
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, unknown> | null; errors?: unknown[] | null }>;
1127
+ };
1128
+ const response = await graphClient.graphql({
1129
+ query: PUBLISH_PROCEDURE_VERSION_MUTATION,
1130
+ variables: { versionId },
1131
+ authMode: USER_POOL_AUTH_MODE,
1132
+ });
1133
+ assertNoGraphQLErrors(response.errors);
1134
+ return normalizeJsonValue(response.data?.publishNewsroomProcedureVersion);
1135
+ }
1136
+
1137
+ export async function startProcedureRunRecord(input: {
1138
+ procedureId?: string;
1139
+ procedureKey?: string;
1140
+ procedureVersionId?: string;
1141
+ title?: string;
1142
+ summary?: string;
1143
+ actorLabel?: string;
1144
+ parameters?: Record<string, unknown>;
1145
+ }) {
1146
+ const dataClient = generateClient<Schema>();
1147
+ const graphClient = dataClient as unknown as {
1148
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, unknown> | null; errors?: unknown[] | null }>;
1149
+ };
1150
+ const response = await graphClient.graphql({
1151
+ query: START_PROCEDURE_RUN_MUTATION,
1152
+ variables: {
1153
+ procedureId: input.procedureId,
1154
+ procedureKey: input.procedureKey,
1155
+ procedureVersionId: input.procedureVersionId,
1156
+ title: input.title,
1157
+ summary: input.summary,
1158
+ actorLabel: input.actorLabel,
1159
+ input: input.parameters ?? {},
1160
+ },
1161
+ authMode: USER_POOL_AUTH_MODE,
1162
+ });
1163
+ assertNoGraphQLErrors(response.errors);
1164
+ return normalizeJsonValue(response.data?.startNewsroomProcedureRun);
1165
+ }
1166
+
1167
+ export async function loadEditorMessagesData(): Promise<MessageRecord[]> {
1168
+ const testMock = getTestEditorNewsroomMock();
1169
+ if (testMock?.messages) return testMock.messages;
1170
+ const page = await loadNewsroomMessagePage();
1171
+ return page.items;
1172
+ }
1173
+
1174
+ export async function loadNewsroomMessagePage(options: NewsroomMessagePageOptions = {}): Promise<NewsroomRecordPage<MessageRecord>> {
1175
+ return loadNewsroomFeedPage<MessageRecord>({
1176
+ query: NEWSROOM_MESSAGE_FEED_QUERY,
1177
+ field: "listMessagesByNewsroomFeedAndCreatedAt",
1178
+ newsroomFeedKey: "messages",
1179
+ limit: options.limit,
1180
+ nextToken: options.nextToken,
1181
+ matches: equalityMatcher({
1182
+ messageKind: options.kind,
1183
+ messageDomain: options.domain,
1184
+ status: options.status,
1185
+ }),
1186
+ });
1187
+ }
1188
+
1189
+ export async function loadEditionForumThreads(options: {
1190
+ editionId: string;
1191
+ sectionId?: string | null;
1192
+ sectionKey?: string | null;
1193
+ includeMessages?: boolean;
1194
+ status?: string;
1195
+ limit?: number;
1196
+ }): Promise<EditionForumThreadsResult> {
1197
+ const editionId = String(options.editionId || "").trim();
1198
+ if (!editionId) {
1199
+ return { editionId: "", editionThreads: [], sectionThreads: [], threadCount: 0 };
1200
+ }
1201
+ const testMock = getTestEditorNewsroomMock();
1202
+ if (testMock?.forumThreadsByEdition?.[editionId]) {
1203
+ const allThreads = testMock.forumThreadsByEdition[editionId] ?? [];
1204
+ const sectionId = String(options.sectionId || "").trim();
1205
+ const sectionKey = slugifyKey(options.sectionKey);
1206
+ const editionThreads = allThreads.filter((thread) => thread.scope === "edition");
1207
+ let sectionThreads = allThreads.filter((thread) => thread.scope === "section");
1208
+ if (sectionId) sectionThreads = sectionThreads.filter((thread) => thread.primaryAnchorId === sectionId);
1209
+ if (sectionKey) {
1210
+ sectionThreads = sectionThreads.filter((thread) => (
1211
+ slugifyKey(thread.sectionKey)
1212
+ || slugifyKey(metadataString(thread.metadata, "sectionKey"))
1213
+ ) === sectionKey);
1214
+ }
1215
+ return {
1216
+ editionId,
1217
+ editionThreads: editionThreads.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))),
1218
+ sectionThreads: sectionThreads.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))),
1219
+ threadCount: editionThreads.length + sectionThreads.length,
1220
+ };
1221
+ }
1222
+
1223
+ const status = String(options.status || "active").trim();
1224
+ const limit = Math.max(1, Math.min(options.limit ?? 200, 500));
1225
+ const includeMessages = options.includeMessages !== false;
1226
+ const [editionThreadsRaw, sectionThreadsRaw] = await Promise.all([
1227
+ listMessageThreadsByKind("edition_forum", limit),
1228
+ listMessageThreadsByKind("section_forum", limit),
1229
+ ]);
1230
+ const sectionId = String(options.sectionId || "").trim();
1231
+ const sectionKey = slugifyKey(options.sectionKey);
1232
+ const editionThreads = editionThreadsRaw.filter((thread) => (
1233
+ (!status || thread.status === status)
1234
+ && thread.primaryAnchorKind === "edition"
1235
+ && thread.primaryAnchorId === editionId
1236
+ ));
1237
+ let sectionThreads = sectionThreadsRaw.filter((thread) => (
1238
+ (!status || thread.status === status)
1239
+ && thread.primaryAnchorKind === "newsroom_section"
1240
+ && thread.primaryAnchorLineageId === editionId
1241
+ ));
1242
+ if (sectionId) sectionThreads = sectionThreads.filter((thread) => thread.primaryAnchorId === sectionId);
1243
+ if (sectionKey) {
1244
+ sectionThreads = sectionThreads.filter((thread) => (
1245
+ slugifyKey(metadataString(thread.metadata, "sectionKey"))
1246
+ || slugifyKey(thread.primaryAnchorId)
1247
+ ) === sectionKey);
1248
+ }
1249
+
1250
+ async function hydrateThread(thread: MessageThreadRecord, scope: "edition" | "section"): Promise<ForumThreadWithMessages> {
1251
+ const messages = includeMessages ? await listMessagesByThreadId(thread.id, 500) : [];
1252
+ return {
1253
+ ...thread,
1254
+ scope,
1255
+ sectionKey: metadataString(thread.metadata, "sectionKey"),
1256
+ sectionTitle: metadataString(thread.metadata, "sectionTitle"),
1257
+ messages,
1258
+ };
1259
+ }
1260
+
1261
+ const [editionHydrated, sectionHydrated] = await Promise.all([
1262
+ Promise.all(editionThreads.map((thread) => hydrateThread(thread, "edition"))),
1263
+ Promise.all(sectionThreads.map((thread) => hydrateThread(thread, "section"))),
1264
+ ]);
1265
+ editionHydrated.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
1266
+ sectionHydrated.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
1267
+ return {
1268
+ editionId,
1269
+ editionThreads: editionHydrated,
1270
+ sectionThreads: sectionHydrated,
1271
+ threadCount: editionHydrated.length + sectionHydrated.length,
1272
+ };
1273
+ }
1274
+
1275
+ export async function loadInsightForumThreads(options: {
1276
+ domain?: string;
1277
+ limit?: number;
1278
+ nextToken?: string | null;
1279
+ includeMessages?: boolean;
1280
+ } = {}): Promise<InsightForumThreadsPage> {
1281
+ const testMock = getTestEditorNewsroomMock();
1282
+ if (testMock?.insightForumThreads) {
1283
+ const domain = String(options.domain || "").trim();
1284
+ let threads = testMock.insightForumThreads;
1285
+ if (domain) threads = threads.filter((thread) => thread.sectionKey === domain);
1286
+ return { threads, nextToken: null };
1287
+ }
1288
+
1289
+ const limit = Math.max(1, Math.min(options.limit ?? NEWSROOM_PAGE_LIMIT, 100));
1290
+ const domain = String(options.domain || "").trim();
1291
+ const includeMessages = options.includeMessages !== false;
1292
+ type KindConnectionResponse = {
1293
+ listMessagesByKindAndCreatedAt?: {
1294
+ items?: Array<MessageRecord | null> | null;
1295
+ nextToken?: string | null;
1296
+ } | null;
1297
+ };
1298
+ const response: KindConnectionResponse = await runGraphql<KindConnectionResponse>(
1299
+ LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY,
1300
+ {
1301
+ messageKind: "insight",
1302
+ sortDirection: "DESC",
1303
+ limit,
1304
+ nextToken: options.nextToken ?? null,
1305
+ },
1306
+ );
1307
+ const connection = response.listMessagesByKindAndCreatedAt;
1308
+ const roots = (connection?.items ?? [])
1309
+ .filter(Boolean)
1310
+ .map((item) => normalizeMessageRecord(item as MessageRecord))
1311
+ .filter(isInsightThreadRootMessage)
1312
+ .filter((message) => !domain || message.messageDomain === domain);
1313
+
1314
+ const threads = await Promise.all(roots.map(async (root) => {
1315
+ const messages = includeMessages
1316
+ ? await loadInsightThreadMessages(root.id, root)
1317
+ : [root];
1318
+ return buildInsightForumThread(root, messages);
1319
+ }));
1320
+
1321
+ return {
1322
+ threads,
1323
+ nextToken: connection?.nextToken ?? null,
1324
+ };
1325
+ }
1326
+
1327
+ export async function loadInsightForumThreadById(
1328
+ threadId: string,
1329
+ options: { includeMessages?: boolean } = {},
1330
+ ): Promise<ForumThreadWithMessages | null> {
1331
+ const normalized = String(threadId || "").trim();
1332
+ if (!normalized) return null;
1333
+ const testMock = getTestEditorNewsroomMock();
1334
+ if (testMock?.insightForumThreads) {
1335
+ return testMock.insightForumThreads.find((thread) => thread.id === normalized) ?? null;
1336
+ }
1337
+ const root = await getMessageRecordById(normalized);
1338
+ if (!root || !isInsightThreadRootMessage(root)) return null;
1339
+ const includeMessages = options.includeMessages !== false;
1340
+ const messages = includeMessages
1341
+ ? await loadInsightThreadMessages(normalized, root)
1342
+ : [root];
1343
+ return buildInsightForumThread(root, messages);
1344
+ }
1345
+
1346
+ export async function appendInsightThreadReplyRecord(input: {
1347
+ threadId: string;
1348
+ summary: string;
1349
+ content: string;
1350
+ role?: string;
1351
+ authorLabel?: string;
1352
+ parentMessageId?: string | null;
1353
+ metadata?: Record<string, unknown>;
1354
+ }): Promise<MessageRecord> {
1355
+ const threadId = String(input.threadId || "").trim();
1356
+ const summary = String(input.summary || "").trim();
1357
+ const content = String(input.content || "").trim();
1358
+ if (!threadId) throw new Error("threadId is required");
1359
+ if (!summary) throw new Error("summary is required");
1360
+ if (!content) throw new Error("content is required");
1361
+ const root = await getMessageRecordById(threadId);
1362
+ if (!root || !isInsightThreadRootMessage(root)) throw new Error(`Insight thread not found: ${threadId}`);
1363
+ const existing = await listMessagesByThreadId(threadId, 1000);
1364
+ const nextSequence = Math.max(1, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
1365
+ const now = new Date().toISOString();
1366
+ const message: MessageRecord = {
1367
+ id: `message-insight-reply-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
1368
+ messageKind: "insight_reply",
1369
+ messageDomain: root.messageDomain ?? "knowledge",
1370
+ status: "active",
1371
+ summary,
1372
+ source: "newsroom.insights",
1373
+ authorLabel: input.authorLabel?.trim() || "editor",
1374
+ threadId,
1375
+ parentMessageId: input.parentMessageId?.trim() || null,
1376
+ sequenceNumber: nextSequence,
1377
+ role: input.role || "human",
1378
+ messageType: "insight_forum_reply",
1379
+ content,
1380
+ metadata: input.metadata ?? {},
1381
+ createdAt: now,
1382
+ updatedAt: now,
1383
+ newsroomFeedKey: "messages",
1384
+ };
1385
+ const created = await runGraphql<{ createMessage?: MessageRecord | null }>(
1386
+ CREATE_MESSAGE_MUTATION,
1387
+ { input: message },
1388
+ );
1389
+ return normalizeMessageRecord(created.createMessage ?? message);
1390
+ }
1391
+
1392
+ export async function deleteInsightThreadMessageRecord(input: {
1393
+ threadId: string;
1394
+ messageId: string;
1395
+ }): Promise<MessageRecord> {
1396
+ const threadId = String(input.threadId || "").trim();
1397
+ const messageId = String(input.messageId || "").trim();
1398
+ if (!threadId) throw new Error("threadId is required");
1399
+ if (!messageId) throw new Error("messageId is required");
1400
+ const existing = await listMessagesByThreadId(threadId, 1000);
1401
+ const target = existing.find((message) => message.id === messageId);
1402
+ if (!target) throw new Error(`Message not found in thread: ${messageId}`);
1403
+ const now = new Date().toISOString();
1404
+ const updatedMessage = await runGraphql<{ updateMessage?: MessageRecord | null }>(
1405
+ UPDATE_MESSAGE_MUTATION,
1406
+ {
1407
+ input: {
1408
+ id: target.id,
1409
+ status: "deleted",
1410
+ updatedAt: now,
1411
+ },
1412
+ },
1413
+ );
1414
+ return normalizeMessageRecord(updatedMessage.updateMessage ?? { ...target, status: "deleted", updatedAt: now });
1415
+ }
1416
+
1417
+ export async function ensureEditionForumThreadRecord(input: {
1418
+ editionId: string;
1419
+ title?: string;
1420
+ summary?: string;
1421
+ actorLabel?: string;
1422
+ metadata?: Record<string, unknown>;
1423
+ }): Promise<{ created: boolean; thread: MessageThreadRecord }> {
1424
+ const editionId = String(input.editionId || "").trim();
1425
+ if (!editionId) throw new Error("editionId is required");
1426
+ const existing = await loadEditionForumThreads({
1427
+ editionId,
1428
+ includeMessages: false,
1429
+ status: "",
1430
+ limit: 40,
1431
+ });
1432
+ if (existing.editionThreads.length) return { created: false, thread: existing.editionThreads[0] };
1433
+ const now = new Date().toISOString();
1434
+ const thread: MessageThreadRecord = {
1435
+ id: `message-thread-edition-forum-${safeForumId(editionId)}`,
1436
+ threadKind: "edition_forum",
1437
+ status: "active",
1438
+ title: input.title?.trim() || "Upcoming edition",
1439
+ summary: input.summary?.trim() || "Cross-section editor and human coordination for this edition.",
1440
+ primaryAnchorKind: "edition",
1441
+ primaryAnchorId: editionId,
1442
+ primaryAnchorLineageId: editionId,
1443
+ primaryAnchorKey: `edition#${editionId}`,
1444
+ createdByLabel: input.actorLabel?.trim() || "editor",
1445
+ messageCount: 0,
1446
+ lastMessageId: null,
1447
+ lastMessageAt: null,
1448
+ metadata: {
1449
+ editionId,
1450
+ ...(input.metadata ?? {}),
1451
+ },
1452
+ createdAt: now,
1453
+ updatedAt: now,
1454
+ newsroomFeedKey: "messages",
1455
+ };
1456
+ const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(
1457
+ CREATE_MESSAGE_THREAD_MUTATION,
1458
+ { input: thread },
1459
+ );
1460
+ return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
1461
+ }
1462
+
1463
+ export async function createSectionForumThreadRecord(input: {
1464
+ editionId: string;
1465
+ sectionId: string;
1466
+ sectionKey?: string | null;
1467
+ sectionTitle?: string | null;
1468
+ title?: string;
1469
+ summary?: string;
1470
+ actorLabel?: string;
1471
+ metadata?: Record<string, unknown>;
1472
+ }): Promise<{ created: boolean; thread: MessageThreadRecord }> {
1473
+ const editionId = String(input.editionId || "").trim();
1474
+ const sectionId = String(input.sectionId || "").trim();
1475
+ if (!editionId) throw new Error("editionId is required");
1476
+ if (!sectionId) throw new Error("sectionId is required");
1477
+ const now = new Date().toISOString();
1478
+ const thread: MessageThreadRecord = {
1479
+ id: `message-thread-section-forum-${safeForumId(editionId)}-${safeForumId(sectionId)}-${safeForumId(now)}`,
1480
+ threadKind: "section_forum",
1481
+ status: "active",
1482
+ title: input.title?.trim() || `Section Forum: ${input.sectionTitle || input.sectionKey || sectionId}`,
1483
+ summary: input.summary?.trim() || "Section-scoped editorial steering thread for this edition.",
1484
+ primaryAnchorKind: "newsroom_section",
1485
+ primaryAnchorId: sectionId,
1486
+ primaryAnchorLineageId: editionId,
1487
+ primaryAnchorKey: `edition#${editionId}#section#${sectionId}`,
1488
+ createdByLabel: input.actorLabel?.trim() || "editor",
1489
+ messageCount: 0,
1490
+ lastMessageId: null,
1491
+ lastMessageAt: null,
1492
+ metadata: {
1493
+ editionId,
1494
+ sectionId,
1495
+ sectionKey: input.sectionKey || sectionId,
1496
+ sectionTitle: input.sectionTitle || null,
1497
+ ...(input.metadata ?? {}),
1498
+ },
1499
+ createdAt: now,
1500
+ updatedAt: now,
1501
+ newsroomFeedKey: "messages",
1502
+ };
1503
+ const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(
1504
+ CREATE_MESSAGE_THREAD_MUTATION,
1505
+ { input: thread },
1506
+ );
1507
+ return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
1508
+ }
1509
+
1510
+ export async function appendForumThreadMessageRecord(input: {
1511
+ threadId: string;
1512
+ summary: string;
1513
+ content: string;
1514
+ role?: string;
1515
+ authorLabel?: string;
1516
+ parentMessageId?: string | null;
1517
+ metadata?: Record<string, unknown>;
1518
+ }): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1519
+ const threadId = String(input.threadId || "").trim();
1520
+ const summary = String(input.summary || "").trim();
1521
+ const content = String(input.content || "").trim();
1522
+ if (!threadId) throw new Error("threadId is required");
1523
+ if (!summary) throw new Error("summary is required");
1524
+ if (!content) throw new Error("content is required");
1525
+ const thread = await getMessageThreadRecord(threadId);
1526
+ const existing = await listMessagesByThreadId(threadId, 1000);
1527
+ const nextSequence = Math.max(0, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
1528
+ const now = new Date().toISOString();
1529
+ const message: MessageRecord = {
1530
+ id: `message-forum-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
1531
+ messageKind: "forum_post",
1532
+ messageDomain: "edition_coordination",
1533
+ status: "active",
1534
+ summary,
1535
+ source: "newsroom.forum",
1536
+ authorLabel: input.authorLabel?.trim() || "editor",
1537
+ threadId,
1538
+ parentMessageId: input.parentMessageId?.trim() || null,
1539
+ sequenceNumber: nextSequence,
1540
+ role: input.role || "human",
1541
+ messageType: "forum_message",
1542
+ content,
1543
+ metadata: input.metadata ?? {},
1544
+ createdAt: now,
1545
+ updatedAt: now,
1546
+ newsroomFeedKey: "messages",
1547
+ };
1548
+ const created = await runGraphql<{ createMessage?: MessageRecord | null }>(
1549
+ CREATE_MESSAGE_MUTATION,
1550
+ { input: message },
1551
+ );
1552
+ const createdMessage = normalizeMessageRecord(created.createMessage ?? message);
1553
+ const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1554
+ UPDATE_MESSAGE_THREAD_MUTATION,
1555
+ {
1556
+ input: {
1557
+ id: threadId,
1558
+ messageCount: (thread.messageCount ?? 0) + 1,
1559
+ lastMessageId: createdMessage.id,
1560
+ lastMessageAt: createdMessage.createdAt,
1561
+ updatedAt: now,
1562
+ },
1563
+ },
1564
+ );
1565
+ return {
1566
+ thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
1567
+ message: createdMessage,
1568
+ };
1569
+ }
1570
+
1571
+ export async function deleteForumThreadMessageRecord(input: {
1572
+ threadId: string;
1573
+ messageId: string;
1574
+ }): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
1575
+ const threadId = String(input.threadId || "").trim();
1576
+ const messageId = String(input.messageId || "").trim();
1577
+ if (!threadId) throw new Error("threadId is required");
1578
+ if (!messageId) throw new Error("messageId is required");
1579
+ const now = new Date().toISOString();
1580
+ const thread = await getMessageThreadRecord(threadId);
1581
+ const existing = await listMessagesByThreadId(threadId, 1000);
1582
+ const target = existing.find((message) => message.id === messageId);
1583
+ if (!target) throw new Error(`Message not found in thread: ${messageId}`);
1584
+ const updatedMessage = await runGraphql<{ updateMessage?: MessageRecord | null }>(
1585
+ UPDATE_MESSAGE_MUTATION,
1586
+ {
1587
+ input: {
1588
+ id: target.id,
1589
+ status: "deleted",
1590
+ updatedAt: now,
1591
+ },
1592
+ },
1593
+ );
1594
+ const materialized = existing.map((message) => (
1595
+ message.id === messageId
1596
+ ? { ...message, status: "deleted", updatedAt: now }
1597
+ : message
1598
+ ));
1599
+ const activeMessages = materialized
1600
+ .filter((message) => String(message.status || "active") === "active")
1601
+ .sort((left, right) => Number(left.sequenceNumber ?? 0) - Number(right.sequenceNumber ?? 0));
1602
+ const lastActive = activeMessages.at(-1) ?? null;
1603
+ const updatedThread = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
1604
+ UPDATE_MESSAGE_THREAD_MUTATION,
1605
+ {
1606
+ input: {
1607
+ id: threadId,
1608
+ messageCount: activeMessages.length,
1609
+ lastMessageId: lastActive?.id ?? null,
1610
+ lastMessageAt: lastActive?.createdAt ?? null,
1611
+ updatedAt: now,
1612
+ },
1613
+ },
1614
+ );
1615
+ return {
1616
+ thread: normalizeThreadRecord(updatedThread.updateMessageThread ?? { ...thread, updatedAt: now }),
1617
+ message: normalizeMessageRecord(updatedMessage.updateMessage ?? { ...target, status: "deleted", updatedAt: now }),
1618
+ };
1619
+ }
1620
+
1621
+ async function getMessageThreadRecord(threadId: string): Promise<MessageThreadRecord> {
1622
+ const rows = await Promise.all([
1623
+ listMessageThreadsByKind("edition_forum", 500),
1624
+ listMessageThreadsByKind("section_forum", 500),
1625
+ ]);
1626
+ const thread = [...rows[0], ...rows[1]].find((entry) => entry.id === threadId) ?? null;
1627
+ if (!thread) throw new Error(`Thread not found: ${threadId}`);
1628
+ return thread;
1629
+ }
1630
+
1631
+ async function listMessageThreadsByKind(threadKind: string, limit = 200): Promise<MessageThreadRecord[]> {
1632
+ type ThreadConnectionResponse = {
1633
+ listMessageThreadsByKindAndUpdatedAt?: {
1634
+ items?: Array<MessageThreadRecord | null> | null;
1635
+ nextToken?: string | null;
1636
+ } | null;
1637
+ };
1638
+ const records: MessageThreadRecord[] = [];
1639
+ let nextToken: string | null | undefined = null;
1640
+ do {
1641
+ const response: ThreadConnectionResponse = await runGraphql<ThreadConnectionResponse>(LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY, {
1642
+ threadKind,
1643
+ sortDirection: "DESC",
1644
+ limit: Math.max(1, Math.min(limit, 500)),
1645
+ nextToken,
1646
+ });
1647
+ const connection: ThreadConnectionResponse["listMessageThreadsByKindAndUpdatedAt"] = response.listMessageThreadsByKindAndUpdatedAt;
1648
+ const items = (connection?.items ?? []).filter(Boolean) as MessageThreadRecord[];
1649
+ for (const item of items) records.push(normalizeThreadRecord(item));
1650
+ nextToken = connection?.nextToken ?? null;
1651
+ } while (nextToken);
1652
+ return records;
1653
+ }
1654
+
1655
+ async function listMessagesByThreadId(threadId: string, limit = 400): Promise<MessageRecord[]> {
1656
+ type MessageConnectionResponse = {
1657
+ listMessagesByThreadAndSequence?: {
1658
+ items?: Array<MessageRecord | null> | null;
1659
+ nextToken?: string | null;
1660
+ } | null;
1661
+ };
1662
+ const records: MessageRecord[] = [];
1663
+ let nextToken: string | null | undefined = null;
1664
+ do {
1665
+ const response: MessageConnectionResponse = await runGraphql<MessageConnectionResponse>(LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY, {
1666
+ threadId,
1667
+ sortDirection: "ASC",
1668
+ limit: Math.max(1, Math.min(limit, 500)),
1669
+ nextToken,
1670
+ });
1671
+ const connection: MessageConnectionResponse["listMessagesByThreadAndSequence"] = response.listMessagesByThreadAndSequence;
1672
+ const items = (connection?.items ?? []).filter(Boolean) as MessageRecord[];
1673
+ for (const item of items) records.push(normalizeMessageRecord(item));
1674
+ nextToken = connection?.nextToken ?? null;
1675
+ } while (nextToken);
1676
+ records.sort((left, right) => Number(left.sequenceNumber ?? 0) - Number(right.sequenceNumber ?? 0));
1677
+ return records;
1678
+ }
1679
+
1680
+ function isInsightThreadRootMessage(message: MessageRecord): boolean {
1681
+ if (String(message.messageKind || "") !== "insight") return false;
1682
+ const threadId = String(message.threadId || "").trim();
1683
+ const sequenceNumber = Number(message.sequenceNumber || 0);
1684
+ if (threadId && threadId !== message.id && sequenceNumber > 1) return false;
1685
+ return true;
1686
+ }
1687
+
1688
+ async function getMessageRecordById(messageId: string): Promise<MessageRecord | null> {
1689
+ const normalized = String(messageId || "").trim();
1690
+ if (!normalized) return null;
1691
+ const response = await runGraphql<{ getMessage?: MessageRecord | null }>(
1692
+ GET_MESSAGE_QUERY,
1693
+ { id: normalized },
1694
+ );
1695
+ const message = response.getMessage;
1696
+ return message ? normalizeMessageRecord(message) : null;
1697
+ }
1698
+
1699
+ async function loadInsightThreadMessages(threadId: string, root: MessageRecord): Promise<MessageRecord[]> {
1700
+ const threadMessages = await listMessagesByThreadId(threadId, 500);
1701
+ const byId = new Map<string, MessageRecord>();
1702
+ byId.set(root.id, root);
1703
+ for (const message of threadMessages) byId.set(message.id, message);
1704
+ const merged = [...byId.values()].sort(
1705
+ (left, right) => Number(left.sequenceNumber ?? 0) - Number(right.sequenceNumber ?? 0),
1706
+ );
1707
+ return hydrateMessageRecordsContent(merged);
1708
+ }
1709
+
1710
+ function insightForumThreadTitle(root: MessageRecord): string {
1711
+ const metadata = normalizeJsonValue(root.metadata);
1712
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
1713
+ const explicit = String((metadata as Record<string, unknown>).insightTitle || "").trim();
1714
+ if (explicit) return explicit;
1715
+ }
1716
+ const summary = String(root.summary || "").trim();
1717
+ return summary || "Insight";
1718
+ }
1719
+
1720
+ function buildInsightForumThread(root: MessageRecord, messages: MessageRecord[]): ForumThreadWithMessages {
1721
+ const activeMessages = messages.filter((message) => String(message.status || "active") === "active");
1722
+ const sorted = [...activeMessages].sort(
1723
+ (left, right) => Number(left.sequenceNumber ?? 0) - Number(right.sequenceNumber ?? 0),
1724
+ );
1725
+ const lastMessage = sorted.at(-1) ?? root;
1726
+ const activityAt = lastMessage.updatedAt ?? lastMessage.createdAt ?? root.updatedAt ?? root.createdAt;
1727
+ return {
1728
+ id: root.id,
1729
+ threadKind: "insight_forum",
1730
+ status: "active",
1731
+ title: insightForumThreadTitle(root),
1732
+ summary: formatInsightForumDomainLabel(root.messageDomain),
1733
+ primaryAnchorKind: "message",
1734
+ primaryAnchorId: root.id,
1735
+ primaryAnchorLineageId: root.id,
1736
+ primaryAnchorKey: `message#${root.id}`,
1737
+ createdByLabel: root.authorLabel ?? null,
1738
+ messageCount: sorted.length,
1739
+ lastMessageId: lastMessage.id,
1740
+ lastMessageAt: activityAt,
1741
+ metadata: normalizeJsonValue(root.metadata) as Record<string, unknown>,
1742
+ createdAt: root.createdAt,
1743
+ updatedAt: activityAt,
1744
+ newsroomFeedKey: "messages",
1745
+ messages: sorted.length ? sorted : [root],
1746
+ scope: "insight",
1747
+ sectionKey: root.messageDomain ?? null,
1748
+ };
1749
+ }
1750
+
1751
+ function formatInsightForumDomainLabel(domain: string | null | undefined): string {
1752
+ const value = String(domain || "").trim();
1753
+ if (value === "assignment_work") return "Assignment research";
1754
+ if (value === "knowledge") return "Reference knowledge";
1755
+ if (!value) return "Insight";
1756
+ return value.replace(/_/g, " ");
1757
+ }
1758
+
1759
+ async function hydrateMessageRecordsContent(messages: MessageRecord[]): Promise<MessageRecord[]> {
1760
+ return Promise.all(messages.map(async (message) => {
1761
+ try {
1762
+ const payloads = await loadModelPayloadsForOwner("message", message.id, ["message_body"]);
1763
+ const body = payloads
1764
+ .find((payload) => payload.attachment.role === "message_body")
1765
+ ?.text
1766
+ ?.trim();
1767
+ if (body) return { ...message, content: body };
1768
+ } catch {
1769
+ return String(message.content || "").trim() ? message : { ...message, content: "" };
1770
+ }
1771
+ return String(message.content || "").trim() ? message : { ...message, content: "" };
1772
+ }));
1773
+ }
1774
+
1775
+ function normalizeMessageRecord(message: MessageRecord): MessageRecord {
1776
+ return {
1777
+ ...message,
1778
+ metadata: normalizeJsonValue(message.metadata),
1779
+ };
1780
+ }
1781
+
1782
+ function normalizeThreadRecord(thread: MessageThreadRecord): MessageThreadRecord {
1783
+ return {
1784
+ ...thread,
1785
+ metadata: normalizeJsonValue(thread.metadata),
1786
+ };
1787
+ }
1788
+
1789
+ function metadataString(metadata: unknown, key: string): string | null {
1790
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
1791
+ const value = (metadata as Record<string, unknown>)[key];
1792
+ if (typeof value !== "string") return null;
1793
+ const trimmed = value.trim();
1794
+ return trimmed || null;
1795
+ }
1796
+
1797
+ function slugifyKey(value: unknown): string {
1798
+ return String(value ?? "")
1799
+ .trim()
1800
+ .toLowerCase()
1801
+ .replace(/[^a-z0-9]+/g, "-")
1802
+ .replace(/^-+|-+$/g, "");
1803
+ }
1804
+
1805
+ function safeForumId(value: string): string {
1806
+ return slugifyKey(value).slice(0, 120) || "thread";
1807
+ }
1808
+
1809
+ async function runGraphql<T extends Record<string, unknown>>(
1810
+ query: string,
1811
+ variables: Record<string, unknown>,
1812
+ ): Promise<T> {
1813
+ configureAmplifyClient();
1814
+ const client = generateClient<Schema>() as unknown as {
1815
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: unknown[] | null }>;
1816
+ };
1817
+ const response = await client.graphql({
1818
+ query,
1819
+ variables,
1820
+ authMode: USER_POOL_AUTH_MODE,
1821
+ });
1822
+ assertNoGraphQLErrors(response.errors);
1823
+ return (response.data ?? {}) as T;
1824
+ }
1825
+
1826
+ export async function loadEditorReferencesData(): Promise<{
1827
+ references: ReferenceRecord[];
1828
+ referenceAttachments: ReferenceAttachmentRecord[];
1829
+ }> {
1830
+ configureAmplifyClient();
1831
+ const testMock = getTestEditorNewsroomMock();
1832
+ if (testMock?.references || testMock?.referenceAttachments) {
1833
+ const referenceAttachments = (testMock.referenceAttachments ?? [])
1834
+ .slice()
1835
+ .sort((left, right) => left.sortKey.localeCompare(right.sortKey));
1836
+ if (testMock.references) {
1837
+ return {
1838
+ references: testMock.references,
1839
+ referenceAttachments,
1840
+ };
1841
+ }
1842
+ const referencePage = await loadNewsroomReferencePage();
1843
+ return {
1844
+ references: referencePage.items,
1845
+ referenceAttachments,
1846
+ };
1847
+ }
1848
+ const [referencePage, referenceAttachments] = await Promise.all([
1849
+ loadNewsroomReferencePage(),
1850
+ listUserPoolModel<ReferenceAttachmentRecord>("ReferenceAttachment"),
1851
+ ]);
1852
+ return {
1853
+ references: referencePage.items,
1854
+ referenceAttachments: referenceAttachments.sort((left, right) => left.sortKey.localeCompare(right.sortKey)),
1855
+ };
1856
+ }
1857
+
1858
+ export function normalizeNewsroomReferencePageOrder(value?: string | null): NewsroomReferencePageOrder {
1859
+ return value?.trim() === "imported" ? "imported" : "published";
1860
+ }
1861
+
1862
+ export async function loadNewsroomReferencePage(options: NewsroomReferencePageOptions = {}): Promise<NewsroomRecordPage<ReferenceRecord>> {
1863
+ configureAmplifyClient();
1864
+ const order = normalizeNewsroomReferencePageOrder(options.order);
1865
+ if (order === "imported") {
1866
+ try {
1867
+ const importedPage = await loadReferencePageByImportedFeed(options);
1868
+ if (importedPage.items.length > 0 || options.nextToken) return importedPage;
1869
+ } catch (error) {
1870
+ if (!isMissingGraphQLQueryFieldError(error, "listReferencesByNewsroomFeedAndImportedAt")) {
1871
+ throw error;
1872
+ }
1873
+ console.warn(
1874
+ "listReferencesByNewsroomFeedAndImportedAt is not deployed in this environment; "
1875
+ + "falling back to legacy reference queries with client-side import-date sort.",
1876
+ );
1877
+ }
1878
+ const fallbackPage = await loadReferencePageByFallbackList(options);
1879
+ if (fallbackPage.items.length > 0 || options.nextToken) return fallbackPage;
1880
+ // Continue into status-index loaders below when the table scan is empty.
1881
+ }
1882
+ const normalizedStatus = normalizeReferenceStatusKey(options.status);
1883
+ if (normalizedStatus) {
1884
+ const indexedPage = await loadReferencePageByStatus({
1885
+ status: normalizedStatus,
1886
+ limit: options.limit,
1887
+ nextToken: options.nextToken,
1888
+ corpusId: options.corpusId,
1889
+ });
1890
+ if (indexedPage.items.length > 0 || options.nextToken) return sortReferencePageByOrder(indexedPage, order);
1891
+ const legacyPage = await loadReferencePageByLegacyFeed(options);
1892
+ if (legacyPage.items.length > 0 || options.nextToken) return sortReferencePageByOrder(legacyPage, order);
1893
+ return loadReferencePageByFallbackList(options);
1894
+ }
1895
+ if (options.excludePending) {
1896
+ // Use the curation-status merge, not reviewedFeedKey. The reviewed-feed GSI is only
1897
+ // populated for references that went through backfill; treating it as authoritative
1898
+ // capped production at ~100 rows while thousands exist in the status indexes.
1899
+ const mergedReviewedPage = await loadMergedReferenceStatusPage({
1900
+ statuses: ["accepted", "rejected", "archived"],
1901
+ limit: options.limit,
1902
+ nextToken: options.nextToken,
1903
+ corpusId: options.corpusId,
1904
+ order,
1905
+ });
1906
+ if (mergedReviewedPage.items.length > 0 || options.nextToken) return mergedReviewedPage;
1907
+ const legacyPage = await loadReferencePageByLegacyFeed(options);
1908
+ if (legacyPage.items.length > 0 || options.nextToken) return sortReferencePageByOrder(legacyPage, order);
1909
+ return loadReferencePageByFallbackList(options);
1910
+ }
1911
+ const mergedPage = await loadMergedReferenceStatusPage({
1912
+ statuses: REFERENCE_STATUS_KEYS,
1913
+ limit: options.limit,
1914
+ nextToken: options.nextToken,
1915
+ corpusId: options.corpusId,
1916
+ order,
1917
+ });
1918
+ if (mergedPage.items.length > 0 || options.nextToken) return mergedPage;
1919
+ const legacyPage = await loadReferencePageByLegacyFeed(options);
1920
+ if (legacyPage.items.length > 0 || options.nextToken) return sortReferencePageByOrder(legacyPage, order);
1921
+ return loadReferencePageByFallbackList(options);
1922
+ }
1923
+
1924
+ function referencePageMatchesFilters(
1925
+ reference: ReferenceRecord,
1926
+ options: NewsroomReferencePageOptions,
1927
+ normalizedStatus: ReferenceStatus | null,
1928
+ ): boolean {
1929
+ if (reference.versionState && reference.versionState !== "current") return false;
1930
+ if (options.corpusId && reference.corpusId !== options.corpusId) return false;
1931
+ const status = normalizeReferenceStatusKey(reference.curationStatus) ?? "pending";
1932
+ if (normalizedStatus) return status === normalizedStatus;
1933
+ if (options.excludePending) return status !== "pending";
1934
+ return true;
1935
+ }
1936
+
1937
+ async function loadReferencePageByLegacyFeed(options: NewsroomReferencePageOptions): Promise<NewsroomRecordPage<ReferenceRecord>> {
1938
+ const normalizedStatus = normalizeReferenceStatusKey(options.status);
1939
+ return loadNewsroomFeedPage<ReferenceRecord>({
1940
+ query: NEWSROOM_REFERENCE_FEED_QUERY,
1941
+ field: "listReferencesByNewsroomFeedAndCreatedAt",
1942
+ newsroomFeedKey: "references",
1943
+ limit: options.limit,
1944
+ nextToken: options.nextToken,
1945
+ matches: (reference) => referencePageMatchesFilters(reference, options, normalizedStatus),
1946
+ });
1947
+ }
1948
+
1949
+ async function loadReferencePageByImportedFeed(options: NewsroomReferencePageOptions): Promise<NewsroomRecordPage<ReferenceRecord>> {
1950
+ const normalizedStatus = normalizeReferenceStatusKey(options.status);
1951
+ return loadNewsroomFeedPage<ReferenceRecord>({
1952
+ query: NEWSROOM_REFERENCE_IMPORTED_FEED_QUERY,
1953
+ field: "listReferencesByNewsroomFeedAndImportedAt",
1954
+ newsroomFeedKey: "references",
1955
+ limit: options.limit,
1956
+ nextToken: options.nextToken,
1957
+ matches: (reference) => referencePageMatchesFilters(reference, options, normalizedStatus),
1958
+ });
1959
+ }
1960
+
1961
+ async function loadReferencePageByFallbackList(options: NewsroomReferencePageOptions): Promise<NewsroomRecordPage<ReferenceRecord>> {
1962
+ const client = generateClient<Schema>() as unknown as {
1963
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ReferenceRecord>>;
1964
+ };
1965
+ const limit = Math.max(1, Math.min(options.limit ?? NEWSROOM_PAGE_LIMIT, 200));
1966
+ const normalizedStatus = normalizeReferenceStatusKey(options.status);
1967
+ const items: ReferenceRecord[] = [];
1968
+ let cursor = options.nextToken ?? null;
1969
+ let connectionNextToken: string | null = null;
1970
+ let pageCount = 0;
1971
+
1972
+ do {
1973
+ pageCount += 1;
1974
+ let response: GraphQLConnectionResponse<ReferenceRecord>;
1975
+ try {
1976
+ response = await client.graphql({
1977
+ query: LIST_REFERENCES_FALLBACK_QUERY,
1978
+ variables: {
1979
+ limit,
1980
+ nextToken: cursor,
1981
+ },
1982
+ authMode: USER_POOL_AUTH_MODE,
1983
+ });
1984
+ } catch (error) {
1985
+ throw new Error(normalizeUnknownErrorMessage(error, "Failed to load listReferences fallback"));
1986
+ }
1987
+
1988
+ const connection = response.data?.listReferences;
1989
+ if (!connection) {
1990
+ assertNoGraphQLErrors(response.errors);
1991
+ throw new Error("Missing GraphQL connection payload for listReferences fallback.");
1992
+ }
1993
+ if (response.errors?.length) {
1994
+ console.warn("GraphQL returned partial data for listReferences fallback.", response.errors);
1995
+ }
1996
+
1997
+ connectionNextToken = connection.nextToken ?? null;
1998
+ const pageItems = ((connection.items ?? []).filter(Boolean) as ReferenceRecord[])
1999
+ .filter((reference) => {
2000
+ if (reference.versionState && reference.versionState !== "current") return false;
2001
+ if (options.corpusId && reference.corpusId !== options.corpusId) return false;
2002
+ const status = normalizeReferenceStatusKey(reference.curationStatus) ?? "pending";
2003
+ if (normalizedStatus) return status === normalizedStatus;
2004
+ if (options.excludePending) return status !== "pending";
2005
+ return true;
2006
+ });
2007
+ items.push(...pageItems);
2008
+ cursor = connectionNextToken;
2009
+ } while (items.length < limit && cursor && pageCount < 40);
2010
+
2011
+ const order = normalizeNewsroomReferencePageOrder(options.order);
2012
+ const sorted = sortReferencePageByOrder({
2013
+ items,
2014
+ nextToken: connectionNextToken,
2015
+ hasMore: Boolean(connectionNextToken),
2016
+ }, order);
2017
+ return {
2018
+ ...sorted,
2019
+ items: sorted.items.slice(0, limit),
2020
+ };
2021
+ }
2022
+
2023
+ export async function loadEditorSemanticRelationsData(): Promise<SemanticRelationRecord[]> {
2024
+ const testMock = getTestEditorNewsroomMock();
2025
+ if (testMock?.semanticRelations) return testMock.semanticRelations;
2026
+ const rows: SemanticRelationRecord[] = [];
2027
+ const seenIds = new Set<string>();
2028
+ let nextToken: string | null | undefined = null;
2029
+ let pageCount = 0;
2030
+
2031
+ do {
2032
+ const page = await loadNewsroomSemanticRelationPage({ nextToken });
2033
+ for (const relation of page.items) {
2034
+ if (!relation?.id || seenIds.has(relation.id)) continue;
2035
+ seenIds.add(relation.id);
2036
+ rows.push(relation);
2037
+ }
2038
+ nextToken = page.nextToken;
2039
+ pageCount += 1;
2040
+ } while (nextToken && pageCount < 40);
2041
+
2042
+ return rows;
2043
+ }
2044
+
2045
+ export async function loadNewsroomSemanticRelationPage(options: NewsroomSemanticRelationPageOptions = {}): Promise<NewsroomRecordPage<SemanticRelationRecord>> {
2046
+ return loadNewsroomFeedPage<SemanticRelationRecord>({
2047
+ query: NEWSROOM_SEMANTIC_RELATION_FEED_QUERY,
2048
+ field: "listSemanticRelationsByNewsroomFeedAndCreatedAt",
2049
+ newsroomFeedKey: "semanticRelations",
2050
+ limit: options.limit,
2051
+ nextToken: options.nextToken,
2052
+ matches: equalityMatcher({
2053
+ relationTypeKey: options.relationTypeKey,
2054
+ relationDomain: options.relationDomain,
2055
+ }),
2056
+ });
2057
+ }
2058
+
2059
+ export async function loadReferenceCitationRelations(reference: {
2060
+ id?: string | null;
2061
+ lineageId?: string | null;
2062
+ }): Promise<{
2063
+ incoming: SemanticRelationRecord[];
2064
+ outgoing: SemanticRelationRecord[];
2065
+ }> {
2066
+ const referenceKeys = Array.from(new Set([
2067
+ String(reference.lineageId || "").trim(),
2068
+ String(reference.id || "").trim(),
2069
+ ].filter(Boolean)));
2070
+ const stateKeys = referenceKeys.map((value) => `reference#${value}#current`);
2071
+ const [outgoingPages, incomingPages] = await Promise.all([
2072
+ Promise.all(stateKeys.map((stateKey) => loadSemanticRelationsByState({
2073
+ field: "listSemanticRelationsBySubjectState",
2074
+ keyName: "subjectStateKey",
2075
+ keyValue: stateKey,
2076
+ query: LIST_SEMANTIC_RELATIONS_BY_SUBJECT_STATE_QUERY,
2077
+ }))),
2078
+ Promise.all(stateKeys.map((stateKey) => loadSemanticRelationsByState({
2079
+ field: "listSemanticRelationsByObjectState",
2080
+ keyName: "objectStateKey",
2081
+ keyValue: stateKey,
2082
+ query: LIST_SEMANTIC_RELATIONS_BY_OBJECT_STATE_QUERY,
2083
+ }))),
2084
+ ]);
2085
+ const outgoing = dedupeSemanticRelationsById(outgoingPages.flat());
2086
+ const incoming = dedupeSemanticRelationsById(incomingPages.flat());
2087
+ return {
2088
+ outgoing: outgoing.filter((relation) => (
2089
+ relation.subjectKind === "reference"
2090
+ && relation.objectKind === "reference"
2091
+ && relationTypeKey(relation) === "cites"
2092
+ && relation.relationState === "current"
2093
+ )),
2094
+ incoming: incoming.filter((relation) => (
2095
+ relation.subjectKind === "reference"
2096
+ && relation.objectKind === "reference"
2097
+ && relationTypeKey(relation) === "cites"
2098
+ && relation.relationState === "current"
2099
+ )),
2100
+ };
2101
+ }
2102
+
2103
+ export async function loadEditorAssignmentsData(): Promise<{
2104
+ assignments: AssignmentRecord[];
2105
+ assignmentEvents: AssignmentEventRecord[];
2106
+ }> {
2107
+ const [assignmentPage, assignmentEvents] = await Promise.all([
2108
+ loadNewsroomAssignmentPage(),
2109
+ listOptionalUserPoolModelPage<AssignmentEventRecord>("AssignmentEvent", USER_POOL_PAGE_LIMIT),
2110
+ ]);
2111
+ return {
2112
+ assignments: assignmentPage.items,
2113
+ assignmentEvents: assignmentEvents.sort((left, right) => right.createdAt.localeCompare(left.createdAt)),
2114
+ };
2115
+ }
2116
+
2117
+ export async function loadEditorOverviewEditionData(): Promise<{
2118
+ editions: EditionRecord[];
2119
+ editionSlots: EditionSlotRecord[];
2120
+ assignments: AssignmentRecord[];
2121
+ }> {
2122
+ const [editions, editionSlots, assignmentState] = await Promise.all([
2123
+ listOptionalUserPoolModel<EditionRecord>("Edition"),
2124
+ listOptionalUserPoolModel<EditionSlotRecord>("EditionSlot"),
2125
+ loadEditorAssignmentsData(),
2126
+ ]);
2127
+ const sortedEditions = [...editions].sort((left, right) => (
2128
+ String(right.editionDate ?? "").localeCompare(String(left.editionDate ?? ""))
2129
+ || String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? ""))
2130
+ || String(left.id ?? "").localeCompare(String(right.id ?? ""))
2131
+ ));
2132
+ const sortedEditionSlots = [...editionSlots].sort((left, right) => (
2133
+ String(left.editionId ?? "").localeCompare(String(right.editionId ?? ""))
2134
+ || String(left.sectionKey ?? "").localeCompare(String(right.sectionKey ?? ""))
2135
+ || Number(left.slotRank ?? 0) - Number(right.slotRank ?? 0)
2136
+ || String(left.id ?? "").localeCompare(String(right.id ?? ""))
2137
+ ));
2138
+ return {
2139
+ editions: sortedEditions,
2140
+ editionSlots: sortedEditionSlots,
2141
+ assignments: assignmentState.assignments,
2142
+ };
2143
+ }
2144
+
2145
+ export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPageOptions = {}): Promise<NewsroomRecordPage<AssignmentRecord>> {
2146
+ return loadNewsroomFeedPage<AssignmentRecord>({
2147
+ query: NEWSROOM_ASSIGNMENT_FEED_QUERY,
2148
+ field: "listAssignmentsByNewsroomFeedAndCreatedAt",
2149
+ newsroomFeedKey: "assignments",
2150
+ limit: options.limit,
2151
+ nextToken: options.nextToken,
2152
+ matches: equalityMatcher({
2153
+ assignmentTypeKey: options.type,
2154
+ status: options.status,
2155
+ }),
2156
+ });
2157
+ }
2158
+
2159
+ export async function loadModelPayloadsForOwner(
2160
+ ownerKind: string,
2161
+ ownerId: string,
2162
+ roles?: string[],
2163
+ ): Promise<HydratedModelPayload[]> {
2164
+ const testMock = getTestEditorNewsroomMock();
2165
+ const mockPayloads = testMock?.payloads?.[`${ownerKind}:${ownerId}`];
2166
+ if (mockPayloads) {
2167
+ const allowedRoles = new Set((roles ?? []).filter(Boolean));
2168
+ return mockPayloads.filter((payload) => !allowedRoles.size || allowedRoles.has(payload.attachment.role));
2169
+ }
2170
+ configureAmplifyClient();
2171
+ const client = generateClient<Schema>() as unknown as {
2172
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ModelAttachmentRecord>>;
2173
+ };
2174
+ const allowedRoles = new Set((roles ?? []).filter(Boolean));
2175
+ const attachments: ModelAttachmentRecord[] = [];
2176
+ let nextToken: string | null | undefined = null;
2177
+
2178
+ do {
2179
+ const response = await client.graphql({
2180
+ query: MODEL_ATTACHMENTS_BY_OWNER_QUERY,
2181
+ variables: {
2182
+ ownerId,
2183
+ sortDirection: "ASC",
2184
+ limit: USER_POOL_PAGE_LIMIT,
2185
+ nextToken,
2186
+ },
2187
+ authMode: USER_POOL_AUTH_MODE,
2188
+ });
2189
+ assertNoGraphQLErrors(response.errors);
2190
+ const connection = response.data?.listModelAttachmentsByOwnerRoleAndSortKey;
2191
+ attachments.push(...((connection?.items ?? []).filter(Boolean) as ModelAttachmentRecord[])
2192
+ .filter((attachment) => attachment.ownerKind === ownerKind)
2193
+ .filter((attachment) => !allowedRoles.size || allowedRoles.has(attachment.role)));
2194
+ nextToken = connection?.nextToken ?? null;
2195
+ } while (nextToken);
2196
+
2197
+ return Promise.all(attachments.map(hydrateModelAttachment));
2198
+ }
2199
+
2200
+ export async function loadReferenceRecordById(id: string): Promise<ReferenceRecord | null> {
2201
+ const testMock = getTestEditorNewsroomMock();
2202
+ if (testMock?.references) {
2203
+ return testMock.references.find((reference) => reference.id === id || reference.lineageId === id) ?? null;
2204
+ }
2205
+ configureAmplifyClient();
2206
+ const client = generateClient<Schema>() as unknown as {
2207
+ graphql: (options: Record<string, unknown>) => Promise<{
2208
+ data?: { getReference?: ReferenceRecord | null } | null;
2209
+ errors?: unknown[] | null;
2210
+ }>;
2211
+ };
2212
+ const response = await client.graphql({
2213
+ query: GET_REFERENCE_QUERY,
2214
+ variables: { id },
2215
+ authMode: USER_POOL_AUTH_MODE,
2216
+ });
2217
+ assertNoGraphQLErrors(response.errors);
2218
+ return response.data?.getReference ?? null;
2219
+ }
2220
+
2221
+ function pickPreferredReferenceRecord(references: ReferenceRecord[]): ReferenceRecord | null {
2222
+ if (!references.length) return null;
2223
+ return references.reduce((best, current) => {
2224
+ const currentCurrent = current.versionState === "current" ? 1 : 0;
2225
+ const bestCurrent = best.versionState === "current" ? 1 : 0;
2226
+ if (currentCurrent !== bestCurrent) return currentCurrent > bestCurrent ? current : best;
2227
+ const currentVersion = current.versionNumber ?? 0;
2228
+ const bestVersion = best.versionNumber ?? 0;
2229
+ if (currentVersion !== bestVersion) return currentVersion > bestVersion ? current : best;
2230
+ return (current.updatedAt ?? "").localeCompare(best.updatedAt ?? "") > 0 ? current : best;
2231
+ });
2232
+ }
2233
+
2234
+ export async function loadReferenceRecordByLineageId(lineageId: string): Promise<ReferenceRecord | null> {
2235
+ const normalized = lineageId.trim();
2236
+ if (!normalized) return null;
2237
+
2238
+ const direct = await loadReferenceRecordById(normalized);
2239
+ if (direct) return direct;
2240
+
2241
+ if (!/-v\d+$/i.test(normalized)) {
2242
+ const versioned = await loadReferenceRecordById(`${normalized}-v1`);
2243
+ if (versioned) return versioned;
2244
+ }
2245
+
2246
+ const testMock = getTestEditorNewsroomMock();
2247
+ if (testMock?.references) {
2248
+ const matches = testMock.references.filter(
2249
+ (reference) => reference.id === normalized || reference.lineageId === normalized,
2250
+ );
2251
+ return pickPreferredReferenceRecord(matches);
2252
+ }
2253
+
2254
+ configureAmplifyClient();
2255
+ const client = generateClient<Schema>() as unknown as {
2256
+ graphql: (options: Record<string, unknown>) => Promise<{
2257
+ data?: { listReferencesByLineageAndVersion?: { items?: ReferenceRecord[] | null; nextToken?: string | null } | null } | null;
2258
+ errors?: unknown[] | null;
2259
+ }>;
2260
+ };
2261
+ const response = await client.graphql({
2262
+ query: LIST_REFERENCES_BY_LINEAGE_AND_VERSION_QUERY,
2263
+ variables: { lineageId: normalized, limit: 20 },
2264
+ authMode: USER_POOL_AUTH_MODE,
2265
+ });
2266
+ assertNoGraphQLErrors(response.errors);
2267
+ const items = (response.data?.listReferencesByLineageAndVersion?.items ?? []).filter(Boolean) as ReferenceRecord[];
2268
+ return pickPreferredReferenceRecord(items);
2269
+ }
2270
+
2271
+ export async function loadReferenceAttachmentsForLineageId(referenceLineageId: string): Promise<ReferenceAttachmentRecord[]> {
2272
+ if (!referenceLineageId) return [];
2273
+ const testMock = getTestEditorNewsroomMock();
2274
+ if (testMock?.referenceAttachments) {
2275
+ return testMock.referenceAttachments
2276
+ .filter((attachment) => attachment.referenceLineageId === referenceLineageId)
2277
+ .slice()
2278
+ .sort((left, right) => left.sortKey.localeCompare(right.sortKey));
2279
+ }
2280
+ configureAmplifyClient();
2281
+ const client = generateClient<Schema>() as unknown as {
2282
+ graphql: (options: Record<string, unknown>) => Promise<{
2283
+ data?: {
2284
+ listReferenceAttachmentsByReferenceLineageAndSortKey?: {
2285
+ items?: ReferenceAttachmentRecord[] | null;
2286
+ nextToken?: string | null;
2287
+ } | null;
2288
+ } | null;
2289
+ errors?: unknown[] | null;
2290
+ }>;
2291
+ };
2292
+ const attachments: ReferenceAttachmentRecord[] = [];
2293
+ let nextToken: string | null | undefined = null;
2294
+ do {
2295
+ const response = await client.graphql({
2296
+ query: LIST_REFERENCE_ATTACHMENTS_BY_REFERENCE_LINEAGE_AND_SORT_KEY_QUERY,
2297
+ variables: {
2298
+ referenceLineageId,
2299
+ sortDirection: "ASC",
2300
+ limit: USER_POOL_PAGE_LIMIT,
2301
+ nextToken,
2302
+ },
2303
+ authMode: USER_POOL_AUTH_MODE,
2304
+ });
2305
+ assertNoGraphQLErrors(response.errors);
2306
+ const connection = response.data?.listReferenceAttachmentsByReferenceLineageAndSortKey;
2307
+ attachments.push(...((connection?.items ?? []).filter(Boolean) as ReferenceAttachmentRecord[]));
2308
+ nextToken = connection?.nextToken ?? null;
2309
+ } while (nextToken);
2310
+ return attachments.sort((left, right) => left.sortKey.localeCompare(right.sortKey));
2311
+ }
2312
+
2313
+ export async function uploadModelPayloadForOwner(input: {
2314
+ ownerKind: string;
2315
+ ownerId: string;
2316
+ ownerLineageId?: string | null;
2317
+ ownerVersionNumber?: number | null;
2318
+ ownerVersionKey?: string | null;
2319
+ role: string;
2320
+ sortKey?: string | null;
2321
+ filename: string;
2322
+ mediaType: string;
2323
+ content: string | Blob | ArrayBuffer | Uint8Array;
2324
+ importRunId?: string | null;
2325
+ status?: string | null;
2326
+ }): Promise<ModelAttachmentRecord> {
2327
+ configureAmplifyClient();
2328
+ const client = generateClient<Schema>() as unknown as {
2329
+ graphql: (options: Record<string, unknown>) => Promise<{
2330
+ data?: {
2331
+ createModelAttachmentUpload?: ModelAttachmentUploadSlot | null;
2332
+ completeModelAttachmentUpload?: ModelAttachmentRecord | null;
2333
+ } | null;
2334
+ errors?: unknown[] | null;
2335
+ }>;
2336
+ };
2337
+ const uploadBody = await normalizeUploadBody(input.content);
2338
+ const variables = {
2339
+ ownerKind: input.ownerKind,
2340
+ ownerId: input.ownerId,
2341
+ ownerLineageId: input.ownerLineageId ?? null,
2342
+ ownerVersionNumber: input.ownerVersionNumber ?? null,
2343
+ ownerVersionKey: input.ownerVersionKey ?? null,
2344
+ role: input.role,
2345
+ sortKey: input.sortKey ?? input.role,
2346
+ filename: input.filename,
2347
+ mediaType: input.mediaType,
2348
+ byteSize: uploadBody.byteSize,
2349
+ sha256: await sha256Hex(uploadBody.body),
2350
+ importRunId: input.importRunId ?? null,
2351
+ status: input.status ?? "active",
2352
+ };
2353
+ const slotResponse = await client.graphql({
2354
+ query: CREATE_MODEL_ATTACHMENT_UPLOAD_MUTATION,
2355
+ variables,
2356
+ authMode: USER_POOL_AUTH_MODE,
2357
+ });
2358
+ assertNoGraphQLErrors(slotResponse.errors);
2359
+ const slot = slotResponse.data?.createModelAttachmentUpload;
2360
+ if (!slot?.uploadUrl) throw new Error("Attachment upload slot did not include an upload URL.");
2361
+ const uploadResponse = await fetch(slot.uploadUrl, {
2362
+ method: slot.method ?? "PUT",
2363
+ headers: normalizeUploadHeaders(slot.requiredHeaders),
2364
+ body: uploadBody.body,
2365
+ });
2366
+ if (!uploadResponse.ok) {
2367
+ throw new Error(`Attachment upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
2368
+ }
2369
+ const completeResponse = await client.graphql({
2370
+ query: COMPLETE_MODEL_ATTACHMENT_UPLOAD_MUTATION,
2371
+ variables: { uploadId: slot.uploadId, ...variables },
2372
+ authMode: USER_POOL_AUTH_MODE,
2373
+ });
2374
+ assertNoGraphQLErrors(completeResponse.errors);
2375
+ const attachment = completeResponse.data?.completeModelAttachmentUpload;
2376
+ if (!attachment) throw new Error("Attachment upload completed without a ModelAttachment record.");
2377
+ return attachment;
2378
+ }
2379
+
2380
+ async function hydrateModelAttachment(attachment: ModelAttachmentRecord): Promise<HydratedModelPayload> {
2381
+ try {
2382
+ const text = attachment.storagePath?.startsWith("newsroom/payloads/")
2383
+ ? await downloadModelAttachmentText(attachment)
2384
+ : await downloadStoragePathTextRaw(attachment.storagePath);
2385
+ const json = text && isJsonMediaType(attachment.mediaType) ? parseAttachmentJson(text) : null;
2386
+ return { attachment, text, json, error: null };
2387
+ } catch (error) {
2388
+ return {
2389
+ attachment,
2390
+ text: null,
2391
+ json: null,
2392
+ error: error instanceof Error ? error.message : "Could not hydrate attachment payload.",
2393
+ };
2394
+ }
2395
+ }
2396
+
2397
+ export async function loadStoragePathText(path: string | null | undefined): Promise<{ error: string | null; text: string | null }> {
2398
+ if (!path) {
2399
+ return { error: "Extracted text attachment is missing a storage path.", text: null };
2400
+ }
2401
+ const testMock = getTestEditorNewsroomMock();
2402
+ const mockText = testMock?.storageTextByPath?.[path];
2403
+ if (typeof mockText === "string") {
2404
+ return { error: null, text: mockText };
2405
+ }
2406
+ try {
2407
+ const text = await downloadStoragePathTextRaw(path);
2408
+ return { error: null, text };
2409
+ } catch (error) {
2410
+ return {
2411
+ error: error instanceof Error ? error.message : "Could not load extracted text.",
2412
+ text: null,
2413
+ };
2414
+ }
2415
+ }
2416
+
2417
+ export async function loadStoragePathUrl(path: string | null | undefined): Promise<{ error: string | null; url: string | null }> {
2418
+ if (!path) {
2419
+ return { error: "Attachment is missing a storage path.", url: null };
2420
+ }
2421
+ const testMock = getTestEditorNewsroomMock();
2422
+ const mockUrl = testMock?.storageUrlByPath?.[path];
2423
+ if (typeof mockUrl === "string" && mockUrl.trim().length > 0) {
2424
+ return { error: null, url: mockUrl };
2425
+ }
2426
+ try {
2427
+ configureAmplifyClient();
2428
+ const signed = await getUrl({
2429
+ path,
2430
+ options: { validateObjectExistence: true },
2431
+ });
2432
+ const url = signed?.url ? signed.url.toString() : null;
2433
+ if (!url) {
2434
+ return { error: "Could not resolve attachment URL.", url: null };
2435
+ }
2436
+ return { error: null, url };
2437
+ } catch (error) {
2438
+ return {
2439
+ error: error instanceof Error ? error.message : "Could not resolve attachment URL.",
2440
+ url: null,
2441
+ };
2442
+ }
2443
+ }
2444
+
2445
+ async function downloadStoragePathTextRaw(path: string | null | undefined): Promise<string | null> {
2446
+ if (!path) return null;
2447
+ const downloaded = await downloadData({ path }).result;
2448
+ const body = downloaded.body as { text?: () => Promise<string> };
2449
+ return typeof body.text === "function" ? await body.text() : null;
2450
+ }
2451
+
2452
+ async function downloadModelAttachmentText(attachment: ModelAttachmentRecord): Promise<string | null> {
2453
+ if (!attachment.id) return null;
2454
+ configureAmplifyClient();
2455
+ const client = generateClient<Schema>() as unknown as {
2456
+ graphql: (options: Record<string, unknown>) => Promise<{
2457
+ data?: {
2458
+ createModelAttachmentDownload?: {
2459
+ downloadUrl?: string | null;
2460
+ method?: string | null;
2461
+ requiredHeaders?: Record<string, string> | string | null;
2462
+ } | null;
2463
+ } | null;
2464
+ errors?: unknown[] | null;
2465
+ }>;
2466
+ };
2467
+ const response = await client.graphql({
2468
+ query: CREATE_MODEL_ATTACHMENT_DOWNLOAD_MUTATION,
2469
+ variables: { attachmentId: attachment.id },
2470
+ authMode: USER_POOL_AUTH_MODE,
2471
+ });
2472
+ assertNoGraphQLErrors(response.errors);
2473
+ const slot = response.data?.createModelAttachmentDownload;
2474
+ const downloadUrl = slot?.downloadUrl;
2475
+ if (!downloadUrl) return downloadStoragePathTextRaw(attachment.storagePath);
2476
+ const fetchResponse = await fetch(downloadUrl, {
2477
+ method: slot?.method ?? "GET",
2478
+ headers: normalizeUploadHeaders(slot?.requiredHeaders),
2479
+ cache: "no-store",
2480
+ });
2481
+ if (!fetchResponse.ok) {
2482
+ throw new Error(`Attachment download failed: ${fetchResponse.status} ${fetchResponse.statusText}`);
2483
+ }
2484
+ return fetchResponse.text();
2485
+ }
2486
+
2487
+ async function normalizeUploadBody(content: string | Blob | ArrayBuffer | Uint8Array): Promise<{ body: Blob; byteSize: number }> {
2488
+ if (typeof content === "string") {
2489
+ const body = new Blob([content]);
2490
+ return { body, byteSize: body.size };
2491
+ }
2492
+ if (content instanceof Blob) return { body: content, byteSize: content.size };
2493
+ const body = new Blob([content instanceof Uint8Array ? content.slice() : content]);
2494
+ return { body, byteSize: body.size };
2495
+ }
2496
+
2497
+ async function sha256Hex(body: Blob): Promise<string> {
2498
+ const bytes = await body.arrayBuffer();
2499
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
2500
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
2501
+ }
2502
+
2503
+ function normalizeUploadHeaders(value: Record<string, string> | string | null | undefined): Record<string, string> {
2504
+ const parsed = typeof value === "string" ? parseAttachmentJson(value) : value;
2505
+ const headers: Record<string, string> = {};
2506
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return headers;
2507
+ for (const [key, entry] of Object.entries(parsed)) {
2508
+ if (entry !== undefined && entry !== null) headers[key] = String(entry);
2509
+ }
2510
+ return headers;
2511
+ }
2512
+
2513
+ function isJsonMediaType(mediaType: string | null | undefined): boolean {
2514
+ const normalized = (mediaType ?? "").toLowerCase();
2515
+ return normalized.includes("json") || normalized.endsWith("+json");
2516
+ }
2517
+
2518
+ function parseAttachmentJson(text: string): unknown {
2519
+ try {
2520
+ return JSON.parse(text);
2521
+ } catch {
2522
+ return null;
2523
+ }
2524
+ }
2525
+
2526
+ export async function loadNewsroomSemanticNodePage(options: NewsroomSemanticNodePageOptions = {}): Promise<NewsroomRecordPage<SemanticNodeRecord>> {
2527
+ return loadNewsroomFeedPage<SemanticNodeRecord>({
2528
+ query: NEWSROOM_SEMANTIC_NODE_FEED_QUERY,
2529
+ field: "listSemanticNodesByNewsroomFeedAndCreatedAt",
2530
+ newsroomFeedKey: "semanticNodes",
2531
+ limit: options.limit,
2532
+ nextToken: options.nextToken,
2533
+ matches: equalityMatcher({
2534
+ nodeKind: options.nodeKind,
2535
+ status: options.status,
2536
+ }),
2537
+ });
2538
+ }
2539
+
2540
+ export async function loadEditorDoctrineRecordsData(options?: {
2541
+ dashboard?: Pick<CategorySteeringDashboard, "categorys" | "categoryNodes" | "canonicalCategorySetId"> | null;
2542
+ doctrineCategories?: DoctrineCategory[];
2543
+ }): Promise<DoctrineRecord[]> {
2544
+ const doctrineCategories = options?.doctrineCategories
2545
+ ?? (options?.dashboard
2546
+ ? selectAcceptedCategoriesForDoctrine({
2547
+ categorys: options.dashboard.categorys,
2548
+ categoryNodes: options.dashboard.categoryNodes,
2549
+ categorySetId: options.dashboard.canonicalCategorySetId ?? null,
2550
+ })
2551
+ : []);
2552
+ return loadDoctrineRecords(doctrineCategories);
2553
+ }
2554
+
2555
+ export async function loadEditorUserDirectoryData(): Promise<UserDirectoryEntry[]> {
2556
+ return loadUserDirectory();
2557
+ }
2558
+
2559
+ export async function runNewsroomKnowledgeQuery(input: Record<string, unknown>): Promise<KnowledgeQueryResponse> {
2560
+ const client = generateClient<Schema>();
2561
+ const query = client.queries.knowledgeQuery as unknown as (
2562
+ args: { input: string },
2563
+ options: { authMode: typeof USER_POOL_AUTH_MODE },
2564
+ ) => Promise<{ data?: unknown; errors?: unknown[] | null }>;
2565
+ const response = await query({ input: JSON.stringify(input) }, { authMode: USER_POOL_AUTH_MODE });
2566
+ assertNoGraphQLErrors(response.errors);
2567
+ return normalizeKnowledgeQueryResponse(response.data);
2568
+ }
2569
+
2570
+ export function hasTestEditorOverride(): boolean {
2571
+ if (typeof window === "undefined") return false;
2572
+ const allowOverride = process.env.NODE_ENV === "test"
2573
+ || process.env.NEXT_PUBLIC_ENABLE_TEST_EDITOR_OVERRIDE === "true";
2574
+ if (!allowOverride) return false;
2575
+ return window.localStorage.getItem(TEST_EDITOR_STORAGE_KEY) === "true";
2576
+ }
2577
+
2578
+ type TestEditorNewsroomMock = {
2579
+ forumThreadsByEdition?: Record<string, ForumThreadWithMessages[]>;
2580
+ insightForumThreads?: ForumThreadWithMessages[];
2581
+ messages?: MessageRecord[];
2582
+ payloads?: Record<string, HydratedModelPayload[]>;
2583
+ referenceAttachments?: ReferenceAttachmentRecord[];
2584
+ references?: ReferenceRecord[];
2585
+ semanticRelations?: SemanticRelationRecord[];
2586
+ storageTextByPath?: Record<string, string>;
2587
+ storageUrlByPath?: Record<string, string>;
2588
+ summary?: unknown;
2589
+ };
2590
+
2591
+ function getTestEditorNewsroomMock(): TestEditorNewsroomMock | null {
2592
+ if (!hasTestEditorOverride() || typeof window === "undefined") return null;
2593
+ const raw = window.localStorage.getItem(TEST_EDITOR_NEWSROOM_MOCK_STORAGE_KEY);
2594
+ if (!raw) return null;
2595
+ try {
2596
+ const parsed = JSON.parse(raw);
2597
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
2598
+ return parsed as TestEditorNewsroomMock;
2599
+ } catch {
2600
+ return null;
2601
+ }
2602
+ }
2603
+
2604
+ function normalizeKnowledgeQueryResponse(value: unknown): KnowledgeQueryResponse {
2605
+ let parsed = value;
2606
+ if (typeof parsed === "string") {
2607
+ const rawText = parsed;
2608
+ try {
2609
+ parsed = JSON.parse(parsed);
2610
+ } catch {
2611
+ return { warnings: ["knowledgeQuery returned non-JSON text"], context: { format: "markdown", text: rawText } };
2612
+ }
2613
+ }
2614
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2615
+ return { warnings: ["knowledgeQuery returned an empty response"], context: null };
2616
+ }
2617
+ const record = parsed as KnowledgeQueryResponse;
2618
+ return {
2619
+ structured: record.structured,
2620
+ context: record.context ?? null,
2621
+ warnings: Array.isArray(record.warnings) ? record.warnings.filter((warning): warning is string => typeof warning === "string") : [],
2622
+ provenance: record.provenance && typeof record.provenance === "object" && !Array.isArray(record.provenance) ? record.provenance as Record<string, unknown> : {},
2623
+ debug: record.debug && typeof record.debug === "object" && !Array.isArray(record.debug) ? record.debug as Record<string, unknown> : {},
2624
+ };
2625
+ }
2626
+
2627
+ async function loadNewsroomSummary(): Promise<NewsroomSummaryRecord> {
2628
+ configureAmplifyClient();
2629
+ const client = generateClient<Schema>();
2630
+ const response = await (client.queries.getNewsroomSummary as unknown as (
2631
+ args: Record<string, never>,
2632
+ options: { authMode: typeof USER_POOL_AUTH_MODE },
2633
+ ) => Promise<NewsroomSummaryResponse>)({}, { authMode: USER_POOL_AUTH_MODE });
2634
+ assertNoGraphQLErrors(response.errors);
2635
+ return normalizeNewsroomSummary(response.data);
2636
+ }
2637
+
2638
+ function createSummaryCategorySteeringDashboard(summary: NewsroomSummaryRecord): CategorySteeringDashboard {
2639
+ const latestImportRun = summary.latestImportRun;
2640
+ return {
2641
+ ...createEmptyCategorySteeringDashboard(),
2642
+ isPublicSkeleton: false,
2643
+ summaryStatus: "ready",
2644
+ summary,
2645
+ importRuns: latestImportRun ? [latestImportRun] : [],
2646
+ loadError: null,
2647
+ };
2648
+ }
2649
+
2650
+ function newsroomSummaryIsMissing(summary: NewsroomSummaryRecord | null | undefined): boolean {
2651
+ return (summary?.source ?? "").trim().toLowerCase() === "missing";
2652
+ }
2653
+
2654
+ function normalizeNewsroomSummary(value: unknown): NewsroomSummaryRecord {
2655
+ const record = value && typeof value === "object" ? value as Record<string, unknown> : {};
2656
+ return {
2657
+ generatedAt: typeof record.generatedAt === "string" ? record.generatedAt : new Date().toISOString(),
2658
+ staleAt: typeof record.staleAt === "string" ? record.staleAt : null,
2659
+ source: typeof record.source === "string" ? record.source : null,
2660
+ latestImportRun: normalizeLatestImportRun(record.latestImportRun),
2661
+ counts: numberRecord(record.counts),
2662
+ facets: normalizeSummaryFacets(record.facets, record),
2663
+ assignmentStatusCounts: numberRecord(record.assignmentStatusCounts),
2664
+ assignmentTypeCounts: numberRecord(record.assignmentTypeCounts),
2665
+ referenceStatusCounts: numberRecord(record.referenceStatusCounts),
2666
+ messageKindCounts: numberRecord(record.messageKindCounts),
2667
+ messageDomainCounts: numberRecord(record.messageDomainCounts),
2668
+ };
2669
+ }
2670
+
2671
+ function normalizeSummaryFacets(value: unknown, legacy: Record<string, unknown>) {
2672
+ const parsed = parseJsonish(value);
2673
+ const record = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
2674
+ return {
2675
+ assignments: {
2676
+ byStatus: { ...numberRecord(legacy.assignmentStatusCounts), ...sectionCounts(record.assignments, "byStatus") },
2677
+ byType: { ...numberRecord(legacy.assignmentTypeCounts), ...sectionCounts(record.assignments, "byType") },
2678
+ statusByType: nestedCounts(record.assignments, "statusByType"),
2679
+ },
2680
+ messages: {
2681
+ byKind: { ...numberRecord(legacy.messageKindCounts), ...sectionCounts(record.messages, "byKind") },
2682
+ byDomain: { ...numberRecord(legacy.messageDomainCounts), ...sectionCounts(record.messages, "byDomain") },
2683
+ byStatus: sectionCounts(record.messages, "byStatus"),
2684
+ domainByKind: nestedCounts(record.messages, "domainByKind"),
2685
+ },
2686
+ references: {
2687
+ byCurationStatus: { ...numberRecord(legacy.referenceStatusCounts), ...sectionCounts(record.references, "byCurationStatus") },
2688
+ byCorpus: sectionCounts(record.references, "byCorpus"),
2689
+ statusByCorpus: nestedCounts(record.references, "statusByCorpus"),
2690
+ },
2691
+ semanticNodes: {
2692
+ byNodeKind: sectionCounts(record.semanticNodes, "byNodeKind"),
2693
+ byStatus: sectionCounts(record.semanticNodes, "byStatus"),
2694
+ byCorpus: sectionCounts(record.semanticNodes, "byCorpus"),
2695
+ byCategorySet: sectionCounts(record.semanticNodes, "byCategorySet"),
2696
+ },
2697
+ semanticRelations: {
2698
+ byRelationTypeKey: sectionCounts(record.semanticRelations, "byRelationTypeKey"),
2699
+ byRelationDomain: sectionCounts(record.semanticRelations, "byRelationDomain"),
2700
+ bySubjectKind: sectionCounts(record.semanticRelations, "bySubjectKind"),
2701
+ byObjectKind: sectionCounts(record.semanticRelations, "byObjectKind"),
2702
+ },
2703
+ imports: {
2704
+ byCorpus: sectionCounts(record.imports, "byCorpus"),
2705
+ },
2706
+ };
2707
+ }
2708
+
2709
+ function sectionCounts(section: unknown, key: string): Record<string, number> {
2710
+ const record = parseJsonish(section);
2711
+ return record && typeof record === "object" && !Array.isArray(record)
2712
+ ? numberRecord((record as Record<string, unknown>)[key])
2713
+ : {};
2714
+ }
2715
+
2716
+ function nestedCounts(section: unknown, key: string): Record<string, Record<string, number>> {
2717
+ const record = parseJsonish(section);
2718
+ const value = record && typeof record === "object" && !Array.isArray(record) ? (record as Record<string, unknown>)[key] : null;
2719
+ const parsed = parseJsonish(value);
2720
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
2721
+ const result: Record<string, Record<string, number>> = {};
2722
+ for (const [outerKey, inner] of Object.entries(parsed as Record<string, unknown>)) {
2723
+ result[outerKey] = numberRecord(inner);
2724
+ }
2725
+ return result;
2726
+ }
2727
+
2728
+ function normalizeLatestImportRun(value: unknown): CategorySteeringImportRun | null {
2729
+ const parsed = parseJsonish(value);
2730
+ return parsed && typeof parsed === "object" && typeof (parsed as { id?: unknown }).id === "string"
2731
+ ? parsed as CategorySteeringImportRun
2732
+ : null;
2733
+ }
2734
+
2735
+ function numberRecord(value: unknown): Record<string, number> {
2736
+ const parsed = parseJsonish(value);
2737
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
2738
+ const entries = Object.entries(parsed as Record<string, unknown>)
2739
+ .map(([key, entry]) => [key, Number(entry)] as const)
2740
+ .filter(([, entry]) => Number.isFinite(entry));
2741
+ return Object.fromEntries(entries);
2742
+ }
2743
+
2744
+ function parseJsonish(value: unknown): unknown {
2745
+ if (typeof value !== "string") return value;
2746
+ try {
2747
+ return JSON.parse(value);
2748
+ } catch {
2749
+ return value;
2750
+ }
2751
+ }
2752
+
2753
+ async function listUserPoolModel<T>(modelName: string): Promise<T[]> {
2754
+ const client = generateClient<Schema>();
2755
+ const model = (client.models as Record<string, ListableModel<T>>)[modelName];
2756
+ if (!model) throw new Error(`GraphQL model ${modelName} is not available in the deployed schema.`);
2757
+ const list = model.list as (input?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<GraphQLListResponse<T>>;
2758
+
2759
+ const items: T[] = [];
2760
+ let nextToken: string | null | undefined;
2761
+ do {
2762
+ const response = await list({
2763
+ limit: USER_POOL_LIST_LIMIT,
2764
+ nextToken,
2765
+ }, {
2766
+ authMode: USER_POOL_AUTH_MODE,
2767
+ });
2768
+ assertNoGraphQLErrors(response.errors);
2769
+ items.push(...((response.data ?? []).filter(Boolean) as T[]));
2770
+ nextToken = response.nextToken;
2771
+ } while (nextToken);
2772
+ return items;
2773
+ }
2774
+
2775
+ async function listOptionalUserPoolModel<T>(modelName: string): Promise<T[]> {
2776
+ const client = generateClient<Schema>();
2777
+ const model = (client.models as Record<string, ListableModel<T>>)[modelName];
2778
+ if (!model) {
2779
+ if (modelName === "NewsroomSection") {
2780
+ return (await listNewsroomSectionsViaGraphql(client)) as T[];
2781
+ }
2782
+ return [];
2783
+ }
2784
+ return listUserPoolModel<T>(modelName);
2785
+ }
2786
+
2787
+ async function listUserPoolModelPage<T>(modelName: string, limit: number): Promise<T[]> {
2788
+ const client = generateClient<Schema>();
2789
+ const model = (client.models as Record<string, ListableModel<T>>)[modelName];
2790
+ if (!model) throw new Error(`GraphQL model ${modelName} is not available in the deployed schema.`);
2791
+ const list = model.list as (input?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<GraphQLListResponse<T>>;
2792
+ const response = await list({
2793
+ limit,
2794
+ }, {
2795
+ authMode: USER_POOL_AUTH_MODE,
2796
+ });
2797
+ assertNoGraphQLErrors(response.errors);
2798
+ return (response.data ?? []).filter(Boolean) as T[];
2799
+ }
2800
+
2801
+ async function listOptionalUserPoolModelPage<T>(modelName: string, limit: number): Promise<T[]> {
2802
+ const client = generateClient<Schema>();
2803
+ const model = (client.models as Record<string, ListableModel<T>>)[modelName];
2804
+ if (!model) return [];
2805
+ return listUserPoolModelPage<T>(modelName, limit);
2806
+ }
2807
+
2808
+ type ReferenceStatus = (typeof REFERENCE_STATUS_KEYS)[number];
2809
+
2810
+ function normalizeReferenceStatusKey(status: unknown): ReferenceStatus | null {
2811
+ const normalized = typeof status === "string" ? status.trim().toLowerCase() : "";
2812
+ if (!normalized) return null;
2813
+ if (normalized === "pending" || normalized === "accepted" || normalized === "rejected" || normalized === "archived") {
2814
+ return normalized;
2815
+ }
2816
+ return null;
2817
+ }
2818
+
2819
+ type MergedReferenceStatusCursor = {
2820
+ cursors: Record<ReferenceStatus, string | null | undefined>;
2821
+ pendingByStatus: Record<ReferenceStatus, ReferenceRecord[]>;
2822
+ };
2823
+
2824
+ async function loadReferencePageByStatus({
2825
+ status,
2826
+ limit = NEWSROOM_PAGE_LIMIT,
2827
+ nextToken,
2828
+ corpusId,
2829
+ }: {
2830
+ status: ReferenceStatus;
2831
+ limit?: number;
2832
+ nextToken?: string | null;
2833
+ corpusId?: string;
2834
+ }): Promise<NewsroomRecordPage<ReferenceRecord>> {
2835
+ const filter = buildReferenceServerFilter(corpusId);
2836
+ return runReferenceIndexedQuery({
2837
+ query: LIST_REFERENCES_BY_CURATION_STATUS_AND_UPDATED_AT_QUERY,
2838
+ field: "listReferencesByCurationStatusAndUpdatedAt",
2839
+ keyName: "curationStatus",
2840
+ keyValue: status,
2841
+ limit,
2842
+ nextToken,
2843
+ filter,
2844
+ });
2845
+ }
2846
+
2847
+ async function loadReferencePageByReviewedFeed({
2848
+ limit = NEWSROOM_PAGE_LIMIT,
2849
+ nextToken,
2850
+ corpusId,
2851
+ }: {
2852
+ limit?: number;
2853
+ nextToken?: string | null;
2854
+ corpusId?: string;
2855
+ }): Promise<NewsroomRecordPage<ReferenceRecord>> {
2856
+ const filter = buildReferenceServerFilter(corpusId);
2857
+ return runReferenceIndexedQuery({
2858
+ query: LIST_REFERENCES_BY_REVIEWED_FEED_AND_UPDATED_AT_QUERY,
2859
+ field: "listReferencesByReviewedFeedAndUpdatedAt",
2860
+ keyName: "reviewedFeedKey",
2861
+ keyValue: REFERENCE_REVIEWED_FEED_KEY,
2862
+ limit,
2863
+ nextToken,
2864
+ filter,
2865
+ });
2866
+ }
2867
+
2868
+ async function loadMergedReferenceStatusPage({
2869
+ statuses,
2870
+ limit = NEWSROOM_PAGE_LIMIT,
2871
+ nextToken,
2872
+ corpusId,
2873
+ order = "published",
2874
+ }: {
2875
+ statuses: readonly ReferenceStatus[];
2876
+ limit?: number;
2877
+ nextToken?: string | null;
2878
+ corpusId?: string;
2879
+ order?: NewsroomReferencePageOrder;
2880
+ }): Promise<NewsroomRecordPage<ReferenceRecord>> {
2881
+ const cursor = decodeMergedReferenceStatusCursor(nextToken, statuses);
2882
+ const output: ReferenceRecord[] = [];
2883
+ const emitted = new Set<string>();
2884
+ const maxIterations = Math.max(200, limit * 8);
2885
+ let iterations = 0;
2886
+
2887
+ while (output.length < limit && iterations < maxIterations) {
2888
+ iterations += 1;
2889
+ for (const status of statuses) {
2890
+ const pending = cursor.pendingByStatus[status];
2891
+ if (pending.length > 0) continue;
2892
+ const token = cursor.cursors[status];
2893
+ if (token === null) continue;
2894
+ const page = await loadReferencePageByStatus({
2895
+ status,
2896
+ limit: Math.max(8, Math.ceil(limit / statuses.length)),
2897
+ nextToken: token,
2898
+ corpusId,
2899
+ });
2900
+ pending.push(...page.items);
2901
+ cursor.cursors[status] = page.nextToken ?? null;
2902
+ }
2903
+
2904
+ const nextStatus = selectNextReferenceStatus(cursor.pendingByStatus, statuses, order);
2905
+ if (!nextStatus) break;
2906
+ const nextReference = cursor.pendingByStatus[nextStatus].shift();
2907
+ if (!nextReference?.id || emitted.has(nextReference.id)) continue;
2908
+ emitted.add(nextReference.id);
2909
+ output.push(nextReference);
2910
+ }
2911
+
2912
+ const hasMore = statuses.some((status) => (
2913
+ cursor.pendingByStatus[status].length > 0 || cursor.cursors[status] !== null
2914
+ ));
2915
+ return {
2916
+ items: output,
2917
+ nextToken: hasMore ? encodeMergedReferenceStatusCursor(cursor) : null,
2918
+ hasMore,
2919
+ };
2920
+ }
2921
+
2922
+ function buildReferenceServerFilter(corpusId?: string): Record<string, unknown> {
2923
+ const filter: Record<string, unknown> = {
2924
+ versionState: { eq: "current" },
2925
+ newsroomFeedKey: { eq: "references" },
2926
+ };
2927
+ if (corpusId) filter.corpusId = { eq: corpusId };
2928
+ return filter;
2929
+ }
2930
+
2931
+ async function runReferenceIndexedQuery({
2932
+ query,
2933
+ field,
2934
+ keyName,
2935
+ keyValue,
2936
+ limit,
2937
+ nextToken,
2938
+ filter,
2939
+ }: {
2940
+ query: string;
2941
+ field: "listReferencesByCurationStatusAndUpdatedAt" | "listReferencesByReviewedFeedAndUpdatedAt";
2942
+ keyName: "curationStatus" | "reviewedFeedKey";
2943
+ keyValue: string;
2944
+ limit: number;
2945
+ nextToken?: string | null;
2946
+ filter: Record<string, unknown>;
2947
+ }): Promise<NewsroomRecordPage<ReferenceRecord>> {
2948
+ const client = generateClient<Schema>() as unknown as {
2949
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ReferenceRecord>>;
2950
+ };
2951
+ let response: GraphQLConnectionResponse<ReferenceRecord>;
2952
+ try {
2953
+ response = await client.graphql({
2954
+ query,
2955
+ variables: {
2956
+ [keyName]: keyValue,
2957
+ sortDirection: "DESC",
2958
+ limit,
2959
+ nextToken: nextToken ?? null,
2960
+ filter,
2961
+ },
2962
+ authMode: USER_POOL_AUTH_MODE,
2963
+ });
2964
+ } catch (error) {
2965
+ throw new Error(normalizeUnknownErrorMessage(error, `Failed to load ${field}`));
2966
+ }
2967
+ const connection = response.data?.[field];
2968
+ if (!connection) {
2969
+ assertNoGraphQLErrors(response.errors);
2970
+ throw new Error(`Missing GraphQL connection payload for ${field}.`);
2971
+ }
2972
+ if (response.errors?.length) {
2973
+ console.warn(`GraphQL returned partial data for ${field}.`, response.errors);
2974
+ }
2975
+ return {
2976
+ items: ((connection.items ?? []).filter(Boolean) as ReferenceRecord[]),
2977
+ nextToken: connection.nextToken ?? null,
2978
+ hasMore: Boolean(connection.nextToken),
2979
+ };
2980
+ }
2981
+
2982
+ function selectNextReferenceStatus(
2983
+ pendingByStatus: Record<ReferenceStatus, ReferenceRecord[]>,
2984
+ statuses: readonly ReferenceStatus[],
2985
+ order: NewsroomReferencePageOrder = "published",
2986
+ ): ReferenceStatus | null {
2987
+ let selected: ReferenceStatus | null = null;
2988
+ for (const status of statuses) {
2989
+ const queue = pendingByStatus[status];
2990
+ const candidate = queue[0];
2991
+ if (!candidate) continue;
2992
+ if (!selected) {
2993
+ selected = status;
2994
+ continue;
2995
+ }
2996
+ const current = pendingByStatus[selected][0];
2997
+ if (!current || compareReferencesForPageOrder(candidate, current, order) < 0) selected = status;
2998
+ }
2999
+ return selected;
3000
+ }
3001
+
3002
+ function compareReferencesForPageOrder(
3003
+ left: ReferenceRecord,
3004
+ right: ReferenceRecord,
3005
+ order: NewsroomReferencePageOrder,
3006
+ ): number {
3007
+ if (order === "imported") return compareReferencesByImportedAt(left, right);
3008
+ return compareReferencesByPublicationDate(left, right);
3009
+ }
3010
+
3011
+ function compareReferencesByRecency(left: ReferenceRecord, right: ReferenceRecord): number {
3012
+ const leftTs = Date.parse(referenceRecencyTimestamp(left));
3013
+ const rightTs = Date.parse(referenceRecencyTimestamp(right));
3014
+ const leftRank = Number.isFinite(leftTs) ? leftTs : 0;
3015
+ const rightRank = Number.isFinite(rightTs) ? rightTs : 0;
3016
+ if (leftRank !== rightRank) return rightRank - leftRank;
3017
+ return String(right.id).localeCompare(String(left.id));
3018
+ }
3019
+
3020
+ function compareReferencesByImportedAt(left: ReferenceRecord, right: ReferenceRecord): number {
3021
+ const leftTs = Date.parse(referenceImportedTimestamp(left));
3022
+ const rightTs = Date.parse(referenceImportedTimestamp(right));
3023
+ const leftRank = Number.isFinite(leftTs) ? leftTs : 0;
3024
+ const rightRank = Number.isFinite(rightTs) ? rightTs : 0;
3025
+ if (leftRank !== rightRank) return rightRank - leftRank;
3026
+ return String(right.id).localeCompare(String(left.id));
3027
+ }
3028
+
3029
+ function compareReferencesByPublicationDate(left: ReferenceRecord, right: ReferenceRecord): number {
3030
+ const leftTs = Date.parse(referencePublicationTimestamp(left));
3031
+ const rightTs = Date.parse(referencePublicationTimestamp(right));
3032
+ const leftRank = Number.isFinite(leftTs) ? leftTs : 0;
3033
+ const rightRank = Number.isFinite(rightTs) ? rightTs : 0;
3034
+ if (leftRank !== rightRank) return rightRank - leftRank;
3035
+ return String(right.id).localeCompare(String(left.id));
3036
+ }
3037
+
3038
+ function sortReferencePageByOrder(
3039
+ page: NewsroomRecordPage<ReferenceRecord>,
3040
+ order: NewsroomReferencePageOrder,
3041
+ ): NewsroomRecordPage<ReferenceRecord> {
3042
+ const compare = order === "imported" ? compareReferencesByImportedAt : compareReferencesByPublicationDate;
3043
+ return {
3044
+ ...page,
3045
+ items: [...page.items].sort(compare),
3046
+ };
3047
+ }
3048
+
3049
+ function referenceRecencyTimestamp(reference: ReferenceRecord): string {
3050
+ return reference.updatedAt
3051
+ || reference.curationStatusUpdatedAt
3052
+ || reference.importedAt
3053
+ || reference.createdAt
3054
+ || "";
3055
+ }
3056
+
3057
+ function referenceImportedTimestamp(reference: ReferenceRecord): string {
3058
+ return reference.importedAt
3059
+ || reference.createdAt
3060
+ || "";
3061
+ }
3062
+
3063
+ function referencePublicationTimestamp(reference: ReferenceRecord): string {
3064
+ return reference.sourcePublishedAt
3065
+ || reference.sourceUpdatedAt
3066
+ || reference.retrievedAt
3067
+ || reference.importedAt
3068
+ || reference.updatedAt
3069
+ || "";
3070
+ }
3071
+
3072
+ function encodeMergedReferenceStatusCursor(cursor: MergedReferenceStatusCursor): string {
3073
+ const payload = JSON.stringify({ v: 1, ...cursor });
3074
+ return `ref-status-merge:${encodeURIComponent(payload)}`;
3075
+ }
3076
+
3077
+ function decodeMergedReferenceStatusCursor(
3078
+ token: string | null | undefined,
3079
+ statuses: readonly ReferenceStatus[],
3080
+ ): MergedReferenceStatusCursor {
3081
+ const empty = buildEmptyMergedReferenceStatusCursor(statuses);
3082
+ if (!token) return empty;
3083
+ if (!token.startsWith("ref-status-merge:")) return empty;
3084
+ try {
3085
+ const parsed = JSON.parse(decodeURIComponent(token.slice("ref-status-merge:".length))) as {
3086
+ v?: number;
3087
+ cursors?: Record<string, unknown>;
3088
+ pendingByStatus?: Record<string, unknown>;
3089
+ };
3090
+ if (parsed.v !== 1) return empty;
3091
+ const next: MergedReferenceStatusCursor = buildEmptyMergedReferenceStatusCursor(statuses);
3092
+ for (const status of statuses) {
3093
+ const rawCursor = parsed.cursors?.[status];
3094
+ next.cursors[status] = typeof rawCursor === "string" ? rawCursor : rawCursor === null ? null : undefined;
3095
+ const rawRows = parsed.pendingByStatus?.[status];
3096
+ next.pendingByStatus[status] = Array.isArray(rawRows)
3097
+ ? (rawRows.filter((row): row is ReferenceRecord => Boolean(row && typeof row === "object")) as ReferenceRecord[])
3098
+ : [];
3099
+ }
3100
+ return next;
3101
+ } catch {
3102
+ return empty;
3103
+ }
3104
+ }
3105
+
3106
+ function buildEmptyMergedReferenceStatusCursor(statuses: readonly ReferenceStatus[]): MergedReferenceStatusCursor {
3107
+ const cursors = {} as Record<ReferenceStatus, string | null | undefined>;
3108
+ const pendingByStatus = {} as Record<ReferenceStatus, ReferenceRecord[]>;
3109
+ for (const status of statuses) {
3110
+ cursors[status] = undefined;
3111
+ pendingByStatus[status] = [];
3112
+ }
3113
+ return { cursors, pendingByStatus };
3114
+ }
3115
+
3116
+ async function loadNewsroomFeedPage<T>({
3117
+ query,
3118
+ field,
3119
+ newsroomFeedKey,
3120
+ matches,
3121
+ limit = NEWSROOM_PAGE_LIMIT,
3122
+ nextToken,
3123
+ }: {
3124
+ query: string;
3125
+ field: string;
3126
+ newsroomFeedKey: string;
3127
+ matches?: ((item: T) => boolean) | null;
3128
+ limit?: number;
3129
+ nextToken?: string | null;
3130
+ }): Promise<NewsroomRecordPage<T>> {
3131
+ const client = generateClient<Schema>() as unknown as {
3132
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<T>>;
3133
+ };
3134
+ const items: T[] = [];
3135
+ let cursor = nextToken ?? null;
3136
+ let pageCount = 0;
3137
+ let connectionNextToken: string | null = null;
3138
+
3139
+ do {
3140
+ pageCount += 1;
3141
+ let response: GraphQLConnectionResponse<T>;
3142
+ try {
3143
+ response = await client.graphql({
3144
+ query,
3145
+ variables: {
3146
+ newsroomFeedKey,
3147
+ sortDirection: "DESC",
3148
+ limit,
3149
+ nextToken: cursor,
3150
+ filter: null,
3151
+ },
3152
+ authMode: USER_POOL_AUTH_MODE,
3153
+ });
3154
+ } catch (error) {
3155
+ throw new Error(normalizeUnknownErrorMessage(error, `Failed to load ${field}`));
3156
+ }
3157
+ const connection = response.data?.[field];
3158
+ if (!connection) {
3159
+ if (isMissingGraphQLQueryFieldError(response.errors, field)) {
3160
+ throw new Error(
3161
+ `Validation error of type FieldUndefined: Field '${field}' in type 'Query' is undefined @ '${field}'`,
3162
+ );
3163
+ }
3164
+ assertNoGraphQLErrors(response.errors);
3165
+ throw new Error(`Missing GraphQL connection payload for ${field}.`);
3166
+ }
3167
+ if (response.errors?.length) {
3168
+ // Preserve list rendering when AppSync returns partial data and per-row errors.
3169
+ console.warn(`GraphQL returned partial data for ${field}.`, response.errors);
3170
+ }
3171
+ connectionNextToken = connection?.nextToken ?? null;
3172
+ const pageItems = ((connection?.items ?? []).filter(Boolean) as T[])
3173
+ .filter((item) => matches ? matches(item) : true);
3174
+ items.push(...pageItems);
3175
+ cursor = connectionNextToken;
3176
+ } while (items.length < limit && cursor && pageCount < 10);
3177
+
3178
+ return {
3179
+ items: items.slice(0, limit),
3180
+ nextToken: connectionNextToken,
3181
+ hasMore: Boolean(connectionNextToken),
3182
+ };
3183
+ }
3184
+
3185
+ async function loadSemanticRelationsByState({
3186
+ field,
3187
+ keyName,
3188
+ keyValue,
3189
+ query,
3190
+ }: {
3191
+ field: "listSemanticRelationsByObjectState" | "listSemanticRelationsBySubjectState";
3192
+ keyName: "objectStateKey" | "subjectStateKey";
3193
+ keyValue: string;
3194
+ query: string;
3195
+ }): Promise<SemanticRelationRecord[]> {
3196
+ const client = generateClient<Schema>() as unknown as {
3197
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<SemanticRelationRecord>>;
3198
+ };
3199
+ const rows: SemanticRelationRecord[] = [];
3200
+ const seenIds = new Set<string>();
3201
+ let nextToken: string | null = null;
3202
+ let pageCount = 0;
3203
+ do {
3204
+ pageCount += 1;
3205
+ const response = await client.graphql({
3206
+ query,
3207
+ variables: {
3208
+ [keyName]: keyValue,
3209
+ sortDirection: "DESC",
3210
+ limit: 200,
3211
+ nextToken,
3212
+ filter: null,
3213
+ },
3214
+ authMode: USER_POOL_AUTH_MODE,
3215
+ });
3216
+ assertNoGraphQLErrors(response.errors);
3217
+ const connection = response.data?.[field];
3218
+ if (!connection) break;
3219
+ for (const item of (connection.items ?? []).filter(Boolean) as SemanticRelationRecord[]) {
3220
+ if (!item.id || seenIds.has(item.id)) continue;
3221
+ seenIds.add(item.id);
3222
+ rows.push(item);
3223
+ }
3224
+ nextToken = connection.nextToken ?? null;
3225
+ } while (nextToken && pageCount < 200);
3226
+ return rows;
3227
+ }
3228
+
3229
+ function dedupeSemanticRelationsById(relations: SemanticRelationRecord[]): SemanticRelationRecord[] {
3230
+ const seen = new Set<string>();
3231
+ const deduped: SemanticRelationRecord[] = [];
3232
+ for (const relation of relations) {
3233
+ if (!relation.id || seen.has(relation.id)) continue;
3234
+ seen.add(relation.id);
3235
+ deduped.push(relation);
3236
+ }
3237
+ return deduped;
3238
+ }
3239
+
3240
+ async function listNewsroomSectionsViaGraphql(client: ReturnType<typeof generateClient<Schema>>): Promise<NewsroomSectionRecord[]> {
3241
+ const graphClient = client as unknown as {
3242
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<NewsroomSectionRecord>>;
3243
+ };
3244
+ const rows: NewsroomSectionRecord[] = [];
3245
+ let nextToken: string | null | undefined = null;
3246
+ do {
3247
+ const response = await graphClient.graphql({
3248
+ query: NEWSROOM_SECTION_LIST_QUERY,
3249
+ variables: { limit: USER_POOL_LIST_LIMIT, nextToken },
3250
+ authMode: USER_POOL_AUTH_MODE,
3251
+ });
3252
+ assertNoGraphQLErrors(response.errors);
3253
+ const connection = response.data?.listNewsroomSections;
3254
+ rows.push(...((connection?.items ?? []).filter(Boolean) as NewsroomSectionRecord[]));
3255
+ nextToken = connection?.nextToken ?? null;
3256
+ } while (nextToken);
3257
+ return rows;
3258
+ }
3259
+
3260
+ async function listSteeringProposalsViaGraphql(): Promise<CategorySteeringProposal[]> {
3261
+ const client = generateClient<Schema>() as unknown as {
3262
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<CategorySteeringProposal>>;
3263
+ };
3264
+ const rows: CategorySteeringProposal[] = [];
3265
+ let nextToken: string | null | undefined = null;
3266
+ do {
3267
+ const response = await client.graphql({
3268
+ query: LIST_STEERING_PROPOSALS_QUERY,
3269
+ variables: { limit: USER_POOL_LIST_LIMIT, nextToken },
3270
+ authMode: USER_POOL_AUTH_MODE,
3271
+ });
3272
+ assertNoGraphQLErrors(response.errors);
3273
+ const connection = response.data?.listSteeringProposals;
3274
+ rows.push(...((connection?.items ?? []).filter(Boolean) as CategorySteeringProposal[]));
3275
+ nextToken = connection?.nextToken ?? null;
3276
+ } while (nextToken);
3277
+ return rows;
3278
+ }
3279
+
3280
+ function equalityMatcher<T extends Record<string, unknown>>(values: Record<string, string | null | undefined>): ((item: T) => boolean) | null {
3281
+ const entries = Object.entries(values)
3282
+ .map(([key, value]) => [key, typeof value === "string" ? value.trim() : ""] as const)
3283
+ .filter((entry): entry is readonly [string, string] => Boolean(entry[1]));
3284
+ if (!entries.length) return null;
3285
+ return (item: T) => entries.every(([key, value]) => item[key] === value);
3286
+ }
3287
+
3288
+ export function selectRootDeskCategoriesForDoctrine({
3289
+ categorys,
3290
+ categoryNodes,
3291
+ categorySetId,
3292
+ }: {
3293
+ categorys: CategorySteeringCategory[];
3294
+ categoryNodes: CategorySteeringCategoryTreeNode[];
3295
+ categorySetId: string | null;
3296
+ }): CategorySteeringCategory[] {
3297
+ const categorySetFilter = (category: { categorySetId: string }) => !categorySetId || category.categorySetId === categorySetId;
3298
+ const currentCategorys = categorys.filter((category) => (
3299
+ categorySetFilter(category)
3300
+ && category.status !== "deprecated"
3301
+ && category.versionState !== "superseded"
3302
+ ));
3303
+ const categoryByKey = new Map(currentCategorys.map((category) => [category.categoryKey, category]));
3304
+ const rootNodes = categoryNodes
3305
+ .filter((node) => (
3306
+ categorySetFilter(node)
3307
+ && node.status === "accepted"
3308
+ && node.versionState !== "superseded"
3309
+ && !node.parentCategoryKey
3310
+ ))
3311
+ .sort((left, right) => categorySortKey(left).localeCompare(categorySortKey(right)));
3312
+
3313
+ if (rootNodes.length) {
3314
+ return rootNodes.map((node) => mergeRootNodeWithCategory(node, categoryByKey.get(node.categoryKey)));
3315
+ }
3316
+
3317
+ return currentCategorys
3318
+ .filter((category) => category.status === "accepted" && category.versionState !== "superseded" && !category.parentCategoryKey)
3319
+ .sort((left, right) => categorySortKey(left).localeCompare(categorySortKey(right)));
3320
+ }
3321
+
3322
+ export function selectAcceptedCategoriesForDoctrine({
3323
+ categorys,
3324
+ categoryNodes,
3325
+ categorySetId,
3326
+ }: {
3327
+ categorys: CategorySteeringCategory[];
3328
+ categoryNodes: CategorySteeringCategoryTreeNode[];
3329
+ categorySetId: string | null;
3330
+ }): DoctrineCategory[] {
3331
+ const categorySetFilter = (category: { categorySetId: string }) => !categorySetId || category.categorySetId === categorySetId;
3332
+ const currentCategorys = categorys.filter((category) => (
3333
+ categorySetFilter(category)
3334
+ && category.status !== "deprecated"
3335
+ && category.versionState !== "superseded"
3336
+ ));
3337
+ const categoryByKey = new Map(currentCategorys.map((category) => [category.categoryKey, category]));
3338
+ return categoryNodes
3339
+ .filter((node) => (
3340
+ categorySetFilter(node)
3341
+ && node.status === "accepted"
3342
+ && node.versionState !== "superseded"
3343
+ ))
3344
+ .sort((left, right) => categorySortKey(left).localeCompare(categorySortKey(right)))
3345
+ .map((node) => mergeRootNodeWithCategory(node, categoryByKey.get(node.categoryKey)));
3346
+ }
3347
+
3348
+ async function loadDoctrineRecords(doctrineCategories: DoctrineCategory[] = []): Promise<DoctrineRecord[]> {
3349
+ const definitions = uniqueDoctrineDefinitions([
3350
+ ...DOCTRINE_DEFINITIONS,
3351
+ ...doctrineCategories.flatMap((category) => getCategoryDoctrineDefinitions(category)),
3352
+ ]);
3353
+ const records = await Promise.all(
3354
+ definitions.map((definition) => getDoctrineRecordBySlug(definition.slug)),
3355
+ );
3356
+ return records.filter((record): record is DoctrineRecord => record !== null);
3357
+ }
3358
+
3359
+ async function getDoctrineRecordBySlug(slug: string): Promise<DoctrineRecord | null> {
3360
+ const client = generateClient<Schema>();
3361
+ const model = client.models.Item as unknown as SlugQueryableModel<DoctrineRecord>;
3362
+ if (typeof model.itemBySlug !== "function") {
3363
+ throw new Error("GraphQL Item.itemBySlug is not available in the deployed schema.");
3364
+ }
3365
+
3366
+ const records: DoctrineRecord[] = [];
3367
+ let nextToken: string | null | undefined;
3368
+ do {
3369
+ const response = await model.itemBySlug(
3370
+ { slug },
3371
+ {
3372
+ authMode: USER_POOL_AUTH_MODE,
3373
+ limit: 100,
3374
+ nextToken,
3375
+ },
3376
+ );
3377
+ assertNoGraphQLErrors(response.errors);
3378
+ records.push(...((response.data ?? []).filter(Boolean) as DoctrineRecord[]));
3379
+ nextToken = response.nextToken;
3380
+ } while (nextToken);
3381
+
3382
+ const doctrine = records.find((record) => record.type === "doctrine") ?? records[0] ?? null;
3383
+ return doctrine;
3384
+ }
3385
+
3386
+ function uniqueDoctrineDefinitions(definitions: typeof DOCTRINE_DEFINITIONS): typeof DOCTRINE_DEFINITIONS {
3387
+ const bySlug = new Map<string, (typeof DOCTRINE_DEFINITIONS)[number]>();
3388
+ for (const definition of definitions) bySlug.set(definition.slug, definition);
3389
+ return Array.from(bySlug.values());
3390
+ }
3391
+
3392
+ function mergeRootNodeWithCategory(
3393
+ node: CategorySteeringCategoryTreeNode,
3394
+ category?: CategorySteeringCategory,
3395
+ ): CategorySteeringCategory {
3396
+ return {
3397
+ ...node,
3398
+ ...category,
3399
+ id: category?.id ?? node.id,
3400
+ lineageId: category?.lineageId ?? node.lineageId ?? category?.id ?? node.id,
3401
+ versionNumber: category?.versionNumber ?? node.versionNumber,
3402
+ previousVersionId: category?.previousVersionId ?? node.previousVersionId,
3403
+ versionState: category?.versionState ?? node.versionState,
3404
+ versionCreatedAt: category?.versionCreatedAt ?? node.versionCreatedAt,
3405
+ versionCreatedBy: category?.versionCreatedBy ?? node.versionCreatedBy,
3406
+ changeReason: category?.changeReason ?? node.changeReason,
3407
+ contentHash: category?.contentHash ?? node.contentHash,
3408
+ categorySetId: category?.categorySetId ?? node.categorySetId,
3409
+ corpusId: category?.corpusId ?? node.corpusId,
3410
+ categoryKey: node.categoryKey,
3411
+ parentCategoryId: null,
3412
+ parentCategoryKey: null,
3413
+ displayName: category?.displayName ?? node.displayName,
3414
+ shortTitle: category?.shortTitle ?? node.shortTitle,
3415
+ subtitle: category?.subtitle ?? node.subtitle,
3416
+ description: category?.description ?? node.description,
3417
+ aliases: category?.aliases ?? node.aliases,
3418
+ status: category?.status ?? node.status,
3419
+ seedItemIds: category?.seedItemIds ?? node.seedItemIds,
3420
+ holdoutItemIds: category?.holdoutItemIds ?? node.holdoutItemIds,
3421
+ rank: category?.rank ?? node.rank,
3422
+ depth: 0,
3423
+ isPinned: category?.isPinned ?? node.isPinned,
3424
+ importRunId: category?.importRunId ?? node.importRunId,
3425
+ updatedAt: category?.updatedAt ?? node.updatedAt,
3426
+ };
3427
+ }
3428
+
3429
+ function categorySortKey(category: Pick<CategorySteeringCategory, "rank" | "categoryKey">): string {
3430
+ return `${String(category.rank ?? 999999).padStart(6, "0")}#${category.categoryKey}`;
3431
+ }
3432
+
3433
+ async function loadUserDirectory(): Promise<UserDirectoryEntry[]> {
3434
+ const client = generateClient<Schema>();
3435
+ const response = await client.queries.listUserDirectory({ authMode: USER_POOL_AUTH_MODE });
3436
+ assertNoGraphQLErrors(response.errors);
3437
+ return ((response.data?.entries ?? []).filter(Boolean) as UserDirectoryEntry[])
3438
+ .sort((left, right) => (left.displayName ?? left.email ?? left.userSub ?? "").localeCompare(right.displayName ?? right.email ?? right.userSub ?? ""));
3439
+ }
3440
+
3441
+ function appendixFromCategoryTree(categoryTree: CategorySteeringCategoryTree, nodes: CategorySteeringCategoryTreeNode[]): NewsDeskAppendix {
3442
+ return {
3443
+ categorySetId: categoryTree.id,
3444
+ corpusId: categoryTree.corpusId,
3445
+ displayName: categoryTree.displayName,
3446
+ description: categoryTree.description,
3447
+ generatedAt: categoryTree.generatedAt,
3448
+ nodes: nodes
3449
+ .filter((node) => node.categorySetId === categoryTree.id)
3450
+ .map((node) => ({
3451
+ id: node.id,
3452
+ categorySetId: node.categorySetId,
3453
+ categoryKey: node.categoryKey,
3454
+ parentCategoryKey: node.parentCategoryKey,
3455
+ displayName: node.displayName,
3456
+ shortTitle: node.shortTitle,
3457
+ subtitle: node.subtitle,
3458
+ description: node.description,
3459
+ status: node.status,
3460
+ seedItemIds: node.seedItemIds,
3461
+ holdoutItemIds: node.holdoutItemIds,
3462
+ rank: node.rank,
3463
+ depth: node.depth,
3464
+ })),
3465
+ };
3466
+ }
3467
+
3468
+ function categoryTreeFromAppendix(appendix: NewsDeskAppendix): CategorySteeringCategoryTree {
3469
+ const roots = appendix.nodes.filter((node) => node.status === "accepted" && !node.parentCategoryKey);
3470
+ return {
3471
+ id: appendix.categorySetId,
3472
+ corpusId: appendix.corpusId,
3473
+ classifierId: "scenario",
3474
+ displayName: appendix.displayName,
3475
+ description: appendix.description,
3476
+ status: "accepted",
3477
+ generatedAt: appendix.generatedAt,
3478
+ nodeCount: appendix.nodes.length,
3479
+ rootCount: roots.length,
3480
+ };
3481
+ }
3482
+
3483
+ function categoryTreeNodeFromAppendixNode(node: NewsDeskCategoryTreeNode): CategorySteeringCategoryTreeNode {
3484
+ return {
3485
+ id: node.id,
3486
+ categorySetId: node.categorySetId,
3487
+ corpusId: "",
3488
+ categoryKey: node.categoryKey,
3489
+ parentCategoryKey: node.parentCategoryKey,
3490
+ displayName: node.displayName,
3491
+ shortTitle: node.shortTitle,
3492
+ subtitle: node.subtitle,
3493
+ description: node.description,
3494
+ status: node.status,
3495
+ seedItemIds: node.seedItemIds,
3496
+ holdoutItemIds: node.holdoutItemIds,
3497
+ rank: node.rank,
3498
+ depth: node.depth,
3499
+ };
3500
+ }
3501
+
3502
+ function selectCurrentAcceptedCategoryTree(categoryTrees: CategorySteeringCategoryTree[]): CategorySteeringCategoryTree | null {
3503
+ return categoryTrees.find(isCurrentAcceptedCategorySet) ?? null;
3504
+ }
3505
+
3506
+ function selectCanonicalCategorySet(corpora: CategorySteeringCorpus[], categorySets: CategorySteeringCategorySet[]): CategorySteeringCategorySet | null {
3507
+ const canonicalCorpus = selectCanonicalCorpus(corpora);
3508
+ const candidates = categorySets.filter(isCurrentAcceptedCategorySet);
3509
+ const canonicalCandidates = canonicalCorpus ? candidates.filter((categorySet) => categorySet.corpusId === canonicalCorpus.id) : candidates;
3510
+ return canonicalCandidates[0] ?? candidates[0] ?? null;
3511
+ }
3512
+
3513
+ function isCurrentAcceptedCategorySet(categorySet: CategorySteeringCategorySet | CategorySteeringCategoryTree): boolean {
3514
+ return categorySet.versionState === "current" && categorySet.status === "accepted";
3515
+ }
3516
+
3517
+ function selectCanonicalCorpus(corpora: CategorySteeringCorpus[]): CategorySteeringCorpus | null {
3518
+ return corpora.find((corpus) => corpus.role === "canonical")
3519
+ ?? corpora.find((corpus) => corpus.role === "authority")
3520
+ ?? corpora[0]
3521
+ ?? null;
3522
+ }
3523
+
3524
+ function sortCategorySets(categorySets: CategorySteeringCategorySet[], importRuns: CategorySteeringImportRun[]): CategorySteeringCategorySet[] {
3525
+ const importRunById = new Map(importRuns.map((importRun) => [importRun.id, importRun]));
3526
+ return [...categorySets].sort((left, right) => {
3527
+ const statusDiff = categorySetStatusRank(left.status) - categorySetStatusRank(right.status);
3528
+ if (statusDiff !== 0) return statusDiff;
3529
+ const dateDiff = categorySetSortDate(right, importRunById).localeCompare(categorySetSortDate(left, importRunById));
3530
+ if (dateDiff !== 0) return dateDiff;
3531
+ return left.displayName.localeCompare(right.displayName);
3532
+ });
3533
+ }
3534
+
3535
+ function categorySetSortDate(categorySet: CategorySteeringCategorySet, importRunById: Map<string, CategorySteeringImportRun>): string {
3536
+ return categorySet.generatedAt ?? (categorySet.importRunId ? importRunById.get(categorySet.importRunId)?.importedAt : null) ?? "";
3537
+ }
3538
+
3539
+ function categorySetStatusRank(status: string): number {
3540
+ if (status === "accepted") return 0;
3541
+ if (status === "draft") return 1;
3542
+ if (status === "proposed") return 2;
3543
+ if (status === "deprecated") return 8;
3544
+ return 5;
3545
+ }
3546
+
3547
+ function sortCategorys(categorys: CategorySteeringCategory[]): CategorySteeringCategory[] {
3548
+ return [...categorys].sort((left, right) => {
3549
+ const rankDiff = (left.rank ?? 999999) - (right.rank ?? 999999);
3550
+ if (rankDiff !== 0) return rankDiff;
3551
+ return left.categoryKey.localeCompare(right.categoryKey);
3552
+ });
3553
+ }
3554
+
3555
+ function normalizeNewsroomSectionType(value: string | null | undefined): "canonical" | "floating" {
3556
+ return value === "floating" || value === "rotating" ? "floating" : "canonical";
3557
+ }
3558
+
3559
+ function sortNewsroomSections(sections: NewsroomSectionRecord[]): NewsroomSectionRecord[] {
3560
+ return [...sections]
3561
+ .map((section) => ({ ...section, type: normalizeNewsroomSectionType(section.type) }))
3562
+ .sort((left, right) => {
3563
+ const typeDiff = (left.type === "canonical" ? 0 : 1) - (right.type === "canonical" ? 0 : 1);
3564
+ if (typeDiff !== 0) return typeDiff;
3565
+ const sortDiff = (left.sortOrder ?? 999999) - (right.sortOrder ?? 999999);
3566
+ if (sortDiff !== 0) return sortDiff;
3567
+ return left.title.localeCompare(right.title);
3568
+ });
3569
+ }
3570
+
3571
+ function sortProposals(proposals: CategorySteeringProposal[]): CategorySteeringProposal[] {
3572
+ const statusWeight = new Map([
3573
+ ["proposed", 0],
3574
+ ["deferred", 1],
3575
+ ["accepted", 2],
3576
+ ["rejected", 3],
3577
+ ]);
3578
+ return [...proposals].sort((left, right) => {
3579
+ const statusDiff = (statusWeight.get(left.status) ?? 9) - (statusWeight.get(right.status) ?? 9);
3580
+ if (statusDiff !== 0) return statusDiff;
3581
+ return (right.proposedAt ?? right.updatedAt ?? "").localeCompare(left.proposedAt ?? left.updatedAt ?? "");
3582
+ });
3583
+ }
3584
+
3585
+
3586
+ function keywordSortKey(keyword: CategoryKeywordRecord): string {
3587
+ return `${keyword.categorySetId}#${keyword.categoryKey}#${String(keyword.rank ?? 999999).padStart(6, "0")}#${keyword.normalizedKeyword}`;
3588
+ }
3589
+
3590
+ function lexicalRuleSortKey(rule: LexicalSteeringRuleRecord): string {
3591
+ return `${rule.status === "active" ? "0" : "1"}#${rule.scope}#${rule.normalizedTerm}#${rule.id}`;
3592
+ }
3593
+
3594
+ function assignmentSortKey(assignment: AssignmentRecord): string {
3595
+ return `${String(assignment.priority ?? 999999).padStart(6, "0")}#${assignment.createdAt}#${assignment.id}`;
3596
+ }
3597
+
3598
+ function sortTaxonomies(categoryTrees: CategorySteeringCategoryTree[]): CategorySteeringCategoryTree[] {
3599
+ return [...categoryTrees].sort((left, right) => {
3600
+ const statusDiff = categoryTreeStatusRank(left.status) - categoryTreeStatusRank(right.status);
3601
+ if (statusDiff !== 0) return statusDiff;
3602
+ const dateDiff = categoryTreeDate(right).localeCompare(categoryTreeDate(left));
3603
+ if (dateDiff !== 0) return dateDiff;
3604
+ return left.displayName.localeCompare(right.displayName);
3605
+ });
3606
+ }
3607
+
3608
+ function sortCategoryTreeNodes(nodes: CategorySteeringCategoryTreeNode[]): CategorySteeringCategoryTreeNode[] {
3609
+ return [...nodes].sort((left, right) => {
3610
+ const depthDiff = (left.depth ?? 0) - (right.depth ?? 0);
3611
+ if (depthDiff !== 0) return depthDiff;
3612
+ const rankDiff = (left.rank ?? 999999) - (right.rank ?? 999999);
3613
+ if (rankDiff !== 0) return rankDiff;
3614
+ return left.categoryKey.localeCompare(right.categoryKey);
3615
+ });
3616
+ }
3617
+
3618
+ function categoryTreeStatusRank(status: string): number {
3619
+ if (status === "accepted") return 0;
3620
+ if (status === "draft") return 1;
3621
+ if (status === "proposed") return 2;
3622
+ if (status === "deprecated") return 8;
3623
+ return 5;
3624
+ }
3625
+
3626
+ function categoryTreeDate(categoryTree: CategorySteeringCategoryTree): string {
3627
+ return categoryTree.generatedAt ?? categoryTree.updatedAt ?? categoryTree.createdAt ?? "";
3628
+ }
3629
+
3630
+ function normalizeJsonValue(value: unknown): Record<string, any> | null {
3631
+ if (!value) return null;
3632
+ if (typeof value === "string") {
3633
+ try {
3634
+ const parsed = JSON.parse(value);
3635
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, any> : null;
3636
+ } catch {
3637
+ return null;
3638
+ }
3639
+ }
3640
+ if (typeof value === "object" && !Array.isArray(value)) return value as Record<string, any>;
3641
+ return null;
3642
+ }
3643
+
3644
+ function assertNoGraphQLErrors(errors?: unknown[] | null) {
3645
+ if (!errors?.length) return;
3646
+ const details = errors
3647
+ .map((error) => {
3648
+ if (error && typeof error === "object" && "message" in error) return String((error as { message?: unknown }).message);
3649
+ return String(error);
3650
+ })
3651
+ .join("; ");
3652
+ throw new Error(details);
3653
+ }
3654
+
3655
+ function isMissingGraphQLQueryFieldError(error: unknown, fieldName: string): boolean {
3656
+ if (Array.isArray(error)) {
3657
+ return error.some((entry) => isMissingGraphQLQueryFieldError(entry, fieldName));
3658
+ }
3659
+ const message = normalizeUnknownErrorMessage(error, "");
3660
+ return message.includes("FieldUndefined") && message.includes(fieldName);
3661
+ }
3662
+
3663
+ function normalizeUnknownErrorMessage(error: unknown, fallback: string): string {
3664
+ if (error instanceof Error && error.message.trim()) return error.message;
3665
+ if (typeof error === "string" && error.trim()) return error;
3666
+ if (!error || typeof error !== "object") return fallback;
3667
+ const record = error as Record<string, unknown>;
3668
+ const directMessage = typeof record.message === "string" ? record.message.trim() : "";
3669
+ if (directMessage) return directMessage;
3670
+ const errors = Array.isArray(record.errors) ? record.errors : [];
3671
+ const nestedMessages = errors
3672
+ .map((entry) => {
3673
+ if (!entry || typeof entry !== "object") return "";
3674
+ const message = (entry as { message?: unknown }).message;
3675
+ return typeof message === "string" ? message.trim() : "";
3676
+ })
3677
+ .filter(Boolean);
3678
+ if (nestedMessages.length) return nestedMessages.join("; ");
3679
+ try {
3680
+ return JSON.stringify(error);
3681
+ } catch {
3682
+ return fallback;
3683
+ }
3684
+ }