@anthusai/papyrus 5.43.0-next.2 → 5.43.0-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +64 -55
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +60 -2
- package/components/newsroom-references-view.tsx +301 -138
- package/components/notification-cards.tsx +369 -0
- package/components/notification-center.tsx +324 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +503 -0
- package/components/reference-review-card.tsx +110 -141
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +180 -0
- package/components/topic-steering-workspace.tsx +44 -9
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +134 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +55 -5
- package/lib/review-drafts.ts +252 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +64 -24
- package/lib/site-brand.ts +2 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +29 -1
- package/routes.manifest.json +1 -1
|
@@ -1,240 +1,254 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
type AnswerInput, type CyclotronEntry, type ReviewRequest,
|
|
12
|
-
} from "../lib/review-requests";
|
|
13
|
-
import type { ReferenceRecord } from "../lib/category-repository";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
|
|
5
|
+
import type { EmailDecisionResult, MonitoringEmailDetails, MonitoringEmailSummary } from "../lib/monitoring-overview";
|
|
6
|
+
import { proposalReceiptMessage, stateLabel, type ProposalReceipt } from "../lib/proposals-admin";
|
|
7
|
+
import type { DraftLoad, ReviewLoad } from "../lib/review-client";
|
|
8
|
+
import { acceptPath, batchAnswer, draftView, rejectPath, undecidedDraftIds, withDecision, draftDecisionUpdate, type DraftChoice, type DraftView } from "../lib/review-drafts";
|
|
9
|
+
import { previewsBySourceAddress, recentDecisions } from "../lib/notification-feed";
|
|
10
|
+
import { answerUpdate, cyclotronAutoDecided, draftIdsOf, isDraftReview, sectionReviews, type AnswerInput, type DraftDecision, type ReviewRequest } from "../lib/review-requests";
|
|
14
11
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
15
|
-
import {
|
|
12
|
+
import { learnFreshness } from "../lib/reference-feed-ranking";
|
|
13
|
+
import { learnConfidenceThreshold, shapingThisWeek } from "../lib/needs-me-ranking";
|
|
14
|
+
import { buildNeedsMeItems } from "../lib/needs-me-items";
|
|
15
|
+
import { captureStatusesByLineage } from "../lib/reference-capture";
|
|
16
|
+
import { normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
17
|
+
import type { ReferencePreview } from "../lib/reference-preview";
|
|
18
|
+
import { referenceLineageId } from "../lib/newsroom-references";
|
|
19
|
+
import { loadNotificationSnapshot, saveNotificationSnapshot } from "../lib/reference-feed-cache";
|
|
20
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
21
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
22
|
+
import { FIXTURE_DRAFTS, FIXTURE_EMAIL_DETAILS, FIXTURE_HELD_EMAILS, FIXTURE_REVIEW_REQUESTS } from "../lib/review-fixture";
|
|
23
|
+
import { NeedsMeView, type NeedsMeDecision } from "./needs-me-feed";
|
|
24
|
+
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
25
|
+
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
26
|
+
import type { ReviewChoice } from "./reference-review-card";
|
|
16
27
|
|
|
17
28
|
export type NotificationLoadState = "loading" | "ready" | "forbidden" | "unavailable";
|
|
29
|
+
export type DraftsLoadState = "loading" | "ready" | "failed";
|
|
18
30
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
requests: ReviewRequest[];
|
|
22
|
-
readIds: ReadonlySet<string>;
|
|
23
|
-
held: CyclotronEntry[];
|
|
24
|
-
autoDecided: CyclotronEntry[];
|
|
25
|
-
storyTitles?: Readonly<Record<string, string>>;
|
|
26
|
-
deletedStories?: ReadonlySet<string>;
|
|
27
|
-
demo?: boolean;
|
|
28
|
-
busyId?: string | null;
|
|
29
|
-
error?: string | null;
|
|
30
|
-
onAnswer?: (request: ReviewRequest, input: AnswerInput) => void;
|
|
31
|
-
onMarkRead?: (requestId: string) => void;
|
|
32
|
-
onMarkAllRead?: () => void;
|
|
33
|
-
};
|
|
31
|
+
/** What a real-data preview shows instead of the invented sample (scripts/export-notification-center-fixture.py). */
|
|
32
|
+
export type NotificationFixture = { requests: ReviewRequest[]; drafts: Parameters<typeof draftView>[0][]; emails?: MonitoringEmailSummary[] };
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}) {
|
|
39
|
-
const [note, setNote] = useState("");
|
|
40
|
-
const href = subjectHref(request.subject, demo);
|
|
41
|
-
const open = request.state === "open";
|
|
42
|
-
const steering = steeringText(request);
|
|
43
|
-
const act = (input: AnswerInput) => onAnswer?.(request, { ...input, feedback: input.feedback ?? (note.trim() || null) });
|
|
44
|
-
return (
|
|
45
|
-
<li className="rounded-lg border p-3 space-y-2" data-review-request={request.id} data-review-mode={request.mode} data-review-state={request.state} data-review-unread={unread || undefined}>
|
|
46
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
47
|
-
<Badge variant="outline"><span aria-hidden="true">{modeMark(request.mode)} </span>{modeLabel(request.mode)}</Badge>
|
|
48
|
-
{unread ? <Badge variant="default"><span aria-hidden="true">● </span>New</Badge> : <span className="text-xs text-muted-foreground">Read</span>}
|
|
49
|
-
<strong className="text-sm">{request.reason}</strong>
|
|
50
|
-
</div>
|
|
51
|
-
<p className="text-xs text-muted-foreground">
|
|
52
|
-
Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
|
|
53
|
-
{" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles, deleted)}</Link> : <span>{subjectLabel(request.subject, titles, deleted)}</span>}
|
|
54
|
-
</p>
|
|
55
|
-
{request.ask ? <p className="text-sm">{request.ask}</p> : null}
|
|
56
|
-
{request.links.length ? (
|
|
57
|
-
<ul className="text-xs space-y-0.5" aria-label="Items in this request">
|
|
58
|
-
{request.links.slice(0, 10).map((link, index) => {
|
|
59
|
-
const target = link.url ?? (link.type ? subjectHref({ type: link.type, id: link.id ?? "" }, demo) : null);
|
|
60
|
-
return <li key={`${link.id ?? link.url}-${index}`}>{target ? <Link href={target}>{link.label ?? link.id ?? link.url}</Link> : (link.label ?? link.id)}</li>;
|
|
61
|
-
})}
|
|
62
|
-
{request.links.length > 10 ? <li>and {request.links.length - 10} more</li> : null}
|
|
63
|
-
</ul>
|
|
64
|
-
) : null}
|
|
65
|
-
<p className="text-sm" data-review-next>{nextText(request)}</p>
|
|
66
|
-
{steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
|
|
67
|
-
{open ? (
|
|
68
|
-
<div className="space-y-2" data-review-controls>
|
|
69
|
-
<label className="block text-xs text-muted-foreground">
|
|
70
|
-
Steering note (optional)
|
|
71
|
-
<textarea
|
|
72
|
-
className="mt-1 block w-full rounded-md border bg-background p-2 text-sm"
|
|
73
|
-
disabled={busy}
|
|
74
|
-
onChange={(event) => setNote(event.target.value)}
|
|
75
|
-
placeholder={request.mode === "flag" ? "Tell the step what to do differently" : "What should change, or what did you want instead?"}
|
|
76
|
-
rows={2}
|
|
77
|
-
value={note}
|
|
78
|
-
/>
|
|
79
|
-
</label>
|
|
80
|
-
<div className="flex flex-wrap gap-2">
|
|
81
|
-
{request.mode === "flag" ? (
|
|
82
|
-
<Button disabled={busy} onClick={() => act({ decision: "acknowledge" })} size="sm" variant="outline">Acknowledge</Button>
|
|
83
|
-
) : (
|
|
84
|
-
<>
|
|
85
|
-
<Button disabled={busy} onClick={() => act({ decision: "approve" })} size="sm" variant="outline">Approve</Button>
|
|
86
|
-
<Button disabled={busy} onClick={() => act({ decision: "reject" })} size="sm" variant="outline">Reject</Button>
|
|
87
|
-
</>
|
|
88
|
-
)}
|
|
89
|
-
<Button disabled={busy || !note.trim()} onClick={() => act({ decision: "feedback", steer: "adjust" })} size="sm" variant="outline">Steer with this note</Button>
|
|
90
|
-
<Button disabled={busy} onClick={() => act({ decision: "feedback", steer: "not_this_kind" })} size="sm" variant="outline">Not this kind of thing</Button>
|
|
91
|
-
{unread ? <Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button> : null}
|
|
92
|
-
</div>
|
|
93
|
-
</div>
|
|
94
|
-
) : unread ? (
|
|
95
|
-
<Button disabled={busy} onClick={() => onMarkRead?.(request.id)} size="sm" variant="ghost">Mark read</Button>
|
|
96
|
-
) : null}
|
|
97
|
-
</li>
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function CyclotronLine({ entry, held }: { entry: CyclotronEntry; held: boolean }) {
|
|
102
|
-
const confidence = entry.confidence === null ? "confidence not recorded" : `confidence ${Math.round(entry.confidence * 100)}%`;
|
|
103
|
-
return (
|
|
104
|
-
<li className="rounded-lg border p-3 space-y-1" data-cyclotron-entry={entry.lineageId} data-cyclotron-held={held || undefined}>
|
|
105
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
106
|
-
<Badge variant="outline"><span aria-hidden="true">{held ? "◔" : "✓"} </span>{held ? "Held for review" : "Decided by the system"}</Badge>
|
|
107
|
-
<strong className="text-sm">{entry.title}</strong>
|
|
108
|
-
</div>
|
|
109
|
-
<p className="text-xs text-muted-foreground">
|
|
110
|
-
Classified as {entry.label} · {confidence}{entry.decidedAt ? ` · ${formatWhen(entry.decidedAt)}` : ""}
|
|
111
|
-
{entry.reviewedByPerson ? " · reviewed by a person" : ""}
|
|
112
|
-
</p>
|
|
113
|
-
{held ? <p className="text-sm">{entry.reviewDetail ? `${entry.reviewDetail}. ` : ""}Held for review: it stays pending until someone decides.</p> : null}
|
|
114
|
-
<Link className="text-sm" href={entry.href}>{held ? "Review it in References" : "Open it in References to correct it"}</Link>
|
|
115
|
-
</li>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
34
|
+
const NO_VISIT: ReadonlySet<string> = new Set();
|
|
35
|
+
const NO_PREVIEWS: ReadonlyMap<string, ReferencePreview> = new Map();
|
|
36
|
+
const NO_ATTACHMENTS: ReferenceAttachmentRecord[] = [];
|
|
118
37
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
|
|
38
|
+
/** Everything the container reads and writes. The live one talks to the site's data API; tests pass a fake. */
|
|
39
|
+
export type NotificationApi = {
|
|
40
|
+
loadReviews: () => Promise<ReviewLoad>;
|
|
41
|
+
loadDrafts: (ids: string[]) => Promise<DraftLoad>;
|
|
42
|
+
loadStoryTitles: (ids: string[]) => Promise<{ titles: Record<string, string>; deleted: string[]; excerpts?: Record<string, string> }>;
|
|
43
|
+
answerReviewRequest: (request: ReviewRequest, input: AnswerInput, by: string) => Promise<ReviewRequest>;
|
|
44
|
+
saveDraftDecision: (request: ReviewRequest, draftId: string, decision: DraftDecision) => Promise<ReviewRequest>;
|
|
45
|
+
markReviewRead: (userKey: string, requestId: string) => Promise<void>;
|
|
46
|
+
transitionProposalPath: (proposalId: string, path: readonly string[]) => Promise<ProposalReceipt>;
|
|
47
|
+
/** Email submissions held for review (Monitoring's own read). Optional: a site without monitoring has none. */
|
|
48
|
+
loadHeldEmails?: () => Promise<MonitoringEmailSummary[]>;
|
|
49
|
+
loadEmail?: (receiptId: string) => Promise<MonitoringEmailDetails>;
|
|
50
|
+
decideEmail?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
51
|
+
};
|
|
127
52
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<div className="space-y-6" data-notification-center>
|
|
152
|
-
<header className="space-y-1">
|
|
153
|
-
<h1 className="text-lg font-semibold">Notification Center</h1>
|
|
154
|
-
<p className="text-sm" data-notification-summary>{summaryText(summary)}</p>
|
|
155
|
-
<p className="text-xs text-muted-foreground">
|
|
156
|
-
Look, or steer. Anything you ignore keeps going: a soft review with a deadline proceeds at it, one without stays open until handled, and flags never wait. Only a hard gate waits for you.
|
|
157
|
-
</p>
|
|
158
|
-
<p className="text-xs text-muted-foreground" data-notification-unread>
|
|
159
|
-
{unreadCount ? `${unreadCount} unread` : "Nothing unread"}
|
|
160
|
-
{unreadCount ? <Button className="ml-2" onClick={onMarkAllRead} size="sm" variant="ghost">Mark all read</Button> : null}
|
|
161
|
-
</p>
|
|
162
|
-
{error ? <p role="alert" className="text-sm" data-notification-error>{error}</p> : null}
|
|
163
|
-
</header>
|
|
53
|
+
const liveApi: NotificationApi = {
|
|
54
|
+
loadReviews: async () => (await import("../lib/review-client")).loadReviews(),
|
|
55
|
+
loadDrafts: async (ids) => (await import("../lib/review-client")).loadDrafts(ids),
|
|
56
|
+
loadStoryTitles: async (ids) => (await import("../lib/review-client")).loadStoryTitles(ids),
|
|
57
|
+
answerReviewRequest: async (request, input, by) => (await import("../lib/review-client")).answerReviewRequest(request, input, by),
|
|
58
|
+
saveDraftDecision: async (request, draftId, decision) => (await import("../lib/review-client")).saveDraftDecision(request, draftId, decision),
|
|
59
|
+
markReviewRead: async (userKey, requestId) => (await import("../lib/review-client")).markReviewRead(userKey, requestId),
|
|
60
|
+
transitionProposalPath: async (proposalId, path) => (await import("../lib/proposals-client")).transitionProposalPath(proposalId, path),
|
|
61
|
+
loadHeldEmails: async () => {
|
|
62
|
+
const { loadMonitoringPage } = await import("../lib/monitoring-client");
|
|
63
|
+
const rows: MonitoringEmailSummary[] = [];
|
|
64
|
+
let cursor: string | null = null;
|
|
65
|
+
do {
|
|
66
|
+
const page = await loadMonitoringPage("emailSubmissions", cursor, "publication", true);
|
|
67
|
+
if (!("rows" in page)) return rows;
|
|
68
|
+
rows.push(...(page.rows as MonitoringEmailSummary[]));
|
|
69
|
+
cursor = page.cursor;
|
|
70
|
+
} while (cursor);
|
|
71
|
+
return rows;
|
|
72
|
+
},
|
|
73
|
+
loadEmail: async (receiptId) => (await import("../lib/monitoring-client")).loadMonitoringEmailSubmission(receiptId),
|
|
74
|
+
decideEmail: async (receiptId, decision, reason) => (await import("../lib/monitoring-client")).decideMonitoringEmailSubmission(receiptId, decision, reason),
|
|
75
|
+
};
|
|
164
76
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<p className="text-xs text-muted-foreground">Read only. The Cyclotron was confident enough to decide these without you. To correct one, open it in References.</p>
|
|
185
|
-
{autoDecided.length ? <ul className="space-y-2">{autoDecided.map((item) => <CyclotronLine entry={item} held={false} key={item.lineageId} />)}</ul> : <p className="text-sm text-muted-foreground">No automatic decisions yet.</p>}
|
|
186
|
-
</div>
|
|
187
|
-
</details>
|
|
188
|
-
</div>
|
|
189
|
-
);
|
|
190
|
-
}
|
|
77
|
+
/** The demo and the real-data preview answer in memory with the same rules as the live data API; nothing leaves the page. */
|
|
78
|
+
const demoApi: NotificationApi = {
|
|
79
|
+
loadReviews: async () => ({ state: "unavailable" }),
|
|
80
|
+
loadDrafts: async () => ({ drafts: {}, missing: [], failed: [] }),
|
|
81
|
+
loadStoryTitles: async () => ({ titles: {}, deleted: [] }),
|
|
82
|
+
answerReviewRequest: async (request, input, by) => {
|
|
83
|
+
const update = answerUpdate(request, input, by, new Date());
|
|
84
|
+
return { ...request, state: "answered", answer: JSON.parse(String(update.answer)), links: update.links ? JSON.parse(String(update.links)) : request.links, history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
|
|
85
|
+
},
|
|
86
|
+
saveDraftDecision: async (request, draftId, decision) => {
|
|
87
|
+
const update = draftDecisionUpdate(request, draftId, decision);
|
|
88
|
+
return { ...request, links: JSON.parse(String(update.links)), history: JSON.parse(String(update.history)), updatedAt: String(update.updatedAt) };
|
|
89
|
+
},
|
|
90
|
+
markReviewRead: async () => undefined,
|
|
91
|
+
transitionProposalPath: async () => ({ status: "ok" }),
|
|
92
|
+
loadHeldEmails: async () => FIXTURE_HELD_EMAILS,
|
|
93
|
+
loadEmail: async (receiptId) => FIXTURE_EMAIL_DETAILS[receiptId] ?? { state: "unavailable" },
|
|
94
|
+
decideEmail: async (_receiptId, decision) => ({ ok: true, already: false, quarantine: { state: decision === "accept" ? "released" : "discarded", decision: null, failure: null } }),
|
|
95
|
+
};
|
|
191
96
|
|
|
192
97
|
type ContainerProps = {
|
|
193
98
|
references: ReferenceRecord[];
|
|
194
99
|
decisions: ReadonlyMap<string, RelevanceDecisionView>;
|
|
195
|
-
humanCuratedReferenceLineageIds
|
|
100
|
+
humanCuratedReferenceLineageIds?: ReadonlySet<string>;
|
|
101
|
+
previews?: ReadonlyMap<string, ReferencePreview>;
|
|
102
|
+
referencesLoading?: boolean;
|
|
103
|
+
predictionLoading?: boolean;
|
|
104
|
+
referenceDisabledReason?: string | null;
|
|
105
|
+
onRateReference?: (reference: ReferenceRecord, choice: ReviewChoice, reasonCode: ReferenceRejectionReasonCode | null, note?: string) => Promise<void> | void;
|
|
106
|
+
/** The Reference attachments (previews, captured text), for the cards' pictures and the capture checks. */
|
|
107
|
+
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
108
|
+
/** Saves a pasted or corrected capture. Without it the capture panel has no edit action. */
|
|
109
|
+
onCorrectCapture?: CorrectCaptureAction;
|
|
196
110
|
actorLabel: string;
|
|
197
111
|
demo: boolean;
|
|
112
|
+
/** A real-data preview (see scripts/export-notification-center-fixture.py); the invented sample otherwise. */
|
|
113
|
+
fixture?: NotificationFixture | null;
|
|
114
|
+
api?: NotificationApi;
|
|
198
115
|
};
|
|
199
116
|
|
|
200
117
|
const REFRESH_MILLISECONDS = 60_000;
|
|
201
118
|
|
|
202
|
-
|
|
119
|
+
/** Preview only: ?ncState=loading holds the skeleton, caught-up shows the empty state, sample swaps in the invented hard gate, flags, batches and held email, mixed adds the invented hard gate, flag and held email to the real data. */
|
|
120
|
+
function previewState(): "loading" | "caught-up" | "sample" | "mixed" | null {
|
|
121
|
+
if (typeof window === "undefined" || !isLocalPreviewEnabled()) return null;
|
|
122
|
+
const requested = new URLSearchParams(window.location.search).get("ncState");
|
|
123
|
+
return requested === "loading" || requested === "caught-up" || requested === "sample" || requested === "mixed" ? requested : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const EMAIL_REFUSALS: Record<string, string> = {
|
|
127
|
+
forbidden: "Your session cannot decide held email.",
|
|
128
|
+
bad_request: "That email could not be decided from here.",
|
|
129
|
+
already_decided: "Someone already decided this email. Refresh to see what they chose.",
|
|
130
|
+
not_quarantined: "This email is no longer held.",
|
|
131
|
+
expired: "This email expired and can no longer be accepted.",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function NotificationCenter({
|
|
135
|
+
references, decisions, humanCuratedReferenceLineageIds = NO_VISIT, previews = NO_PREVIEWS, referencesLoading = false, predictionLoading = false, referenceDisabledReason = null,
|
|
136
|
+
onRateReference, referenceAttachments = NO_ATTACHMENTS, onCorrectCapture, actorLabel, demo, fixture = null, api: liveOverride = liveApi,
|
|
137
|
+
}: ContainerProps) {
|
|
138
|
+
const api = demo ? demoApi : liveOverride;
|
|
203
139
|
const [loadState, setLoadState] = useState<NotificationLoadState>("loading");
|
|
140
|
+
const [stale, setStale] = useState(false);
|
|
204
141
|
const [requests, setRequests] = useState<ReviewRequest[]>([]);
|
|
205
|
-
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
|
206
142
|
const [userKey, setUserKey] = useState<string | null>(null);
|
|
207
|
-
const [busyId, setBusyId] = useState<string | null>(null);
|
|
208
143
|
const [error, setError] = useState<string | null>(null);
|
|
209
144
|
const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
|
|
145
|
+
const [storyExcerpts, setStoryExcerpts] = useState<Record<string, string>>({});
|
|
210
146
|
const [deletedStories, setDeletedStories] = useState<ReadonlySet<string>>(new Set());
|
|
147
|
+
const [drafts, setDrafts] = useState<Record<string, DraftView>>({});
|
|
148
|
+
const [missingDrafts, setMissingDrafts] = useState<ReadonlySet<string>>(new Set());
|
|
149
|
+
const [draftsState, setDraftsState] = useState<DraftsLoadState>("loading");
|
|
150
|
+
const [emails, setEmails] = useState<MonitoringEmailSummary[]>([]);
|
|
151
|
+
const [emailsState, setEmailsState] = useState<"loading" | "ready">("loading");
|
|
152
|
+
const [emailsDecided, setEmailsDecided] = useState<ReadonlyMap<string, "accept" | "reject">>(new Map());
|
|
153
|
+
const [stay, setStay] = useState<ReadonlySet<string>>(new Set());
|
|
154
|
+
const [visitRated, setVisitRated] = useState<ReadonlySet<string>>(new Set());
|
|
155
|
+
const [now] = useState(() => Date.now());
|
|
156
|
+
const resolving = useRef(new Set<string>());
|
|
157
|
+
const demoStarted = useRef(false);
|
|
158
|
+
const preview = useMemo(() => previewState(), []);
|
|
159
|
+
const sample = useMemo(() => {
|
|
160
|
+
if (preview === "sample" || !fixture) return { requests: FIXTURE_REVIEW_REQUESTS, drafts: FIXTURE_DRAFTS };
|
|
161
|
+
if (preview === "mixed") {
|
|
162
|
+
const invented = FIXTURE_REVIEW_REQUESTS.filter((request) => request.id === "rr-hard-1" || request.id === "rr-flag-1");
|
|
163
|
+
return { requests: [...invented, ...fixture.requests], drafts: fixture.drafts };
|
|
164
|
+
}
|
|
165
|
+
return { requests: preview === "caught-up" ? [] : fixture.requests, drafts: fixture.drafts };
|
|
166
|
+
}, [fixture, preview]);
|
|
167
|
+
|
|
168
|
+
const keep = useCallback((key: string) => setStay((current) => (current.has(key) ? current : new Set(current).add(key))), []);
|
|
169
|
+
|
|
170
|
+
const loadDraftsFor = useCallback(async (list: ReviewRequest[]) => {
|
|
171
|
+
const ids = list.filter((request) => request.state === "open" && isDraftReview(request)).flatMap(draftIdsOf);
|
|
172
|
+
if (!ids.length) {
|
|
173
|
+
setDraftsState("ready");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (demo) {
|
|
177
|
+
const known = sample.drafts.filter((row) => ids.includes(row.id));
|
|
178
|
+
setDrafts((current) => ({ ...current, ...Object.fromEntries(known.map((row) => [row.id, draftView(row, true)])) }));
|
|
179
|
+
setMissingDrafts(new Set(ids.filter((id) => !known.some((row) => row.id === id))));
|
|
180
|
+
setDraftsState("ready");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const loaded = await api.loadDrafts(ids);
|
|
185
|
+
setDrafts((current) => ({ ...current, ...loaded.drafts }));
|
|
186
|
+
setMissingDrafts(new Set(loaded.missing));
|
|
187
|
+
setDraftsState(loaded.failed.length ? "failed" : "ready");
|
|
188
|
+
} catch {
|
|
189
|
+
setDraftsState("failed");
|
|
190
|
+
}
|
|
191
|
+
}, [api, demo, sample]);
|
|
192
|
+
|
|
193
|
+
// Email held for review is read on its own: a site without monitoring, or an editor who may not read it, simply has none.
|
|
194
|
+
const loadEmailsNow = useCallback(async () => {
|
|
195
|
+
if (preview === "caught-up") { setEmails([]); setEmailsState("ready"); return; }
|
|
196
|
+
// The real-data preview has no held email of its own; only the sample and mixed previews show the invented one.
|
|
197
|
+
if (demo && fixture && preview !== "sample" && preview !== "mixed") { setEmails(fixture.emails ?? []); setEmailsState("ready"); return; }
|
|
198
|
+
try {
|
|
199
|
+
setEmails((await api.loadHeldEmails?.()) ?? []);
|
|
200
|
+
} catch {
|
|
201
|
+
// Held email is one source among several; the others still show.
|
|
202
|
+
}
|
|
203
|
+
setEmailsState("ready");
|
|
204
|
+
}, [api, demo, fixture, preview]);
|
|
205
|
+
|
|
206
|
+
// A return visit paints the last view at once, marked as such, while the real reads run.
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (demo) return;
|
|
209
|
+
const snapshot = loadNotificationSnapshot(SITE_BRAND.id);
|
|
210
|
+
if (!snapshot) return;
|
|
211
|
+
setRequests(snapshot.requests);
|
|
212
|
+
setDrafts(snapshot.drafts);
|
|
213
|
+
setMissingDrafts(new Set(snapshot.missing));
|
|
214
|
+
setDraftsState("ready");
|
|
215
|
+
setStale(true);
|
|
216
|
+
setLoadState((current) => (current === "loading" ? "ready" : current));
|
|
217
|
+
}, [demo]);
|
|
211
218
|
|
|
212
219
|
const refresh = useCallback(async () => {
|
|
213
220
|
if (demo) {
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
// The sample is read once: answering in the preview must not be undone by the next refresh.
|
|
222
|
+
if (demoStarted.current) return;
|
|
223
|
+
demoStarted.current = true;
|
|
224
|
+
setRequests(sample.requests);
|
|
216
225
|
setLoadState("ready");
|
|
226
|
+
void loadDraftsFor(sample.requests);
|
|
227
|
+
void loadEmailsNow();
|
|
217
228
|
return;
|
|
218
229
|
}
|
|
219
230
|
try {
|
|
220
|
-
const
|
|
221
|
-
const loaded = await loadReviews();
|
|
231
|
+
const loaded = await api.loadReviews();
|
|
222
232
|
if (loaded.state !== "ready") {
|
|
233
|
+
setStale(false);
|
|
223
234
|
setLoadState(loaded.state);
|
|
224
235
|
return;
|
|
225
236
|
}
|
|
226
237
|
setRequests(loaded.requests);
|
|
227
|
-
setReadIds(new Set(loaded.readIds));
|
|
228
238
|
setUserKey(loaded.userKey);
|
|
239
|
+
setStale(false);
|
|
229
240
|
setLoadState("ready");
|
|
230
|
-
|
|
231
|
-
|
|
241
|
+
void loadDraftsFor(loaded.requests);
|
|
242
|
+
void loadEmailsNow();
|
|
243
|
+
const stories = await api.loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({ titles: {}, deleted: [] as string[], excerpts: {} }));
|
|
232
244
|
setStoryTitles(stories.titles);
|
|
245
|
+
setStoryExcerpts(stories.excerpts ?? {});
|
|
233
246
|
setDeletedStories(new Set(stories.deleted));
|
|
234
247
|
} catch {
|
|
248
|
+
setStale(false);
|
|
235
249
|
setLoadState("unavailable");
|
|
236
250
|
}
|
|
237
|
-
}, [demo]);
|
|
251
|
+
}, [api, demo, loadDraftsFor, loadEmailsNow, sample]);
|
|
238
252
|
|
|
239
253
|
useEffect(() => {
|
|
240
254
|
void refresh();
|
|
@@ -242,40 +256,137 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
|
|
|
242
256
|
return () => window.clearInterval(timer);
|
|
243
257
|
}, [refresh]);
|
|
244
258
|
|
|
245
|
-
|
|
246
|
-
|
|
259
|
+
// Keep a display copy for the next visit once the live read and its drafts have arrived.
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (demo || stale || loadState !== "ready" || draftsState !== "ready") return;
|
|
262
|
+
const timer = window.setTimeout(() => saveNotificationSnapshot(SITE_BRAND.id, { requests, drafts, missing: missingDrafts }), 800);
|
|
263
|
+
return () => window.clearTimeout(timer);
|
|
264
|
+
}, [demo, draftsState, drafts, loadState, missingDrafts, requests, stale]);
|
|
247
265
|
|
|
248
266
|
const markRead = useCallback(async (requestId: string) => {
|
|
249
|
-
setReadIds((current) => new Set(current).add(requestId));
|
|
250
267
|
if (demo || !userKey) return;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}, [demo, userKey]);
|
|
268
|
+
await api.markReviewRead(userKey, requestId).catch(() => undefined);
|
|
269
|
+
}, [api, demo, userKey]);
|
|
254
270
|
|
|
271
|
+
/** Answers a whole request. Throws a sentence for the card when it cannot be saved. */
|
|
255
272
|
const answer = useCallback(async (request: ReviewRequest, input: AnswerInput) => {
|
|
256
273
|
setError(null);
|
|
257
|
-
|
|
274
|
+
const saved = await api.answerReviewRequest(request, input, actorLabel);
|
|
275
|
+
keep(request.id);
|
|
276
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
277
|
+
await markRead(request.id);
|
|
278
|
+
}, [actorLabel, api, keep, markRead]);
|
|
279
|
+
|
|
280
|
+
/** Accept or Reject one draft: the Proposal moves to approved or declined, the decision is recorded on the request, and the request closes once every draft is decided. */
|
|
281
|
+
const decideDraft = useCallback(async (request: ReviewRequest, draftId: string, choice: DraftChoice, extras: { why: string; notThisKind: boolean }) => {
|
|
282
|
+
setError(null);
|
|
283
|
+
resolving.current.add(request.id);
|
|
258
284
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
await
|
|
285
|
+
const view = drafts[draftId];
|
|
286
|
+
if (!view) throw new Error("This draft has not loaded yet.");
|
|
287
|
+
const path = choice === "accept" ? acceptPath(view.state) : rejectPath(view.state);
|
|
288
|
+
if (!path) throw new Error(`This draft is ${stateLabel(view.state, false).toLowerCase()} and cannot be ${choice === "accept" ? "accepted" : "rejected"} from here. Open it in Proposals.`);
|
|
289
|
+
const receipt = await api.transitionProposalPath(draftId, path);
|
|
290
|
+
if (receipt.status !== "ok") throw new Error(proposalReceiptMessage(receipt));
|
|
291
|
+
const decided: DraftView = { ...view, state: choice === "accept" ? "approved" : "declined", decided: choice };
|
|
292
|
+
const nextDrafts = { ...drafts, [draftId]: decided };
|
|
293
|
+
setDrafts(nextDrafts);
|
|
294
|
+
keep(`${request.id}#${draftId}`);
|
|
295
|
+
const decision: DraftDecision = { choice, title: view.headline, why: extras.why || null, notThisKind: choice === "reject" && extras.notThisKind, by: actorLabel, at: new Date().toISOString() };
|
|
296
|
+
const withDecisionRequest = { ...request, links: withDecision(request, draftId, decision) };
|
|
297
|
+
const closing = batchAnswer(withDecisionRequest, nextDrafts, { by: actorLabel, at: decision.at });
|
|
298
|
+
try {
|
|
299
|
+
const saved = closing ? await api.answerReviewRequest(request, closing, actorLabel) : await api.saveDraftDecision(request, draftId, decision);
|
|
300
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
301
|
+
if (closing) await markRead(request.id);
|
|
302
|
+
} catch (failure) {
|
|
303
|
+
throw new Error(`The draft was ${choice === "accept" ? "accepted" : "rejected"} in Proposals, but this request could not record it. ${failure instanceof Error ? failure.message : ""}`.trim());
|
|
304
|
+
}
|
|
264
305
|
} catch (failure) {
|
|
265
|
-
|
|
306
|
+
if (!demo) void loadDraftsFor([request]);
|
|
307
|
+
throw failure;
|
|
266
308
|
} finally {
|
|
267
|
-
|
|
309
|
+
resolving.current.delete(request.id);
|
|
310
|
+
}
|
|
311
|
+
}, [actorLabel, api, demo, drafts, keep, loadDraftsFor, markRead]);
|
|
312
|
+
|
|
313
|
+
// Drafts decided in Proposals (not here) still close their request.
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
if (demo || draftsState !== "ready" || stale) return;
|
|
316
|
+
for (const request of requests) {
|
|
317
|
+
if (request.state !== "open" || !isDraftReview(request) || resolving.current.has(request.id)) continue;
|
|
318
|
+
if (undecidedDraftIds(request, drafts).length) continue;
|
|
319
|
+
const closing = batchAnswer(request, drafts, { by: "an editor in Proposals", at: new Date().toISOString() });
|
|
320
|
+
if (!closing) continue;
|
|
321
|
+
resolving.current.add(request.id);
|
|
322
|
+
void (async () => {
|
|
323
|
+
try {
|
|
324
|
+
const saved = await api.answerReviewRequest(request, closing, "an editor in Proposals");
|
|
325
|
+
setRequests((current) => current.map((entry) => (entry.id === saved.id ? saved : entry)));
|
|
326
|
+
} catch {
|
|
327
|
+
// The next refresh shows what became of it.
|
|
328
|
+
} finally {
|
|
329
|
+
resolving.current.delete(request.id);
|
|
330
|
+
}
|
|
331
|
+
})();
|
|
268
332
|
}
|
|
269
|
-
}, [
|
|
333
|
+
}, [api, demo, drafts, draftsState, requests, stale]);
|
|
270
334
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
335
|
+
/** One decision, whatever the type: it goes to the action that already owns that kind of thing. */
|
|
336
|
+
const decide = useCallback(async (item: NeedsMeEntry, decision: NeedsMeDecision) => {
|
|
337
|
+
const { payload } = item;
|
|
338
|
+
if (payload.kind === "reference") {
|
|
339
|
+
setVisitRated((current) => (current.has(payload.lineageId) ? current : new Set(current).add(payload.lineageId)));
|
|
340
|
+
await onRateReference?.(payload.reference, decision.choice, decision.choice === "reject" ? decision.reasonCode : null, decision.why || undefined);
|
|
341
|
+
} else if (payload.kind === "draft") {
|
|
342
|
+
await decideDraft(payload.item.request, payload.item.draftId, decision.choice, { why: decision.why, notThisKind: decision.notThisKind });
|
|
343
|
+
} else if (payload.kind === "request") {
|
|
344
|
+
const { request } = payload;
|
|
345
|
+
if (request.mode === "flag") await answer(request, { decision: "acknowledge", feedback: decision.why || null });
|
|
346
|
+
else if (decision.choice === "accept") await answer(request, { decision: "approve", feedback: decision.why || null });
|
|
347
|
+
else await answer(request, { decision: "reject", feedback: decision.why || null, steer: decision.notThisKind ? "not_this_kind" : null });
|
|
348
|
+
} else if (payload.kind === "email") {
|
|
349
|
+
const result = await api.decideEmail?.(payload.summary.receiptId, decision.choice, decision.why);
|
|
350
|
+
if (!result) throw new Error("Held email cannot be decided from here.");
|
|
351
|
+
if (!("ok" in result)) throw new Error("error" in result ? EMAIL_REFUSALS[result.error] ?? "The decision could not be saved." : "The decision could not be saved right now. Try again in a minute.");
|
|
352
|
+
setEmailsDecided((current) => new Map(current).set(payload.summary.receiptId, decision.choice));
|
|
353
|
+
}
|
|
354
|
+
}, [answer, api, decideDraft, onRateReference]);
|
|
355
|
+
|
|
356
|
+
const shownReferences = preview === "caught-up" ? references.filter((reference) => normalizeReferenceCurationStatus(reference.curationStatus) !== "pending") : references;
|
|
357
|
+
const bySource = useMemo(() => previewsBySourceAddress(shownReferences, previews), [shownReferences, previews]);
|
|
358
|
+
const lineageIds = useMemo(() => shownReferences.map(referenceLineageId), [shownReferences]);
|
|
359
|
+
const { statuses: captures, teaserLine } = useMemo(() => captureStatusesByLineage(referenceAttachments, lineageIds), [lineageIds, referenceAttachments]);
|
|
360
|
+
|
|
361
|
+
const entries = useMemo(
|
|
362
|
+
() => buildNeedsMeItems({
|
|
363
|
+
references: shownReferences, decisions, previews, captures, visitRated, requests, drafts, missingDrafts, draftsState, stay, bySource,
|
|
364
|
+
storyTitles, storyExcerpts, deletedStories, emails, emailsDecided,
|
|
365
|
+
}),
|
|
366
|
+
[shownReferences, decisions, previews, captures, visitRated, requests, drafts, missingDrafts, draftsState, stay, bySource, storyTitles, storyExcerpts, deletedStories, emails, emailsDecided],
|
|
367
|
+
);
|
|
368
|
+
const freshness = useMemo(() => learnFreshness(shownReferences, visitRated), [shownReferences, visitRated]);
|
|
369
|
+
const threshold = useMemo(() => learnConfidenceThreshold(shownReferences, decisions, visitRated), [shownReferences, decisions, visitRated]);
|
|
370
|
+
const recent = useMemo(() => recentDecisions(requests, references, demo), [requests, references, demo]);
|
|
371
|
+
const shaping = useMemo(
|
|
372
|
+
() => shapingThisWeek({ decidedAt: recentDecisions(requests, references, demo, Number.MAX_SAFE_INTEGER).map((entry) => entry.at), references, decisions, now }),
|
|
373
|
+
[requests, references, demo, decisions, now],
|
|
374
|
+
);
|
|
375
|
+
const shownIds = useMemo(() => new Set(entries.map((item) => item.id)), [entries]);
|
|
376
|
+
const legacyAuto = useMemo(
|
|
377
|
+
() => cyclotronAutoDecided(shownReferences, decisions, humanCuratedReferenceLineageIds, demo).filter((entry) => !shownIds.has(entry.lineageId)),
|
|
378
|
+
[shownReferences, decisions, humanCuratedReferenceLineageIds, demo, shownIds],
|
|
379
|
+
);
|
|
380
|
+
const openDraftRequests = useMemo(() => sectionReviews(requests).draftBatches, [requests]);
|
|
381
|
+
const missingCount = openDraftRequests.reduce((total, request) => total + draftIdsOf(request).filter((id) => missingDrafts.has(id)).length, 0);
|
|
382
|
+
const settled = !stale && loadState === "ready" && draftsState !== "loading" && emailsState === "ready" && !referencesLoading && !predictionLoading;
|
|
274
383
|
|
|
275
384
|
return (
|
|
276
|
-
<
|
|
277
|
-
|
|
278
|
-
|
|
385
|
+
<NeedsMeView
|
|
386
|
+
captureTeaserLine={teaserLine} demo={demo} draftsFailed={draftsState === "failed"} entries={entries} error={error} freshness={freshness} legacyAuto={legacyAuto}
|
|
387
|
+
loadEmail={api.loadEmail} loadState={preview === "loading" ? "loading" : loadState} missingDraftCount={missingCount} now={now} onCorrectCapture={onCorrectCapture} onDecide={decide}
|
|
388
|
+
onRetryDrafts={() => void loadDraftsFor(requests)} predictionLoading={predictionLoading} recent={recent} referenceAttachments={referenceAttachments}
|
|
389
|
+
referenceDisabledReason={referenceDisabledReason} referencesLoading={referencesLoading} settled={settled} shaping={shaping} stale={stale} threshold={threshold}
|
|
279
390
|
/>
|
|
280
391
|
);
|
|
281
392
|
}
|