@anthusai/papyrus 1.0.0-next.62 → 1.0.0-next.63

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.
@@ -893,9 +893,11 @@ const schema = a.schema({
893
893
  createdAt: a.datetime().required(),
894
894
  updatedAt: a.datetime().required(),
895
895
  newsroomFeedKey: a.string(),
896
+ itemLineageId: a.id(),
896
897
  })
897
898
  .secondaryIndexes((index) => [
898
899
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
900
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
899
901
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
900
902
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
901
903
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1632,6 +1634,7 @@ const schema = a.schema({
1632
1634
  type: a.string().required(),
1633
1635
  status: a.string().required(),
1634
1636
  typeStatus: a.string().required(),
1637
+ stage: a.string(),
1635
1638
  slug: a.string().required(),
1636
1639
  shortSlug: a.string(),
1637
1640
  section: a.string(),
@@ -1663,6 +1666,7 @@ const schema = a.schema({
1663
1666
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1664
1667
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1665
1668
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1669
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1666
1670
  ])
1667
1671
  .authorization((allow) => [
1668
1672
  allow.groups(contentWriteGroups),
@@ -970,9 +970,11 @@ const schema = a.schema({
970
970
  createdAt: a.datetime().required(),
971
971
  updatedAt: a.datetime().required(),
972
972
  newsroomFeedKey: a.string(),
973
+ itemLineageId: a.id(),
973
974
  })
974
975
  .secondaryIndexes((index) => [
975
976
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
977
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
976
978
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
977
979
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
978
980
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1733,6 +1735,7 @@ const schema = a.schema({
1733
1735
  type: a.string().required(),
1734
1736
  status: a.string().required(),
1735
1737
  typeStatus: a.string().required(),
1738
+ stage: a.string(),
1736
1739
  slug: a.string().required(),
1737
1740
  shortSlug: a.string(),
1738
1741
  section: a.string(),
@@ -1764,6 +1767,7 @@ const schema = a.schema({
1764
1767
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1765
1768
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1766
1769
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1770
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1767
1771
  ])
1768
1772
  .authorization((allow) => [
1769
1773
  allow.groups(contentWriteGroups),
@@ -799,9 +799,11 @@ const baseDefinitions = {
799
799
  createdAt: a.datetime().required(),
800
800
  updatedAt: a.datetime().required(),
801
801
  newsroomFeedKey: a.string(),
802
+ itemLineageId: a.id(),
802
803
  })
803
804
  .secondaryIndexes((index) => [
804
805
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
806
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
805
807
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
806
808
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
807
809
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1511,6 +1513,7 @@ const baseDefinitions = {
1511
1513
  changeReason: a.string(),
1512
1514
  contentHash: a.string(),
1513
1515
  type: a.string().required(),
1516
+ stage: a.string(),
1514
1517
  status: a.string().required(),
1515
1518
  typeStatus: a.string().required(),
1516
1519
  slug: a.string().required(),
@@ -1544,6 +1547,7 @@ const baseDefinitions = {
1544
1547
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1545
1548
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1546
1549
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1550
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1547
1551
  ])
1548
1552
  .authorization((allow) => [
1549
1553
  allow.groups(contentWriteGroups),
@@ -870,9 +870,11 @@ const baseDefinitions = {
870
870
  createdAt: a.datetime().required(),
871
871
  updatedAt: a.datetime().required(),
872
872
  newsroomFeedKey: a.string(),
873
+ itemLineageId: a.id(),
873
874
  })
874
875
  .secondaryIndexes((index) => [
875
876
  index("newsroomFeedKey").sortKeys(["createdAt"]).queryField("listAssignmentsByNewsroomFeedAndCreatedAt"),
877
+ index("itemLineageId").sortKeys(["createdAt"]).queryField("listAssignmentsByItemLineageAndCreatedAt"),
876
878
  index("queueStatusKey").sortKeys(["priority", "createdAt"]).queryField("listAssignmentsByQueueStatusAndPriority"),
877
879
  index("assignmentTypeKey").sortKeys(["status", "createdAt"]).queryField("listAssignmentsByTypeStatusAndCreatedAt"),
878
880
  index("status").sortKeys(["updatedAt"]).queryField("listAssignmentsByStatusAndUpdatedAt"),
@@ -1606,6 +1608,7 @@ const baseDefinitions = {
1606
1608
  changeReason: a.string(),
1607
1609
  contentHash: a.string(),
1608
1610
  type: a.string().required(),
1611
+ stage: a.string(),
1609
1612
  status: a.string().required(),
1610
1613
  typeStatus: a.string().required(),
1611
1614
  slug: a.string().required(),
@@ -1639,6 +1642,7 @@ const baseDefinitions = {
1639
1642
  index("typeStatus").sortKeys(["publishedAt"]).queryField("listItemsByTypeStatusAndPublishedAt"),
1640
1643
  index("sectionStatus").sortKeys(["publishedAt"]).queryField("listItemsBySectionStatusAndPublishedAt"),
1641
1644
  index("versionState").sortKeys(["updatedAt"]).queryField("listItemsByVersionStateAndUpdatedAt"),
1645
+ index("stage").sortKeys(["updatedAt"]).queryField("listItemsByStageAndUpdatedAt"),
1642
1646
  ])
1643
1647
  .authorization((allow) => [
1644
1648
  allow.groups(contentWriteGroups),
@@ -39,6 +39,7 @@ import type {
39
39
  UserDirectoryEntry,
40
40
  } from "../lib/category-repository";
41
41
  import { relationTypeKey } from "../lib/semantic-graph";
42
+ import { CONTENT_ITEM_STAGES, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
42
43
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
43
44
  import { configureAmplifyClient } from "./amplify-client-provider";
44
45
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
@@ -253,7 +254,7 @@ const UPDATE_MESSAGE_MUTATION = `
253
254
  const NEWSROOM_ASSIGNMENT_FEED_QUERY = `
254
255
  query ListAssignmentsByNewsroomFeedAndCreatedAt($newsroomFeedKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String, $filter: ModelAssignmentFilterInput) {
255
256
  listAssignmentsByNewsroomFeedAndCreatedAt(newsroomFeedKey: $newsroomFeedKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken, filter: $filter) {
256
- 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 }
257
+ 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 itemLineageId }
257
258
  nextToken
258
259
  }
259
260
  }
@@ -2157,6 +2158,41 @@ export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPage
2157
2158
  });
2158
2159
  }
2159
2160
 
2161
+ const CONTENT_ITEMS_BY_STAGE_QUERY = `
2162
+ query ListItemsByStageAndUpdatedAt($stage: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
2163
+ listItemsByStageAndUpdatedAt(stage: $stage, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
2164
+ items { id lineageId versionNumber stage type status title headline slug section updatedAt }
2165
+ nextToken
2166
+ }
2167
+ }
2168
+ `;
2169
+
2170
+ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2171
+ configureAmplifyClient();
2172
+ const client = generateClient<Schema>() as unknown as {
2173
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2174
+ };
2175
+ const field = "listItemsByStageAndUpdatedAt";
2176
+ const versionsPerStage = await Promise.all(CONTENT_ITEM_STAGES.map(async (stage) => {
2177
+ const versions: ContentItemVersionRecord[] = [];
2178
+ let nextToken: string | null = null;
2179
+ do {
2180
+ const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
2181
+ query: CONTENT_ITEMS_BY_STAGE_QUERY,
2182
+ variables: { stage, sortDirection: "DESC", limit: NEWSROOM_PAGE_LIMIT, nextToken },
2183
+ authMode: USER_POOL_AUTH_MODE,
2184
+ });
2185
+ assertNoGraphQLErrors(response.errors);
2186
+ const connection = response.data?.[field];
2187
+ if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
2188
+ versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
2189
+ nextToken = connection.nextToken ?? null;
2190
+ } while (nextToken);
2191
+ return versions;
2192
+ }));
2193
+ return versionsPerStage.flat();
2194
+ }
2195
+
2160
2196
  export async function loadModelPayloadsForOwner(
2161
2197
  ownerKind: string,
2162
2198
  ownerId: string,
@@ -6,10 +6,9 @@ import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
8
  import { canReadNewsroomBoard, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
9
- import { resolveArticlesBackend } from "../lib/newsroom-articles";
10
- import { SITE_BRAND } from "../lib/site-brand";
9
+ import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
11
10
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
12
- import { loadNewsroomAssignmentPage, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
11
+ import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
13
12
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
14
13
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
15
14
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -45,12 +44,12 @@ export function NewsroomBoardView() {
45
44
  if (!allowed) { setLoading(false); return; }
46
45
  setLoading(true); busy.current = true;
47
46
  Promise.all([
48
- resolveArticlesBackend(false).listRows(),
47
+ loadNewsroomContentItemVersions(),
49
48
  loadNewsroomReferencePage({ limit: 50 }),
50
49
  loadNewsroomAssignmentPage({ limit: 50 }),
51
- ]).then(([articles, references, assignments]) => {
50
+ ]).then(([contentItemVersions, references, assignments]) => {
52
51
  if (generation.current !== request) return;
53
- setIssues(projectNewsroomBoard({ articles, references: references.items, assignments: assignments.items }));
52
+ setIssues(projectNewsroomBoard({ contentItemVersions, references: references.items, assignments: assignments.items }));
54
53
  setTokens({ reference: references.nextToken, assignment: assignments.nextToken });
55
54
  }).catch((reason: unknown) => {
56
55
  if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
@@ -81,7 +80,7 @@ export function NewsroomBoardView() {
81
80
  <IssueTypeFilter config={config} value={filter} onChange={value => { setFilter(value); setSelected(null); }} name="Record type" />
82
81
  <div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button><button type="button" disabled={loading} onClick={() => setRevision(current => current + 1)}>Refresh records</button></div>
83
82
  </div>
84
- <p className="organizer-board-note">Native CMS records and statuses. Open a record to use its existing editor or workflow controls. Story linking is not configured.</p>
83
+ <p className="organizer-board-note">{`Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open a record to use its existing editor or workflow controls.`}</p>
85
84
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
86
85
  <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded. References and assignments load in pages.`}</p>
87
86
  <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
@@ -91,7 +90,7 @@ export function NewsroomBoardView() {
91
90
  selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel="Newsroom records"
92
91
  emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
93
92
  {issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
94
- <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type} workspace</Link>
93
+ <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>
95
94
  </IssueDetailPanel>}
96
95
  </div>
97
96
  <div className="organizer-board-pages">{tokens.reference && <button disabled={loading} type="button" onClick={() => void loadMore("reference")}>Load more references</button>}{tokens.assignment && <button disabled={loading} type="button" onClick={() => void loadMore("assignment")}>Load more assignments</button>}</div>
@@ -4,7 +4,7 @@ import {
4
4
  type PrimingProofReport,
5
5
  } from "../lib/priming-proof-report";
6
6
 
7
- function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
7
+ function ProofMethod({ heldOutSize, teachingSource }: { heldOutSize: number | null; teachingSource: string | null }) {
8
8
  return (
9
9
  <section aria-labelledby="priming-proof-method" className="space-y-2 text-sm leading-relaxed">
10
10
  <h2 id="priming-proof-method" className="text-base font-semibold uppercase tracking-wide">How the number is made</h2>
@@ -14,8 +14,10 @@ function ProofMethod({ heldOutSize }: { heldOutSize: number | null }) {
14
14
  for teaching. A classifier that always gives the same answer agrees on half of it.
15
15
  </p>
16
16
  <p>
17
- Editors teach the classifier by pasting a source URL, reviewing what Papyrus extracted, and choosing publish or
18
- reject. Each point below starts a fresh classifier that sees only the first N of those labels, then decides
17
+ Editors teach the classifier with labels: their other reviews of candidate sources, and sources they paste,
18
+ check after Papyrus extracts them, and mark publish or reject.
19
+ {teachingSource === null ? "" : ` This run taught from ${teachingSource}.`} Each point below starts a fresh
20
+ classifier that sees only the first N of those labels, then decides
19
21
  every held-out source once. Nothing else changes between points: the same decision model, the publication&apos;s
20
22
  mission and policies as the only rubric, and no automatic rubric rewriting.
21
23
  </p>
@@ -31,7 +33,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
31
33
  <h1 className="text-3xl font-bold">Does teaching the classifier work?</h1>
32
34
  <p className="text-muted-foreground">No approved proof run has been published for this publication yet.</p>
33
35
  </header>
34
- <ProofMethod heldOutSize={null} />
36
+ <ProofMethod heldOutSize={null} teachingSource={null} />
35
37
  </main>
36
38
  );
37
39
  }
@@ -82,7 +84,7 @@ export function PrimingProofPage({ report }: { report: PrimingProofReport | null
82
84
  </table>
83
85
  </div>
84
86
  </section>
85
- <ProofMethod heldOutSize={report.heldOut.size} />
87
+ <ProofMethod heldOutSize={report.heldOut.size} teachingSource={report.priming.source} />
86
88
  <p className="break-all text-xs text-muted-foreground">
87
89
  Held-out fingerprint {report.heldOut.fingerprint}
88
90
  {report.priming.fingerprint ? ` · labels fingerprint ${report.priming.fingerprint}` : ""}
@@ -15042,6 +15042,9 @@ function buildUiCopywritingAssignment({ actorLabel, assignment, decision, messag
15042
15042
  message: MessageRecord;
15043
15043
  now: string;
15044
15044
  }): AssignmentRecord {
15045
+ if (!assignment.itemLineageId) {
15046
+ throw new Error(`Assignment ${assignment.id} has no content item lineage; copywriting must continue the same content item.`);
15047
+ }
15045
15048
  const type = decision === "brief" ? "brief" : "article";
15046
15049
  const assignmentTypeKey = type === "brief" ? "copywriting.brief-draft" : "copywriting.article-draft";
15047
15050
  const section = assignment.sectionKey ?? assignment.sectionId ?? "unsectioned";
@@ -15094,6 +15097,7 @@ function buildUiCopywritingAssignment({ actorLabel, assignment, decision, messag
15094
15097
  createdAt: now,
15095
15098
  updatedAt: now,
15096
15099
  newsroomFeedKey: "assignment#open",
15100
+ itemLineageId: assignment.itemLineageId,
15097
15101
  metadata,
15098
15102
  };
15099
15103
  }
@@ -494,6 +494,7 @@ export type AssignmentRecord = {
494
494
  createdAt: string;
495
495
  updatedAt: string;
496
496
  newsroomFeedKey?: string | null;
497
+ itemLineageId?: string | null;
497
498
  metadata?: unknown;
498
499
  };
499
500
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * One content item, one lifecycle: the stages an Item lineage moves through.
3
+ * Mirrors CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py.
4
+ * See docs/content-item-lifecycle.md.
5
+ */
6
+ export const CONTENT_ITEM_STAGES = [
7
+ "idea",
8
+ "assignment",
9
+ "research",
10
+ "report",
11
+ "editor_select",
12
+ "copywriting",
13
+ "published",
14
+ ] as const;
15
+
16
+ export type ContentItemStage = (typeof CONTENT_ITEM_STAGES)[number];
17
+
18
+ const CONTENT_ITEM_STAGE_LABELS: Record<ContentItemStage, string> = {
19
+ idea: "Idea",
20
+ assignment: "Assignment",
21
+ research: "Research",
22
+ report: "Report",
23
+ editor_select: "Editor select",
24
+ copywriting: "Copywriting",
25
+ published: "Published",
26
+ };
27
+
28
+ export function contentItemStageLabel(stage: string): string {
29
+ return (CONTENT_ITEM_STAGE_LABELS as Record<string, string>)[stage] ?? stage;
30
+ }
31
+
32
+ export type ContentItemVersionRecord = {
33
+ id: string;
34
+ lineageId: string;
35
+ versionNumber: number;
36
+ stage?: string | null;
37
+ type: string;
38
+ status: string;
39
+ title?: string | null;
40
+ headline?: string | null;
41
+ slug: string;
42
+ section?: string | null;
43
+ updatedAt?: string | null;
44
+ };
45
+
46
+ export function latestVersionPerContentItem(
47
+ versions: readonly ContentItemVersionRecord[],
48
+ ): ContentItemVersionRecord[] {
49
+ const latestByLineage = new Map<string, ContentItemVersionRecord>();
50
+ for (const version of versions) {
51
+ const current = latestByLineage.get(version.lineageId);
52
+ if (!current || version.versionNumber > current.versionNumber) latestByLineage.set(version.lineageId, version);
53
+ }
54
+ return [...latestByLineage.values()];
55
+ }
@@ -1,38 +1,67 @@
1
1
  import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
2
2
  import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
3
- import type { NewsroomArticleRow } from "./newsroom-articles";
3
+ import {
4
+ CONTENT_ITEM_STAGES,
5
+ contentItemStageLabel,
6
+ latestVersionPerContentItem,
7
+ type ContentItemVersionRecord,
8
+ } from "./content-item-lifecycle";
9
+ import { getContentItemName } from "./site-brand";
4
10
 
5
- export type NativeBoardKind = "article" | "reference" | "assignment";
11
+ export type NativeBoardKind = "contentItem" | "reference" | "assignment";
6
12
  export interface NativeBoardIssue extends KanbanIssue {
7
- type: NativeBoardKind;
13
+ kind: NativeBoardKind;
8
14
  nativeId: string;
9
15
  nativeStatus: string;
10
16
  href: string;
11
17
  }
12
18
 
13
- /** Native records keep their own identity, status and mutation authority. No Story linkage is inferred. */
19
+ export function contentItemBoardType(): string {
20
+ return getContentItemName().singular.toLowerCase();
21
+ }
22
+
23
+ function boardTypeForKind(kind: NativeBoardKind): string {
24
+ return kind === "contentItem" ? contentItemBoardType() : kind;
25
+ }
26
+
27
+ /**
28
+ * One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
29
+ * are counted on that item's card rather than shown as separate cards. References and other work keep their own
30
+ * identity and native status.
31
+ */
14
32
  export function projectNewsroomBoard(records: {
15
- articles?: readonly NewsroomArticleRow[];
33
+ contentItemVersions?: readonly ContentItemVersionRecord[];
16
34
  references?: readonly ReferenceRecord[];
17
35
  assignments?: readonly AssignmentRecord[];
18
36
  }): NativeBoardIssue[] {
37
+ const assignmentCountByContentItem = new Map<string, number>();
38
+ for (const assignment of records.assignments ?? []) {
39
+ if (assignment.itemLineageId) {
40
+ assignmentCountByContentItem.set(assignment.itemLineageId, (assignmentCountByContentItem.get(assignment.itemLineageId) ?? 0) + 1);
41
+ }
42
+ }
43
+ const contentItemType = contentItemBoardType();
19
44
  return [
20
- ...(records.articles ?? []).map(row => ({
21
- id: `article:${row.id}`, nativeId: row.id, type: "article" as const,
22
- title: row.title, nativeStatus: row.status, status: `article/${row.status}`,
23
- priority: 2, description: row.pending ? "Published copy has pending draft changes." : undefined,
24
- updated_at: row.updatedAt, href: `/newsroom/articles/${encodeURIComponent(row.id)}`,
25
- })),
45
+ ...latestVersionPerContentItem(records.contentItemVersions ?? []).map(version => {
46
+ const stage = version.stage ?? "idea";
47
+ const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
48
+ return {
49
+ id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
50
+ title: version.headline || version.title || version.slug, nativeStatus: stage, status: `${contentItemType}/${stage}`,
51
+ priority: 2, description: workCount ? `${workCount} assignment${workCount === 1 ? "" : "s"} on this ${contentItemType}.` : undefined,
52
+ updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
53
+ };
54
+ }),
26
55
  ...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
27
- id: `reference:${row.id}`, nativeId: row.id, type: "reference" as const,
56
+ id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
28
57
  title: row.title || row.externalItemId || row.id,
29
58
  nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
30
59
  priority: 2, description: row.curationStatusReason || undefined,
31
60
  updated_at: row.updatedAt || row.importedAt || undefined,
32
61
  href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
33
62
  })),
34
- ...(records.assignments ?? []).map(row => ({
35
- id: `assignment:${row.id}`, nativeId: row.id, type: "assignment" as const,
63
+ ...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
64
+ id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
36
65
  title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
37
66
  priority: row.priority ?? 2, assignee: row.assigneeId || undefined,
38
67
  description: row.summary || row.brief || undefined,
@@ -42,16 +71,26 @@ export function projectNewsroomBoard(records: {
42
71
  ];
43
72
  }
44
73
 
45
- /** Unknown native statuses remain visible instead of being translated into invented stages. */
74
+ /** Content items always show every lifecycle stage in order; unknown native statuses of other records stay visible. */
46
75
  export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
47
- const keys = [...new Set(issues.map(issue => issue.status))];
76
+ const contentItemType = contentItemBoardType();
77
+ const kinds: NativeBoardKind[] = ["contentItem", "reference", "assignment"];
78
+ const contentItemStatuses = [
79
+ ...CONTENT_ITEM_STAGES,
80
+ ...new Set(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus)),
81
+ ].filter((stage, index, stages) => stages.indexOf(stage) === index)
82
+ .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage), category: "native" }));
83
+ const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
84
+ .map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
85
+ const statuses = [...contentItemStatuses, ...otherStatuses];
48
86
  return {
49
- hierarchy: [], types: ["article", "reference", "assignment"],
50
- statuses: keys.map(key => ({ key, name: key.replace("/", " · "), category: "native" })),
87
+ hierarchy: [], types: kinds.map(boardTypeForKind),
88
+ statuses,
51
89
  categories: [{ name: "native" }], priorities: {}, type_colors: {},
52
- workflows: Object.fromEntries(["article", "reference", "assignment"].map(kind => [kind,
53
- Object.fromEntries(keys.filter(key => key.startsWith(`${kind}/`)).map(key => [key, []])),
54
- ])),
90
+ workflows: Object.fromEntries(kinds.map(kind => {
91
+ const type = boardTypeForKind(kind);
92
+ return [type, Object.fromEntries(statuses.filter(status => status.key.startsWith(`${type}/`)).map(status => [status.key, []]))];
93
+ })),
55
94
  };
56
95
  }
57
96
 
@@ -1,4 +1,5 @@
1
1
  import { newsroomHref, toPublicNewsroomPath } from "./newsroom-base-path";
2
+ import { getContentItemName } from "./site-brand";
2
3
 
3
4
  export type NewsroomNavTabId =
4
5
  | "overview"
@@ -23,7 +24,7 @@ export type NewsroomNavItem = {
23
24
 
24
25
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
25
26
  { id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref(), mobilePrimary: true },
26
- { id: "articles", label: "Articles", detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
27
+ { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles"), mobilePrimary: true },
27
28
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments"), mobilePrimary: true },
28
29
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references"), mobilePrimary: true },
29
30
  { id: "messages", label: "Messages", detail: "Forum", href: newsroomHref("messages") },
@@ -1,3 +1,6 @@
1
+ import { getContentItemName } from "./site-brand";
2
+
3
+ const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
1
4
  export type OrganizerDestination = {
2
5
  key: string; label: string; description: string; path: string;
3
6
  access?: "admin"; primary?: boolean; unavailable?: string;
@@ -5,8 +8,8 @@ export type OrganizerDestination = {
5
8
  export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
6
9
  { title: "Editorial work", items: [
7
10
  { key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
8
- { key: "board", label: "Board", description: "Follow articles, sources and assignments across their stages.", path: "/newsroom/work", primary: true },
9
- { key: "articles", label: "Articles", description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
11
+ { key: "board", label: "Board", description: `Follow ${contentItemPluralLowercase}, sources and assignments across their stages.`, path: "/newsroom/work", primary: true },
12
+ { key: "articles", label: getContentItemName().plural, description: "Write, revise and prepare copy for publication.", path: "/newsroom/articles" },
10
13
  { key: "assignments", label: "Assignments", description: "Claim work and follow research and reporting progress.", path: "/newsroom/assignments" },
11
14
  { key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
12
15
  { key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
@@ -41,6 +41,7 @@ export const primingProofReportSchema = z.object({
41
41
  acceptedExamples: z.number().int().min(0),
42
42
  excludedBecauseInHeldOut: z.number().int().min(0),
43
43
  fingerprint: z.string().nullable(),
44
+ source: z.string().min(1),
44
45
  order: z.string().min(1),
45
46
  }),
46
47
  conditions: z.object({
package/lib/site-brand.ts CHANGED
@@ -64,6 +64,19 @@ export type SiteBrandVideo = {
64
64
  rhythm?: Record<string, unknown>;
65
65
  };
66
66
 
67
+ /**
68
+ * The publication's name for its one content item. A content item keeps one
69
+ * identity and one lifecycle from idea through research, reporting,
70
+ * copywriting and publication; only the word editors see changes per
71
+ * publication (for example Story, Article or Post).
72
+ */
73
+ export type ContentItemName = {
74
+ singular: string;
75
+ plural: string;
76
+ };
77
+
78
+ export const DEFAULT_CONTENT_ITEM_NAME: ContentItemName = { singular: "Story", plural: "Stories" };
79
+
67
80
  export type SiteBrand = {
68
81
  id: SiteBrandId;
69
82
  appTitle: string;
@@ -77,6 +90,8 @@ export type SiteBrand = {
77
90
  mastheadTaglineLines?: SiteBrandTaglineLine[];
78
91
  backToHomeLabel: string;
79
92
  articleTitleSuffix: string;
93
+ /** Defaults to Story / Stories, the newsroom term. */
94
+ contentItemName?: ContentItemName;
80
95
  placeholderByline: string;
81
96
  defaultPresentation: EditionPresentationFormat;
82
97
  forcedPresentation?: EditionPresentationFormat;
@@ -204,6 +219,10 @@ export function getSiteBrand(id: SiteBrandId = resolveSiteBrandId()): SiteBrand
204
219
 
205
220
  export const SITE_BRAND = getSiteBrand();
206
221
 
222
+ export function getContentItemName(brand: SiteBrand = SITE_BRAND): ContentItemName {
223
+ return brand.contentItemName ?? DEFAULT_CONTENT_ITEM_NAME;
224
+ }
225
+
207
226
  /** Root-route config for a brand, defaulting to `{ kind: "reader" }`. */
208
227
  export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
209
228
  return brand.rootRoute ?? { kind: "reader" };
package/package.json CHANGED
@@ -155,6 +155,7 @@
155
155
  "./lib/console-web-context": "./lib/console-web-context.ts",
156
156
  "./lib/content-actions-client": "./lib/content-actions-client.ts",
157
157
  "./lib/content-actions-demo": "./lib/content-actions-demo.ts",
158
+ "./lib/content-item-lifecycle": "./lib/content-item-lifecycle.ts",
158
159
  "./lib/content-load-timing": "./lib/content-load-timing.ts",
159
160
  "./lib/content-repository": "./lib/content-repository.ts",
160
161
  "./lib/content-source-context": "./lib/content-source-context.ts",
@@ -331,7 +332,7 @@
331
332
  "optional": true
332
333
  }
333
334
  },
334
- "version": "1.0.0-next.62",
335
+ "version": "1.0.0-next.63",
335
336
  "dependencies": {
336
337
  "@aws-amplify/adapter-nextjs": "^1.7.3",
337
338
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.0-next.62",
2
+ "version": "1.0.0-next.63",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",