@rightkit/social-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +40 -2
- package/dist/appName.d.ts +2 -0
- package/dist/appName.js +8 -0
- package/dist/components/ScopeSelect.d.ts +7 -0
- package/dist/components/ScopeSelect.js +6 -0
- package/dist/components/Shared.d.ts +34 -0
- package/dist/components/Shared.js +55 -0
- package/dist/data/derive.d.ts +21 -0
- package/dist/data/derive.js +47 -0
- package/dist/data/draftRules.d.ts +10 -0
- package/dist/data/draftRules.js +20 -0
- package/dist/data/engine/engineDataSource.d.ts +194 -0
- package/dist/data/engine/engineDataSource.js +836 -0
- package/dist/data/engine/errors.d.ts +13 -0
- package/dist/data/engine/errors.js +21 -0
- package/dist/data/engine/mapping.d.ts +172 -0
- package/dist/data/engine/mapping.js +1487 -0
- package/dist/data/engine/protocol.d.ts +910 -0
- package/dist/data/engine/protocol.js +67 -0
- package/dist/data/index.d.ts +18 -0
- package/dist/data/index.js +23 -0
- package/dist/data/mock/devSource.d.ts +7 -0
- package/dist/data/mock/devSource.js +10 -0
- package/dist/data/mock/fixtures-analytics.d.ts +7 -0
- package/dist/data/mock/fixtures-analytics.js +123 -0
- package/dist/data/mock/fixtures-briefs.d.ts +4 -0
- package/dist/data/mock/fixtures-briefs.js +230 -0
- package/dist/data/mock/fixtures-content.d.ts +4 -0
- package/dist/data/mock/fixtures-content.js +89 -0
- package/dist/data/mock/fixtures-digests.d.ts +3 -0
- package/dist/data/mock/fixtures-digests.js +40 -0
- package/dist/data/mock/fixtures-drafts.d.ts +2 -0
- package/dist/data/mock/fixtures-drafts.js +74 -0
- package/dist/data/mock/fixtures-gate.d.ts +3 -0
- package/dist/data/mock/fixtures-gate.js +49 -0
- package/dist/data/mock/fixtures-hooks.d.ts +2 -0
- package/dist/data/mock/fixtures-hooks.js +103 -0
- package/dist/data/mock/fixtures-inbox.d.ts +11 -0
- package/dist/data/mock/fixtures-inbox.js +213 -0
- package/dist/data/mock/fixtures-links.d.ts +5 -0
- package/dist/data/mock/fixtures-links.js +89 -0
- package/dist/data/mock/fixtures-memory.d.ts +3 -0
- package/dist/data/mock/fixtures-memory.js +148 -0
- package/dist/data/mock/fixtures-ops.d.ts +15 -0
- package/dist/data/mock/fixtures-ops.js +403 -0
- package/dist/data/mock/fixtures-optimise.d.ts +18 -0
- package/dist/data/mock/fixtures-optimise.js +221 -0
- package/dist/data/mock/fixtures-pacing.d.ts +5 -0
- package/dist/data/mock/fixtures-pacing.js +285 -0
- package/dist/data/mock/fixtures-publish.d.ts +2 -0
- package/dist/data/mock/fixtures-publish.js +129 -0
- package/dist/data/mock/fixtures-store.d.ts +10 -0
- package/dist/data/mock/fixtures-store.js +13 -0
- package/dist/data/mock/fixtures-variants.d.ts +5 -0
- package/dist/data/mock/fixtures-variants.js +138 -0
- package/dist/data/mock/fixtures.d.ts +6 -0
- package/dist/data/mock/fixtures.js +420 -0
- package/dist/data/mock/mockDataSource.d.ts +98 -0
- package/dist/data/mock/mockDataSource.js +903 -0
- package/dist/data/money.d.ts +14 -0
- package/dist/data/money.js +36 -0
- package/dist/data/platforms.d.ts +12 -0
- package/dist/data/platforms.js +26 -0
- package/dist/data/source.d.ts +196 -0
- package/dist/data/source.js +1 -0
- package/dist/data/time.d.ts +23 -0
- package/dist/data/time.js +86 -0
- package/dist/data/types.d.ts +1092 -0
- package/dist/data/types.js +3 -0
- package/dist/env.d.ts +59 -0
- package/dist/hooks/useAsync.d.ts +19 -0
- package/dist/hooks/useAsync.js +25 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +58 -0
- package/dist/lib/host.d.ts +21 -0
- package/dist/lib/host.js +32 -0
- package/dist/lib/shell.d.ts +156 -0
- package/dist/lib/shell.js +16 -0
- package/dist/screens/AccountsScreen.d.ts +8 -0
- package/dist/screens/AccountsScreen.js +100 -0
- package/dist/screens/AnalyticsScreen.d.ts +7 -0
- package/dist/screens/AnalyticsScreen.js +64 -0
- package/dist/screens/ApprovalsScreen.d.ts +10 -0
- package/dist/screens/ApprovalsScreen.js +144 -0
- package/dist/screens/BriefsScreen.d.ts +7 -0
- package/dist/screens/BriefsScreen.js +83 -0
- package/dist/screens/CalendarScreen.d.ts +10 -0
- package/dist/screens/CalendarScreen.js +143 -0
- package/dist/screens/CampaignsScreen.d.ts +7 -0
- package/dist/screens/CampaignsScreen.js +52 -0
- package/dist/screens/ChannelMemoryScreen.d.ts +7 -0
- package/dist/screens/ChannelMemoryScreen.js +109 -0
- package/dist/screens/ContentScreen.d.ts +7 -0
- package/dist/screens/ContentScreen.js +64 -0
- package/dist/screens/DigestsScreen.d.ts +7 -0
- package/dist/screens/DigestsScreen.js +53 -0
- package/dist/screens/DraftsScreen.d.ts +10 -0
- package/dist/screens/DraftsScreen.js +108 -0
- package/dist/screens/FailuresScreen.d.ts +8 -0
- package/dist/screens/FailuresScreen.js +111 -0
- package/dist/screens/HookTestsScreen.d.ts +7 -0
- package/dist/screens/HookTestsScreen.js +54 -0
- package/dist/screens/InboxScreen.d.ts +12 -0
- package/dist/screens/InboxScreen.js +76 -0
- package/dist/screens/LinksScreen.d.ts +7 -0
- package/dist/screens/LinksScreen.js +52 -0
- package/dist/screens/PacingScreen.d.ts +7 -0
- package/dist/screens/PacingScreen.js +88 -0
- package/dist/screens/PublishJobsScreen.d.ts +7 -0
- package/dist/screens/PublishJobsScreen.js +66 -0
- package/dist/screens/SuggestionsScreen.d.ts +8 -0
- package/dist/screens/SuggestionsScreen.js +94 -0
- package/dist/screens/TextGateScreen.d.ts +7 -0
- package/dist/screens/TextGateScreen.js +111 -0
- package/dist/screens/ThreadsScreen.d.ts +8 -0
- package/dist/screens/ThreadsScreen.js +113 -0
- package/dist/screens/TypedBriefsScreen.d.ts +7 -0
- package/dist/screens/TypedBriefsScreen.js +75 -0
- package/dist/screens/labels.d.ts +121 -0
- package/dist/screens/labels.js +272 -0
- package/package.json +56 -3
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
import { awaitingDecision, mediaReady } from "../derive.js";
|
|
2
|
+
import { disclosureFound } from "../draftRules.js";
|
|
3
|
+
import { FIXTURE_ACCOUNTS, FIXTURE_JOBS, MOCK_NOW_UTC, MOCK_POLICY_REVISION } from "./fixtures.js";
|
|
4
|
+
import { FIXTURE_CAMPAIGNS, FIXTURE_LINKS, FIXTURE_LINK_VISITS } from "./fixtures-links.js";
|
|
5
|
+
import { FIXTURE_DRAFTS } from "./fixtures-drafts.js";
|
|
6
|
+
import { FIXTURE_HOOK_TESTS } from "./fixtures-hooks.js";
|
|
7
|
+
import { FIXTURE_DIGESTS, FIXTURE_DIGEST_APPLICATIONS } from "./fixtures-digests.js";
|
|
8
|
+
import { FIXTURE_GATE_POLICIES, FIXTURE_GATE_RECORDS } from "./fixtures-gate.js";
|
|
9
|
+
import { FIXTURE_BRIEFS, FIXTURE_DELIVERIES, FIXTURE_DISPATCH_ATTEMPTS } from "./fixtures-briefs.js";
|
|
10
|
+
import { FIXTURE_MEMORY, FIXTURE_PROOFS } from "./fixtures-memory.js";
|
|
11
|
+
import { FIXTURE_BRIEF_VARIANTS, FIXTURE_TYPED_BRIEFS } from "./fixtures-variants.js";
|
|
12
|
+
import { FIXTURE_ANALYTICS_OBSERVATIONS, FIXTURE_ANALYTICS_POSTS, FIXTURE_METRIC_IMPORTS, } from "./fixtures-analytics.js";
|
|
13
|
+
import { FIXTURE_HEARTBEAT, FIXTURE_RESTORE_RECEIPTS } from "./fixtures-store.js";
|
|
14
|
+
import { FIXTURE_PUBLISH_JOBS } from "./fixtures-publish.js";
|
|
15
|
+
import { FIXTURE_INBOX, FIXTURE_INBOX_STATS, FIXTURE_UNMATCHED_UNREADABLE } from "./fixtures-inbox.js";
|
|
16
|
+
import { pacingViewFor, quotaFor } from "./fixtures-pacing.js";
|
|
17
|
+
import { FIXTURE_CONTENT, FIXTURE_CONTENT_SUMMARIES, FIXTURE_CONTENT_VARIANTS } from "./fixtures-content.js";
|
|
18
|
+
import { FIXTURE_ALLOWANCES, FIXTURE_PRICES, FIXTURE_PROPOSALS, FIXTURE_REPORTS, FIXTURE_SCOPES, } from "./fixtures-optimise.js";
|
|
19
|
+
import { EXTRA_JOBS, FIXTURE_FAILURES, FIXTURE_MODERATION, FIXTURE_THREADS, RECONCILE_RESULTS, } from "./fixtures-ops.js";
|
|
20
|
+
/** FNV-1a, 32 bit. A display fingerprint for the mock, not a cryptographic digest. */
|
|
21
|
+
export function fingerprint(text) {
|
|
22
|
+
let hash = 0x811c9dc5;
|
|
23
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
24
|
+
hash ^= text.charCodeAt(i);
|
|
25
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
26
|
+
}
|
|
27
|
+
return hash.toString(16).padStart(8, "0");
|
|
28
|
+
}
|
|
29
|
+
const clone = (value) => structuredClone(value);
|
|
30
|
+
/** The links of one campaign in one scope, in the engine's order: variant, platform, then link id. */
|
|
31
|
+
function sortedLinks(scopeId, campaignId) {
|
|
32
|
+
return FIXTURE_LINKS.filter((l) => l.scopeId === scopeId && l.campaignId === campaignId).sort((a, b) => a.variantId.localeCompare(b.variantId) || a.platform.localeCompare(b.platform) || a.id.localeCompare(b.id));
|
|
33
|
+
}
|
|
34
|
+
const DAY_MS = 86_400_000;
|
|
35
|
+
const ALERT_LABEL = {
|
|
36
|
+
disk_full: "disk space",
|
|
37
|
+
credentials_expired: "expired credentials",
|
|
38
|
+
quota_cooldown: "quota cooldown",
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* In-memory daemon stand-in. It enforces the daemon's rules on every write: a mismatched account
|
|
42
|
+
* or revision returns a typed error and changes nothing. Data resets on each page load.
|
|
43
|
+
*/
|
|
44
|
+
export class MockDataSource {
|
|
45
|
+
kind = "mock";
|
|
46
|
+
mock;
|
|
47
|
+
latencyMs;
|
|
48
|
+
failReads;
|
|
49
|
+
accounts;
|
|
50
|
+
jobs;
|
|
51
|
+
failures;
|
|
52
|
+
threads;
|
|
53
|
+
drafts;
|
|
54
|
+
proposals;
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
this.latencyMs = options.latencyMs ?? 220;
|
|
57
|
+
this.failReads = options.failReads ?? false;
|
|
58
|
+
this.accounts = clone(FIXTURE_ACCOUNTS);
|
|
59
|
+
this.jobs = new Map(clone([...FIXTURE_JOBS, ...EXTRA_JOBS]).map((job) => {
|
|
60
|
+
job.captionDigest = fingerprint(job.caption);
|
|
61
|
+
return [job.id, job];
|
|
62
|
+
}));
|
|
63
|
+
this.failures = new Map(clone(FIXTURE_FAILURES).map((f) => [f.id, f]));
|
|
64
|
+
this.threads = new Map(clone(FIXTURE_THREADS).map((t) => [t.id, t]));
|
|
65
|
+
this.drafts = new Map(clone(FIXTURE_DRAFTS).map((draft) => {
|
|
66
|
+
draft.bodyChars = Array.from(draft.body).length;
|
|
67
|
+
return [draft.id, draft];
|
|
68
|
+
}));
|
|
69
|
+
this.proposals = new Map(clone(FIXTURE_PROPOSALS).map((p) => [p.id, p]));
|
|
70
|
+
this.mock = this;
|
|
71
|
+
}
|
|
72
|
+
nowUtc() {
|
|
73
|
+
return MOCK_NOW_UTC;
|
|
74
|
+
}
|
|
75
|
+
listAccounts() {
|
|
76
|
+
return this.read(() => clone(this.accounts));
|
|
77
|
+
}
|
|
78
|
+
listJobs(range) {
|
|
79
|
+
return this.read(() => clone([...this.jobs.values()]
|
|
80
|
+
.filter((j) => j.scheduledAtUtc >= range.fromUtc && j.scheduledAtUtc < range.toUtc)
|
|
81
|
+
.sort((a, b) => a.scheduledAtUtc.localeCompare(b.scheduledAtUtc))));
|
|
82
|
+
}
|
|
83
|
+
getJob(jobId) {
|
|
84
|
+
return this.read(() => {
|
|
85
|
+
const job = this.jobs.get(jobId);
|
|
86
|
+
return job ? clone(job) : null;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async approve(jobId, revision, accountId) {
|
|
90
|
+
await this.delay();
|
|
91
|
+
const job = this.jobs.get(jobId);
|
|
92
|
+
if (!job)
|
|
93
|
+
return fail("not_found", "This job no longer exists. Nothing changed.");
|
|
94
|
+
if (job.accountId !== accountId) {
|
|
95
|
+
return fail("account_mismatch", "The job belongs to a different account. Nothing changed.");
|
|
96
|
+
}
|
|
97
|
+
if (job.revision !== revision) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: {
|
|
101
|
+
code: "revision_conflict",
|
|
102
|
+
message: `This job is now revision ${job.revision}, not ${revision}. Review the new version before approving.`,
|
|
103
|
+
currentRevision: job.revision,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (job.approval.status !== "pending" && job.approval.status !== "invalidated") {
|
|
108
|
+
return fail("not_approvable", `Nothing to approve: the approval is ${job.approval.status}.`);
|
|
109
|
+
}
|
|
110
|
+
if (!mediaReady(job)) {
|
|
111
|
+
return fail("not_approvable", "Media not ready on server. Approve again once every item is ready.");
|
|
112
|
+
}
|
|
113
|
+
job.approval = {
|
|
114
|
+
status: "approved",
|
|
115
|
+
approvedRevision: job.revision,
|
|
116
|
+
policyRevision: MOCK_POLICY_REVISION,
|
|
117
|
+
approvedAtUtc: MOCK_NOW_UTC,
|
|
118
|
+
};
|
|
119
|
+
job.policyRevision = MOCK_POLICY_REVISION;
|
|
120
|
+
job.state = this.isPaused(accountId) ? "paused" : "scheduled";
|
|
121
|
+
return { ok: true, value: clone(job) };
|
|
122
|
+
}
|
|
123
|
+
async reject(jobId, decision) {
|
|
124
|
+
await this.delay();
|
|
125
|
+
const job = this.jobs.get(jobId);
|
|
126
|
+
if (!job)
|
|
127
|
+
return fail("not_found", "This job no longer exists. Nothing changed.");
|
|
128
|
+
if (job.accountId !== decision.accountId) {
|
|
129
|
+
return fail("account_mismatch", "The job belongs to a different account. Nothing changed.");
|
|
130
|
+
}
|
|
131
|
+
if (job.revision !== decision.revision) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: {
|
|
135
|
+
code: "revision_conflict",
|
|
136
|
+
message: `This job is now revision ${job.revision}, not ${decision.revision}. Review the new version before rejecting.`,
|
|
137
|
+
currentRevision: job.revision,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (decision.code === "other" && decision.note.trim() === "") {
|
|
142
|
+
return fail("reason_required", "Add a note when the reason is Other.");
|
|
143
|
+
}
|
|
144
|
+
if (job.approval.status === "rejected") {
|
|
145
|
+
return fail("not_approvable", "This job is already rejected.");
|
|
146
|
+
}
|
|
147
|
+
job.approval = {
|
|
148
|
+
status: "rejected",
|
|
149
|
+
reason: decision.code,
|
|
150
|
+
note: decision.note.trim() === "" ? null : decision.note.trim(),
|
|
151
|
+
};
|
|
152
|
+
job.revision += 1;
|
|
153
|
+
job.state = "draft";
|
|
154
|
+
return { ok: true, value: clone(job) };
|
|
155
|
+
}
|
|
156
|
+
async pauseAccount(accountId, reason) {
|
|
157
|
+
await this.delay();
|
|
158
|
+
const account = this.accounts.find((a) => a.id === accountId);
|
|
159
|
+
if (!account)
|
|
160
|
+
return fail("not_found", "This account is not known to the daemon.");
|
|
161
|
+
account.readiness.paused = { by: "desktop", atUtc: MOCK_NOW_UTC, reason: reason.trim() || "Paused from desktop" };
|
|
162
|
+
for (const job of this.jobs.values()) {
|
|
163
|
+
if (job.accountId === accountId && job.state === "scheduled")
|
|
164
|
+
job.state = "paused";
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, value: clone(account) };
|
|
167
|
+
}
|
|
168
|
+
async resumeAccount(accountId) {
|
|
169
|
+
await this.delay();
|
|
170
|
+
const account = this.accounts.find((a) => a.id === accountId);
|
|
171
|
+
if (!account)
|
|
172
|
+
return fail("not_found", "This account is not known to the daemon.");
|
|
173
|
+
account.readiness.paused = null;
|
|
174
|
+
for (const job of this.jobs.values()) {
|
|
175
|
+
if (job.accountId === accountId && job.state === "paused") {
|
|
176
|
+
job.state = job.approval.status === "approved" ? "scheduled" : "ready";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, value: clone(account) };
|
|
180
|
+
}
|
|
181
|
+
async disconnectAccount(accountId) {
|
|
182
|
+
await this.delay();
|
|
183
|
+
const account = this.accounts.find((a) => a.id === accountId);
|
|
184
|
+
if (!account)
|
|
185
|
+
return fail("not_found", "This account is not known to the daemon.");
|
|
186
|
+
if (account.readiness.token.status === "revoked") {
|
|
187
|
+
return fail("not_approvable", "This account is already disconnected.");
|
|
188
|
+
}
|
|
189
|
+
account.readiness.token = { status: "revoked" };
|
|
190
|
+
// The engine cancels the publish jobs; the mock has no job state for that, so scheduled posts read as paused.
|
|
191
|
+
let cancelledJobs = 0;
|
|
192
|
+
for (const job of this.jobs.values()) {
|
|
193
|
+
if (job.accountId === accountId && job.state === "scheduled") {
|
|
194
|
+
job.state = "paused";
|
|
195
|
+
cancelledJobs += 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { ok: true, value: { account: clone(account), cancelledJobs, keptJobs: 0 } };
|
|
199
|
+
}
|
|
200
|
+
async simulateCaptionChange(jobId, caption) {
|
|
201
|
+
await this.delay();
|
|
202
|
+
const job = this.jobs.get(jobId);
|
|
203
|
+
if (!job)
|
|
204
|
+
return fail("not_found", "This job no longer exists. Nothing changed.");
|
|
205
|
+
job.caption = caption;
|
|
206
|
+
job.captionDigest = fingerprint(caption);
|
|
207
|
+
job.revision += 1;
|
|
208
|
+
if (job.approval.status === "approved") {
|
|
209
|
+
job.approval = {
|
|
210
|
+
status: "invalidated",
|
|
211
|
+
approvedRevision: job.approval.approvedRevision,
|
|
212
|
+
policyRevision: job.approval.policyRevision,
|
|
213
|
+
cause: "caption_changed",
|
|
214
|
+
};
|
|
215
|
+
job.state = "draft";
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, value: clone(job) };
|
|
218
|
+
}
|
|
219
|
+
// ---- Failures and digest -------------------------------------------------------------
|
|
220
|
+
listFailures() {
|
|
221
|
+
return this.read(() => clone([...this.failures.values()]));
|
|
222
|
+
}
|
|
223
|
+
async reconcile(jobId) {
|
|
224
|
+
await this.delay();
|
|
225
|
+
const failure = [...this.failures.values()].find((f) => f.jobId === jobId && f.kind === "outcome_unknown");
|
|
226
|
+
const make = RECONCILE_RESULTS[jobId];
|
|
227
|
+
if (!failure || !make)
|
|
228
|
+
return fail("not_approvable", "This job has no unknown outcome to check.");
|
|
229
|
+
if (failure.status === "resolved")
|
|
230
|
+
return fail("not_approvable", "This outcome is already resolved.");
|
|
231
|
+
const result = make(MOCK_NOW_UTC);
|
|
232
|
+
failure.reconcile = result;
|
|
233
|
+
return { ok: true, value: clone(result) };
|
|
234
|
+
}
|
|
235
|
+
async resolveFailure(failureId, resolution, note) {
|
|
236
|
+
await this.delay();
|
|
237
|
+
const failure = this.failures.get(failureId);
|
|
238
|
+
if (!failure)
|
|
239
|
+
return fail("not_found", "This failure is no longer open. Nothing changed.");
|
|
240
|
+
if (failure.status === "resolved")
|
|
241
|
+
return fail("not_approvable", "This failure is already resolved.");
|
|
242
|
+
const trimmed = note.trim();
|
|
243
|
+
switch (failure.kind) {
|
|
244
|
+
case "alert":
|
|
245
|
+
if (resolution !== "acknowledged") {
|
|
246
|
+
return fail("not_approvable", "An alert can only be acknowledged.");
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
case "dead_letter":
|
|
250
|
+
if (resolution === "published_confirmed" || resolution === "acknowledged") {
|
|
251
|
+
return fail("not_approvable", "A dead-letter job has no platform check to confirm against. Choose cancelled or accepted as lost.");
|
|
252
|
+
}
|
|
253
|
+
if (resolution === "accepted_as_lost" && trimmed === "") {
|
|
254
|
+
return fail("reason_required", "Add a note to accept this as lost.");
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
case "outcome_unknown":
|
|
258
|
+
if (resolution === "acknowledged") {
|
|
259
|
+
return fail("not_approvable", "An unknown outcome is resolved, not acknowledged.");
|
|
260
|
+
}
|
|
261
|
+
if (resolution === "published_confirmed" && failure.reconcile?.outcome !== "found_on_platform") {
|
|
262
|
+
return fail("not_approvable", "Check the platform first. Mark it published only when the check found the post.");
|
|
263
|
+
}
|
|
264
|
+
if (resolution === "cancelled_not_published" && failure.reconcile?.outcome !== "not_found") {
|
|
265
|
+
return fail("not_approvable", "Check the platform first. Cancel only when the check did not find the post.");
|
|
266
|
+
}
|
|
267
|
+
if (resolution === "accepted_as_lost" && trimmed === "") {
|
|
268
|
+
return fail("reason_required", "Add a note to accept this as lost.");
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
failure.status = "resolved";
|
|
273
|
+
failure.resolution = resolution;
|
|
274
|
+
failure.resolutionNote = trimmed === "" ? null : trimmed;
|
|
275
|
+
const job = failure.jobId ? this.jobs.get(failure.jobId) : undefined;
|
|
276
|
+
if (job) {
|
|
277
|
+
if (resolution === "published_confirmed")
|
|
278
|
+
job.state = "published";
|
|
279
|
+
if (resolution === "cancelled_not_published")
|
|
280
|
+
job.state = "draft";
|
|
281
|
+
if (resolution === "accepted_as_lost")
|
|
282
|
+
job.state = "failed";
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, value: clone(failure) };
|
|
285
|
+
}
|
|
286
|
+
listDigest() {
|
|
287
|
+
return this.read(() => {
|
|
288
|
+
const now = Date.parse(MOCK_NOW_UTC);
|
|
289
|
+
const start = now - DAY_MS;
|
|
290
|
+
const within = (iso) => {
|
|
291
|
+
const t = Date.parse(iso);
|
|
292
|
+
return t >= start && t <= now;
|
|
293
|
+
};
|
|
294
|
+
const name = (accountId) => this.accounts.find((a) => a.id === accountId)?.handle ?? "the daemon";
|
|
295
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
296
|
+
const published = [...this.jobs.values()].filter((j) => j.state === "published" && within(j.scheduledAtUtc));
|
|
297
|
+
const deadLetters = [...this.failures.values()].filter((f) => f.kind === "dead_letter" && within(f.atUtc));
|
|
298
|
+
const unknownOpen = [...this.failures.values()].filter((f) => f.kind === "outcome_unknown" && f.status === "open");
|
|
299
|
+
const alertsOpen = [...this.failures.values()].filter((f) => f.kind === "alert" && f.status === "open");
|
|
300
|
+
const paused = this.accounts.filter((a) => a.readiness.paused !== null);
|
|
301
|
+
const cooldowns = this.accounts.filter((a) => a.readiness.quotaCooldownUntil !== null && Date.parse(a.readiness.quotaCooldownUntil) > now);
|
|
302
|
+
const pendingApprovals = [...this.jobs.values()].filter(awaitingDecision);
|
|
303
|
+
const openThreads = [...this.threads.values()].filter((t) => t.status === "open");
|
|
304
|
+
const sentences = [];
|
|
305
|
+
sentences.push(published.length === 0
|
|
306
|
+
? "No post published in the last 24 hours."
|
|
307
|
+
: `${published.length} ${plural(published.length, "post", "posts")} published in the last 24 hours: ${published
|
|
308
|
+
.map((j) => name(j.accountId))
|
|
309
|
+
.join(", ")}.`);
|
|
310
|
+
sentences.push(deadLetters.length === 0
|
|
311
|
+
? "No post stopped after repeated failures in the last 24 hours."
|
|
312
|
+
: `${deadLetters.length} ${plural(deadLetters.length, "post stopped", "posts stopped")} after repeated failures: ${deadLetters
|
|
313
|
+
.map((f) => name(f.accountId))
|
|
314
|
+
.join(", ")}.`);
|
|
315
|
+
sentences.push(unknownOpen.length === 0
|
|
316
|
+
? "No post has an unknown outcome."
|
|
317
|
+
: `${unknownOpen.length} ${plural(unknownOpen.length, "post has", "posts have")} an unknown outcome. Nothing is retried until each one is checked.`);
|
|
318
|
+
sentences.push(alertsOpen.length === 0
|
|
319
|
+
? "No open alerts."
|
|
320
|
+
: `${alertsOpen.length} open ${plural(alertsOpen.length, "alert", "alerts")}: ${alertsOpen
|
|
321
|
+
.map((f) => ALERT_LABEL[f.alertKind ?? ""] ?? "alert")
|
|
322
|
+
.join(", ")}.`);
|
|
323
|
+
sentences.push(paused.length + cooldowns.length === 0
|
|
324
|
+
? "No account is paused or in a quota cooldown."
|
|
325
|
+
: `${paused.length} ${plural(paused.length, "account is", "accounts are")} paused${paused.length ? `: ${paused.map((a) => a.handle).join(", ")}` : ""}. ${cooldowns.length} ${plural(cooldowns.length, "account is", "accounts are")} in a quota cooldown.`);
|
|
326
|
+
sentences.push(`${pendingApprovals.length} ${plural(pendingApprovals.length, "job is", "jobs are")} waiting for approval, and ${openThreads.length} ${plural(openThreads.length, "thread is", "threads are")} open.`);
|
|
327
|
+
return {
|
|
328
|
+
windowStartUtc: new Date(start).toISOString(),
|
|
329
|
+
windowEndUtc: MOCK_NOW_UTC,
|
|
330
|
+
sentences,
|
|
331
|
+
counts: {
|
|
332
|
+
published: published.length,
|
|
333
|
+
failedDeadLetter: deadLetters.length,
|
|
334
|
+
outcomeUnknown: unknownOpen.length,
|
|
335
|
+
openAlerts: alertsOpen.length,
|
|
336
|
+
pausedAccounts: paused.length,
|
|
337
|
+
quotaCooldowns: cooldowns.length,
|
|
338
|
+
pendingApprovals: pendingApprovals.length,
|
|
339
|
+
openThreads: openThreads.length,
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// ---- Threads ---------------------------------------------------------------------------
|
|
345
|
+
listThreads() {
|
|
346
|
+
return this.read(() => clone([...this.threads.values()]));
|
|
347
|
+
}
|
|
348
|
+
async resolveThread(threadId) {
|
|
349
|
+
await this.delay();
|
|
350
|
+
const thread = this.threads.get(threadId);
|
|
351
|
+
if (!thread)
|
|
352
|
+
return fail("not_found", "This thread is no longer available. Nothing changed.");
|
|
353
|
+
if (thread.status === "resolved")
|
|
354
|
+
return fail("not_approvable", "This thread is already resolved.");
|
|
355
|
+
thread.status = "resolved";
|
|
356
|
+
thread.snoozedUntilUtc = null;
|
|
357
|
+
return { ok: true, value: clone(thread) };
|
|
358
|
+
}
|
|
359
|
+
async snoozeThread(threadId, untilUtc) {
|
|
360
|
+
await this.delay();
|
|
361
|
+
const thread = this.threads.get(threadId);
|
|
362
|
+
if (!thread)
|
|
363
|
+
return fail("not_found", "This thread is no longer available. Nothing changed.");
|
|
364
|
+
if (thread.status === "resolved")
|
|
365
|
+
return fail("not_approvable", "A resolved thread cannot be snoozed.");
|
|
366
|
+
const until = Date.parse(untilUtc);
|
|
367
|
+
if (Number.isNaN(until) || until <= Date.parse(MOCK_NOW_UTC)) {
|
|
368
|
+
return fail("invalid_input", "Choose a time after now.");
|
|
369
|
+
}
|
|
370
|
+
thread.status = "snoozed";
|
|
371
|
+
thread.snoozedUntilUtc = new Date(until).toISOString();
|
|
372
|
+
return { ok: true, value: clone(thread) };
|
|
373
|
+
}
|
|
374
|
+
listModerationCapabilities() {
|
|
375
|
+
return this.read(() => clone(FIXTURE_MODERATION));
|
|
376
|
+
}
|
|
377
|
+
// ---- Manual drafts -------------------------------------------------------------------------
|
|
378
|
+
// Mirrors the engine's rules: the approval gate, the https and host check, and revision
|
|
379
|
+
// compare-and-swap. Nothing is posted. A recorded link changes only this mock's own state.
|
|
380
|
+
listDrafts() {
|
|
381
|
+
return this.read(() => clone([...this.drafts.values()].sort((a, b) => b.updatedAtUtc.localeCompare(a.updatedAtUtc) || a.id.localeCompare(b.id))));
|
|
382
|
+
}
|
|
383
|
+
async approveDraft(draftId, revision, disclosurePhrases) {
|
|
384
|
+
await this.delay();
|
|
385
|
+
const draft = this.drafts.get(draftId);
|
|
386
|
+
if (!draft)
|
|
387
|
+
return fail("not_found", "This draft is no longer available. Nothing changed.");
|
|
388
|
+
if (draft.revision !== revision)
|
|
389
|
+
return revisionConflict(draft, revision, "approving it");
|
|
390
|
+
if (draft.state !== "drafted") {
|
|
391
|
+
return fail("not_approvable", `Only a drafted draft can be approved. This one is ${draft.state}. Nothing changed.`);
|
|
392
|
+
}
|
|
393
|
+
if (draft.body.trim() === "")
|
|
394
|
+
return fail("not_approvable", "The body is empty. Nothing was approved.");
|
|
395
|
+
if ((draft.kind === "comment" || draft.kind === "reply") && !draft.target) {
|
|
396
|
+
return fail("not_approvable", "A comment or reply needs a target URL before it can be approved.");
|
|
397
|
+
}
|
|
398
|
+
if (draft.disclosureRequired && !disclosureFound(draft.body, disclosurePhrases)) {
|
|
399
|
+
const noPhrases = disclosurePhrases.every((p) => p.trim() === "");
|
|
400
|
+
return fail("not_approvable", noPhrases
|
|
401
|
+
? "Disclosure is required, and no disclosure phrases are set for this app. Nothing was approved."
|
|
402
|
+
: "Disclosure is required: the first sentence must contain a disclosure phrase. Nothing was approved.");
|
|
403
|
+
}
|
|
404
|
+
draft.state = "approved";
|
|
405
|
+
draft.revision += 1;
|
|
406
|
+
draft.updatedAtUtc = MOCK_NOW_UTC;
|
|
407
|
+
return { ok: true, value: clone(draft) };
|
|
408
|
+
}
|
|
409
|
+
async markDraftPosted(draftId, revision, postedUrl) {
|
|
410
|
+
await this.delay();
|
|
411
|
+
const draft = this.drafts.get(draftId);
|
|
412
|
+
if (!draft)
|
|
413
|
+
return fail("not_found", "This draft is no longer available. Nothing changed.");
|
|
414
|
+
if (draft.revision !== revision)
|
|
415
|
+
return revisionConflict(draft, revision, "recording the link");
|
|
416
|
+
if (draft.state !== "approved") {
|
|
417
|
+
return fail("not_approvable", `Only an approved draft can be marked posted. This one is ${draft.state}. Nothing changed.`);
|
|
418
|
+
}
|
|
419
|
+
const url = postedUrl.trim();
|
|
420
|
+
const problem = postedLinkProblem(draft.platform, url);
|
|
421
|
+
if (problem)
|
|
422
|
+
return fail("invalid_input", problem);
|
|
423
|
+
draft.state = "posted_by_operator";
|
|
424
|
+
draft.postedUrl = url;
|
|
425
|
+
draft.postedAtUtc = MOCK_NOW_UTC;
|
|
426
|
+
draft.revision += 1;
|
|
427
|
+
draft.updatedAtUtc = MOCK_NOW_UTC;
|
|
428
|
+
return { ok: true, value: clone(draft) };
|
|
429
|
+
}
|
|
430
|
+
async skipDraft(draftId, revision, reason) {
|
|
431
|
+
await this.delay();
|
|
432
|
+
const draft = this.drafts.get(draftId);
|
|
433
|
+
if (!draft)
|
|
434
|
+
return fail("not_found", "This draft is no longer available. Nothing changed.");
|
|
435
|
+
if (draft.revision !== revision)
|
|
436
|
+
return revisionConflict(draft, revision, "skipping it");
|
|
437
|
+
const trimmed = reason.trim();
|
|
438
|
+
if (trimmed === "")
|
|
439
|
+
return fail("reason_required", "Add a reason to skip this draft. Nothing changed.");
|
|
440
|
+
if (draft.state !== "drafted" && draft.state !== "approved") {
|
|
441
|
+
return fail("not_approvable", `Only a drafted or approved draft can be skipped. This one is ${draft.state}. Nothing changed.`);
|
|
442
|
+
}
|
|
443
|
+
draft.state = "skipped";
|
|
444
|
+
draft.skipReason = trimmed;
|
|
445
|
+
draft.revision += 1;
|
|
446
|
+
draft.updatedAtUtc = MOCK_NOW_UTC;
|
|
447
|
+
return { ok: true, value: clone(draft) };
|
|
448
|
+
}
|
|
449
|
+
// ---- Suggestions and spend ------------------------------------------------------------
|
|
450
|
+
// Mirrors the engine's rules: revision compare-and-swap, the proposed-only decision, and the
|
|
451
|
+
// "remake_entry_" id of the accepted entry. The mock does not add a calendar job for it, because the
|
|
452
|
+
// mock calendar has no account for a channel scope. Spend is read-only.
|
|
453
|
+
listScopes() {
|
|
454
|
+
return this.read(() => clone(FIXTURE_SCOPES));
|
|
455
|
+
}
|
|
456
|
+
listProposals(scopeId) {
|
|
457
|
+
return this.read(() => clone([...this.proposals.values()]
|
|
458
|
+
.filter((p) => p.scopeId === scopeId)
|
|
459
|
+
.sort((a, b) => a.createdAtUtc.localeCompare(b.createdAtUtc) || a.id.localeCompare(b.id))));
|
|
460
|
+
}
|
|
461
|
+
getProposal(scopeId, proposalId) {
|
|
462
|
+
return this.read(() => {
|
|
463
|
+
const proposal = this.proposals.get(proposalId);
|
|
464
|
+
return proposal && proposal.scopeId === scopeId ? clone(proposal) : null;
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async acceptProposal(scopeId, proposalId, expectedRevision) {
|
|
468
|
+
await this.delay();
|
|
469
|
+
const proposal = this.findProposal(scopeId, proposalId);
|
|
470
|
+
if (!proposal)
|
|
471
|
+
return fail("not_found", "This suggestion is not in this channel. Nothing changed.");
|
|
472
|
+
if (proposal.revision !== expectedRevision)
|
|
473
|
+
return proposalRevisionConflict(proposal, expectedRevision);
|
|
474
|
+
if (proposal.state !== "proposed") {
|
|
475
|
+
return fail("not_approvable", `Only a proposed suggestion can be accepted. This one is ${proposal.state}. Nothing changed.`);
|
|
476
|
+
}
|
|
477
|
+
proposal.state = "accepted";
|
|
478
|
+
proposal.revision += 1;
|
|
479
|
+
proposal.decidedAtUtc = MOCK_NOW_UTC;
|
|
480
|
+
proposal.calendarEntryId = `remake_entry_${proposal.id}`;
|
|
481
|
+
proposal.updatedAtUtc = MOCK_NOW_UTC;
|
|
482
|
+
return { ok: true, value: clone(proposal) };
|
|
483
|
+
}
|
|
484
|
+
async dismissProposal(scopeId, proposalId, expectedRevision) {
|
|
485
|
+
await this.delay();
|
|
486
|
+
const proposal = this.findProposal(scopeId, proposalId);
|
|
487
|
+
if (!proposal)
|
|
488
|
+
return fail("not_found", "This suggestion is not in this channel. Nothing changed.");
|
|
489
|
+
if (proposal.revision !== expectedRevision)
|
|
490
|
+
return proposalRevisionConflict(proposal, expectedRevision);
|
|
491
|
+
if (proposal.state !== "proposed") {
|
|
492
|
+
return fail("not_approvable", `Only a proposed suggestion can be dismissed. This one is ${proposal.state}. Nothing changed.`);
|
|
493
|
+
}
|
|
494
|
+
proposal.state = "dismissed";
|
|
495
|
+
proposal.revision += 1;
|
|
496
|
+
proposal.decidedAtUtc = MOCK_NOW_UTC;
|
|
497
|
+
proposal.updatedAtUtc = MOCK_NOW_UTC;
|
|
498
|
+
return { ok: true, value: clone(proposal) };
|
|
499
|
+
}
|
|
500
|
+
getAllowance(scopeId, month) {
|
|
501
|
+
return this.read(() => {
|
|
502
|
+
const row = FIXTURE_ALLOWANCES.find((a) => a.scopeId === scopeId && a.allowance.month === month);
|
|
503
|
+
return row ? clone(row.allowance) : null;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
spendReport(scopeId, month) {
|
|
507
|
+
return this.read(() => {
|
|
508
|
+
const row = FIXTURE_REPORTS.find((r) => r.scopeId === scopeId && r.report.month === month);
|
|
509
|
+
return row ? clone(row.report) : null;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
listPrices() {
|
|
513
|
+
return this.read(() => clone(FIXTURE_PRICES));
|
|
514
|
+
}
|
|
515
|
+
// ---- Campaigns and hook tests (read only) ---------------------------------------------------
|
|
516
|
+
// Mirrors the engine's ordering: campaigns by id, links by variant, platform and link id.
|
|
517
|
+
listCampaigns(scopeId) {
|
|
518
|
+
return this.read(() => clone(FIXTURE_CAMPAIGNS.filter((c) => c.scopeId === scopeId).sort((a, b) => a.id.localeCompare(b.id))));
|
|
519
|
+
}
|
|
520
|
+
listCampaignLinks(scopeId, campaignId) {
|
|
521
|
+
return this.read(() => clone(sortedLinks(scopeId, campaignId)));
|
|
522
|
+
}
|
|
523
|
+
campaignAttribution(scopeId, campaignId) {
|
|
524
|
+
return this.read(() => {
|
|
525
|
+
const inScope = FIXTURE_CAMPAIGNS.some((c) => c.scopeId === scopeId && c.id === campaignId);
|
|
526
|
+
if (!inScope)
|
|
527
|
+
return null;
|
|
528
|
+
const links = sortedLinks(scopeId, campaignId).map((link) => ({
|
|
529
|
+
linkId: link.id,
|
|
530
|
+
variantId: link.variantId,
|
|
531
|
+
platform: link.platform,
|
|
532
|
+
calendarEntryId: link.calendarEntryId,
|
|
533
|
+
visits: FIXTURE_LINK_VISITS[link.id] ?? 0,
|
|
534
|
+
}));
|
|
535
|
+
return {
|
|
536
|
+
campaignId,
|
|
537
|
+
totalVisits: links.reduce((sum, row) => sum + row.visits, 0),
|
|
538
|
+
links,
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
// Every link of every campaign in the scope: campaigns by id, then the links of each in the engine's order.
|
|
543
|
+
listChannelLinks(scopeId) {
|
|
544
|
+
return this.read(() => {
|
|
545
|
+
const campaigns = FIXTURE_CAMPAIGNS.filter((c) => c.scopeId === scopeId).sort((a, b) => a.id.localeCompare(b.id));
|
|
546
|
+
return clone(campaigns.flatMap((campaign) => sortedLinks(scopeId, campaign.id).map((link) => ({
|
|
547
|
+
link,
|
|
548
|
+
campaignName: campaign.name,
|
|
549
|
+
visits: FIXTURE_LINK_VISITS[link.id] ?? 0,
|
|
550
|
+
}))));
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
listHookTests(scopeId) {
|
|
554
|
+
return this.read(() => clone(FIXTURE_HOOK_TESTS.filter((s) => s.scopeId === scopeId)));
|
|
555
|
+
}
|
|
556
|
+
getHookTest(scopeId, setId) {
|
|
557
|
+
return this.read(() => {
|
|
558
|
+
const set = FIXTURE_HOOK_TESTS.find((s) => s.scopeId === scopeId && s.id === setId);
|
|
559
|
+
return set ? clone(set) : null;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
// ---- Research digests and content library (read only) ---------------------------------------
|
|
563
|
+
// Mirrors the engine's ordering: digests by date then id, content by content id. Nothing is written.
|
|
564
|
+
listDigests(scopeId) {
|
|
565
|
+
return this.read(() => clone(FIXTURE_DIGESTS.filter((d) => d.scopeId === scopeId).sort((a, b) => a.digestDate.localeCompare(b.digestDate) || a.id.localeCompare(b.id))));
|
|
566
|
+
}
|
|
567
|
+
getDigest(scopeId, digestId) {
|
|
568
|
+
return this.read(() => {
|
|
569
|
+
const digest = FIXTURE_DIGESTS.find((d) => d.scopeId === scopeId && d.id === digestId);
|
|
570
|
+
return digest ? clone(digest) : null;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
digestApplications(scopeId, digestId) {
|
|
574
|
+
return this.read(() => {
|
|
575
|
+
const inScope = FIXTURE_DIGESTS.some((d) => d.scopeId === scopeId && d.id === digestId);
|
|
576
|
+
if (!inScope)
|
|
577
|
+
return null;
|
|
578
|
+
return clone(FIXTURE_DIGEST_APPLICATIONS.filter((a) => a.digestId === digestId).sort((a, b) => a.appliedAtUtc.localeCompare(b.appliedAtUtc) || a.digestEntryId.localeCompare(b.digestEntryId)));
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
listContent(scopeId) {
|
|
582
|
+
return this.read(() => clone(FIXTURE_CONTENT.filter((c) => c.scopeId === scopeId).sort((a, b) => a.id.localeCompare(b.id))));
|
|
583
|
+
}
|
|
584
|
+
getContent(scopeId, contentId) {
|
|
585
|
+
return this.read(() => {
|
|
586
|
+
const record = FIXTURE_CONTENT.find((c) => c.scopeId === scopeId && c.id === contentId);
|
|
587
|
+
if (!record)
|
|
588
|
+
return null;
|
|
589
|
+
const variants = FIXTURE_CONTENT_VARIANTS.filter((v) => v.scopeId === scopeId && v.contentId === contentId).sort((a, b) => a.kind.localeCompare(b.kind) || a.createdAtUtc.localeCompare(b.createdAtUtc) || a.id.localeCompare(b.id));
|
|
590
|
+
return clone({ record, variants });
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
contentExperimentSummary(scopeId, contentId) {
|
|
594
|
+
return this.read(() => {
|
|
595
|
+
const summary = FIXTURE_CONTENT_SUMMARIES.find((s) => s.scopeId === scopeId && s.contentId === contentId);
|
|
596
|
+
return summary ? clone(summary) : null;
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
// ---- Text gate, briefs, deliveries and dispatch attempts (read only) ----------------------------
|
|
600
|
+
// Mirrors the engine: a check stores nothing, and a scope with no policy has no gate policy here.
|
|
601
|
+
gatePolicy(scopeId) {
|
|
602
|
+
return this.read(() => {
|
|
603
|
+
const policy = FIXTURE_GATE_POLICIES.find((p) => p.scopeId === scopeId);
|
|
604
|
+
return policy ? clone(policy) : null;
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
checkText(scopeId, subjectId, text) {
|
|
608
|
+
return this.read(() => {
|
|
609
|
+
const policy = FIXTURE_GATE_POLICIES.find((p) => p.scopeId === scopeId);
|
|
610
|
+
if (!policy) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
error: {
|
|
614
|
+
code: "engine_error",
|
|
615
|
+
message: "No gate policy is set for this channel, so nothing can pass.",
|
|
616
|
+
engineCode: "policy_missing",
|
|
617
|
+
},
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
return { ok: true, value: mockGateCheck(policy, subjectId, text) };
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
gateRecords(scopeId, subjectId) {
|
|
624
|
+
return this.read(() => clone(FIXTURE_GATE_RECORDS.filter((r) => r.scopeId === scopeId && r.subjectId === subjectId).sort((a, b) => b.recordSeq - a.recordSeq)));
|
|
625
|
+
}
|
|
626
|
+
listBriefs(scopeId) {
|
|
627
|
+
return this.read(() => clone(FIXTURE_BRIEFS.filter((b) => b.scopeId === scopeId).sort((a, b) => a.requestId.localeCompare(b.requestId))));
|
|
628
|
+
}
|
|
629
|
+
getBrief(scopeId, requestId) {
|
|
630
|
+
return this.read(() => {
|
|
631
|
+
const brief = FIXTURE_BRIEFS.find((b) => b.scopeId === scopeId && b.requestId === requestId);
|
|
632
|
+
return brief ? clone(brief) : null;
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
listDeliveries(scopeId) {
|
|
636
|
+
return this.read(() => clone(FIXTURE_DELIVERIES.filter((d) => d.scopeId === scopeId).sort((a, b) => a.acceptedAtUtc.localeCompare(b.acceptedAtUtc) || a.envelopeId.localeCompare(b.envelopeId))));
|
|
637
|
+
}
|
|
638
|
+
getDelivery(scopeId, envelopeId) {
|
|
639
|
+
return this.read(() => {
|
|
640
|
+
const delivery = FIXTURE_DELIVERIES.find((d) => d.scopeId === scopeId && d.envelopeId === envelopeId);
|
|
641
|
+
return delivery ? clone(delivery) : null;
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
listDispatchAttempts(scopeId, requestId) {
|
|
645
|
+
return this.read(() => clone(FIXTURE_DISPATCH_ATTEMPTS.filter((a) => a.scopeId === scopeId && a.requestId === requestId).sort((a, b) => a.attemptId - b.attemptId)));
|
|
646
|
+
}
|
|
647
|
+
// ---- Channel memory and proof bank (read only) ----------------------------------------------
|
|
648
|
+
// Mirrors the engine: the latest revision by number, history newest first without documents, and proofs
|
|
649
|
+
// sorted by answer id. Nothing is written.
|
|
650
|
+
channelMemory(scopeId) {
|
|
651
|
+
return this.read(() => {
|
|
652
|
+
const latest = FIXTURE_MEMORY.filter((m) => m.scopeId === scopeId).sort((a, b) => b.revision - a.revision)[0];
|
|
653
|
+
return latest ? clone(latest) : null;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
channelMemoryHistory(scopeId) {
|
|
657
|
+
return this.read(() => FIXTURE_MEMORY.filter((m) => m.scopeId === scopeId)
|
|
658
|
+
.sort((a, b) => b.revision - a.revision)
|
|
659
|
+
.map((detail) => revisionOnly(detail)));
|
|
660
|
+
}
|
|
661
|
+
listProofs(scopeId, status) {
|
|
662
|
+
return this.read(() => clone(FIXTURE_PROOFS.filter((p) => p.scopeId === scopeId && (status === null || p.status === status)).sort((a, b) => a.answerId.localeCompare(b.answerId))));
|
|
663
|
+
}
|
|
664
|
+
// ---- Typed briefs and platform variants (read only) ----------------------------------------------
|
|
665
|
+
// Mirrors the engine's ordering: briefs by brief id, variants by platform then variant id, rules by platform.
|
|
666
|
+
// Nothing is written.
|
|
667
|
+
listTypedBriefs(scopeId) {
|
|
668
|
+
return this.read(() => clone(FIXTURE_TYPED_BRIEFS.filter((b) => b.scopeId === scopeId).sort((a, b) => a.briefId.localeCompare(b.briefId))));
|
|
669
|
+
}
|
|
670
|
+
getTypedBrief(scopeId, briefId) {
|
|
671
|
+
return this.read(() => {
|
|
672
|
+
const brief = FIXTURE_TYPED_BRIEFS.find((b) => b.scopeId === scopeId && b.briefId === briefId);
|
|
673
|
+
return brief ? clone(brief) : null;
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
listBriefVariants(scopeId, briefId) {
|
|
677
|
+
return this.read(() => clone(FIXTURE_BRIEF_VARIANTS.filter((v) => v.scopeId === scopeId && v.briefId === briefId).sort((a, b) => a.platform.localeCompare(b.platform) || a.variantId.localeCompare(b.variantId))));
|
|
678
|
+
}
|
|
679
|
+
// ---- Analytics and manual imports (read only) -----------------------------------------------
|
|
680
|
+
// Mirrors the engine: the latest value of each metric of each post, the history oldest first, and the imports in
|
|
681
|
+
// creation order. Nothing is pulled or imported here.
|
|
682
|
+
listAnalyticsPosts(scopeId) {
|
|
683
|
+
return this.read(() => clone(latestAnalytics(scopeId)));
|
|
684
|
+
}
|
|
685
|
+
listAnalyticsObservations(scopeId, jobId) {
|
|
686
|
+
return this.read(() => clone(FIXTURE_ANALYTICS_OBSERVATIONS.filter((row) => row.scopeId === scopeId && row.jobId === jobId).sort((a, b) => a.observedAtUtc.localeCompare(b.observedAtUtc))));
|
|
687
|
+
}
|
|
688
|
+
listMetricImports(scopeId) {
|
|
689
|
+
return this.read(() => clone(FIXTURE_METRIC_IMPORTS.filter((row) => row.scopeId === scopeId).sort((a, b) => a.createdAtUtc.localeCompare(b.createdAtUtc) || a.importId.localeCompare(b.importId))));
|
|
690
|
+
}
|
|
691
|
+
// ---- Publish jobs (read only) ---------------------------------------------------------------
|
|
692
|
+
// Mirrors the engine: every job of the scope, newest first. Nothing is queued, stepped or resolved here.
|
|
693
|
+
listPublishJobs(scopeId) {
|
|
694
|
+
return this.read(() => clone(FIXTURE_PUBLISH_JOBS.filter((job) => job.scopeId === scopeId).sort((a, b) => b.createdAtUtc.localeCompare(a.createdAtUtc) || a.jobId.localeCompare(b.jobId))));
|
|
695
|
+
}
|
|
696
|
+
// ---- Webhook inbox (read only) ---------------------------------------------------------------
|
|
697
|
+
// Mirrors the engine: the newest events of one account, or the unmatched ones, newest first. Nothing is received
|
|
698
|
+
// or marked here. The unmatched list ends with the rows the desktop cannot read, as the engine would keep them.
|
|
699
|
+
listInbox(scopeId) {
|
|
700
|
+
return this.read(() => {
|
|
701
|
+
const events = FIXTURE_INBOX.filter((row) => row.event.scopeId === scopeId)
|
|
702
|
+
.map((row) => row.event)
|
|
703
|
+
.sort((a, b) => b.receivedAtUtc.localeCompare(a.receivedAtUtc) || b.eventId.localeCompare(a.eventId));
|
|
704
|
+
const rows = scopeId === null ? [...events, ...FIXTURE_UNMATCHED_UNREADABLE] : events;
|
|
705
|
+
return clone({ rows, moreAvailable: false });
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
getInboxEvent(scopeId, eventId) {
|
|
709
|
+
return this.read(() => {
|
|
710
|
+
const row = FIXTURE_INBOX.find((r) => r.event.eventId === eventId && r.event.scopeId === scopeId);
|
|
711
|
+
return row ? { eventId, rawJson: row.rawJson } : null;
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
inboxStats() {
|
|
715
|
+
return this.read(() => clone(FIXTURE_INBOX_STATS));
|
|
716
|
+
}
|
|
717
|
+
// ---- Publish pacing (read only) -------------------------------------------------------------
|
|
718
|
+
// Mirrors the engine's answer for the channel and for each account. Nothing is queued, run or changed here.
|
|
719
|
+
listPublishNext(scopeId) {
|
|
720
|
+
return this.read(() => clone(pacingViewFor(scopeId)));
|
|
721
|
+
}
|
|
722
|
+
getPublishQuota(scopeId, platform, remoteAccount) {
|
|
723
|
+
return this.read(() => clone(quotaFor(scopeId, platform, remoteAccount)));
|
|
724
|
+
}
|
|
725
|
+
// ---- Store health (read only)---------------------------------------------------------------
|
|
726
|
+
// Mirrors the engine: the age is whole seconds from the `now` given, and a source older than the limit is stale.
|
|
727
|
+
// Nothing is beaten, backed up or restored here.
|
|
728
|
+
heartbeatStatus(nowUtc, staleAfterSecs) {
|
|
729
|
+
return this.read(() => {
|
|
730
|
+
const now = Date.parse(nowUtc);
|
|
731
|
+
const ageSeconds = Math.floor((now - FIXTURE_HEARTBEAT.updatedMs) / 1000);
|
|
732
|
+
const state = ageSeconds <= staleAfterSecs ? "alive" : "stale";
|
|
733
|
+
return {
|
|
734
|
+
nowUtc,
|
|
735
|
+
staleAfterSeconds: staleAfterSecs,
|
|
736
|
+
allAlive: state === "alive",
|
|
737
|
+
state,
|
|
738
|
+
pid: FIXTURE_HEARTBEAT.pid,
|
|
739
|
+
updatedMs: FIXTURE_HEARTBEAT.updatedMs,
|
|
740
|
+
ageSeconds,
|
|
741
|
+
recordState: FIXTURE_HEARTBEAT.recordState,
|
|
742
|
+
error: null,
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
listRestoreReceipts() {
|
|
747
|
+
return this.read(() => ({ receipts: clone(FIXTURE_RESTORE_RECEIPTS), unreadable: [] }));
|
|
748
|
+
}
|
|
749
|
+
findProposal(scopeId, proposalId) {
|
|
750
|
+
const proposal = this.proposals.get(proposalId);
|
|
751
|
+
return proposal && proposal.scopeId === scopeId ? proposal : undefined;
|
|
752
|
+
}
|
|
753
|
+
isPaused(accountId) {
|
|
754
|
+
return this.accounts.some((a) => a.id === accountId && a.readiness.paused !== null);
|
|
755
|
+
}
|
|
756
|
+
async read(produce) {
|
|
757
|
+
await this.delay();
|
|
758
|
+
if (this.failReads)
|
|
759
|
+
throw new Error("Mock read failure: failReads is set");
|
|
760
|
+
return produce();
|
|
761
|
+
}
|
|
762
|
+
delay() {
|
|
763
|
+
return new Promise((resolve) => setTimeout(resolve, this.latencyMs));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** A regular expression's literal text, so a banned phrase is matched as typed. */
|
|
767
|
+
function escapeRegExp(text) {
|
|
768
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* The mock's text check. It fails a script that contains a banned phrase of the policy as a whole word,
|
|
772
|
+
* case-insensitive, and passes every other script. It simulates no other rule: each other rule passes and
|
|
773
|
+
* says so. The real engine also checks numbers, which this mock does not do.
|
|
774
|
+
*/
|
|
775
|
+
function mockGateCheck(policy, subjectId, text) {
|
|
776
|
+
const hit = policy.bannedPhrases
|
|
777
|
+
.map((phrase) => ({ phrase, match: new RegExp(`\\b${escapeRegExp(phrase)}\\b`, "i").exec(text) }))
|
|
778
|
+
.find((row) => row.match !== null);
|
|
779
|
+
const bannedFailed = hit !== undefined;
|
|
780
|
+
const bannedReason = hit !== undefined && hit.match !== null
|
|
781
|
+
? `Mock check: the banned phrase "${hit.phrase}" occurs at character ${hit.match.index}.`
|
|
782
|
+
: "Mock check: no banned phrase occurs.";
|
|
783
|
+
const simulated = (ruleId, required) => ({
|
|
784
|
+
ruleId,
|
|
785
|
+
required,
|
|
786
|
+
verdict: "pass",
|
|
787
|
+
blocks: false,
|
|
788
|
+
reason: "Mock check: this rule is not simulated, so it passes.",
|
|
789
|
+
});
|
|
790
|
+
const rules = [
|
|
791
|
+
{
|
|
792
|
+
ruleId: "det.text.banned_phrase",
|
|
793
|
+
required: policy.required.bannedPhrase,
|
|
794
|
+
verdict: bannedFailed ? "fail" : "pass",
|
|
795
|
+
blocks: bannedFailed,
|
|
796
|
+
reason: bannedReason,
|
|
797
|
+
},
|
|
798
|
+
simulated("det.text.dash_density", policy.required.dashDensity),
|
|
799
|
+
simulated("det.text.semicolon_density", policy.required.semicolonDensity),
|
|
800
|
+
simulated("det.text.sentence_length_variance", policy.required.sentenceLengthVariance),
|
|
801
|
+
simulated("det.text.tricolon_frequency", policy.required.tricolonFrequency),
|
|
802
|
+
simulated("det.text.stock_opener", policy.required.stockOpener),
|
|
803
|
+
simulated("det.text.claim_proof", true),
|
|
804
|
+
simulated("postright.text.claim_citation", true),
|
|
805
|
+
simulated("postright.text.unannotated_number", true),
|
|
806
|
+
];
|
|
807
|
+
const blockingRules = rules.filter((rule) => rule.blocks).map((rule) => rule.ruleId);
|
|
808
|
+
return {
|
|
809
|
+
subjectId,
|
|
810
|
+
disposition: blockingRules.length > 0 ? "block" : "pass",
|
|
811
|
+
policyRevision: policy.revision,
|
|
812
|
+
textHash: `mock:${fingerprint(text)}`,
|
|
813
|
+
rules,
|
|
814
|
+
blockingRules,
|
|
815
|
+
proofIdsUsed: [],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
/** Hosts a posted link may use, by platform. A platform not listed accepts any https host. Mirrors the engine. */
|
|
819
|
+
const POSTED_HOSTS = {
|
|
820
|
+
reddit: ["reddit.com", "www.reddit.com", "old.reddit.com"],
|
|
821
|
+
x: ["x.com", "twitter.com"],
|
|
822
|
+
threads: ["threads.net", "www.threads.net", "threads.com", "www.threads.com"],
|
|
823
|
+
};
|
|
824
|
+
/** The reason a posted link is refused, or null when it passes the https and host checks. */
|
|
825
|
+
function postedLinkProblem(platform, url) {
|
|
826
|
+
if (/\s/.test(url) || !/^https:\/\//i.test(url))
|
|
827
|
+
return "The link must be an https URL. Nothing changed.";
|
|
828
|
+
const authority = url.slice(8).split(/[/?#]/)[0].toLowerCase();
|
|
829
|
+
if (authority === "" || /[@:]/.test(authority)) {
|
|
830
|
+
return "The link must name a plain host, with no user, port or credentials. Nothing changed.";
|
|
831
|
+
}
|
|
832
|
+
const allowed = POSTED_HOSTS[platform];
|
|
833
|
+
if (allowed && !allowed.includes(authority)) {
|
|
834
|
+
return `The link's host ${authority} is not an allowed ${platform} host. Nothing changed.`;
|
|
835
|
+
}
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
function revisionConflict(draft, revision, action) {
|
|
839
|
+
return {
|
|
840
|
+
ok: false,
|
|
841
|
+
error: {
|
|
842
|
+
code: "revision_conflict",
|
|
843
|
+
message: `This draft is now revision ${draft.revision}, not ${revision}. Review it before ${action}.`,
|
|
844
|
+
currentRevision: draft.revision,
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
function proposalRevisionConflict(proposal, expected) {
|
|
849
|
+
return {
|
|
850
|
+
ok: false,
|
|
851
|
+
error: {
|
|
852
|
+
code: "revision_conflict",
|
|
853
|
+
message: `This suggestion is now revision ${proposal.revision}, not ${expected}. Nothing changed.`,
|
|
854
|
+
currentRevision: proposal.revision,
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/** A memory revision without its document, as memory.history lists it. */
|
|
859
|
+
function revisionOnly(detail) {
|
|
860
|
+
return {
|
|
861
|
+
scopeId: detail.scopeId,
|
|
862
|
+
revision: detail.revision,
|
|
863
|
+
supersedes: detail.supersedes,
|
|
864
|
+
author: detail.author,
|
|
865
|
+
createdAtUtc: detail.createdAtUtc,
|
|
866
|
+
contentHash: detail.contentHash,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* The latest stored value of each metric of each post, as analytics.summary reports it. For one metric and window
|
|
871
|
+
* the row observed last wins; at the same instant the later row in the fixture list wins. A post's age is its
|
|
872
|
+
* observation instant minus its publish instant, in seconds.
|
|
873
|
+
*/
|
|
874
|
+
function latestAnalytics(scopeId) {
|
|
875
|
+
return FIXTURE_ANALYTICS_POSTS.filter((post) => post.scopeId === scopeId)
|
|
876
|
+
.sort((a, b) => a.publishedAtUtc.localeCompare(b.publishedAtUtc) || a.jobId.localeCompare(b.jobId))
|
|
877
|
+
.map((post) => {
|
|
878
|
+
const latest = new Map();
|
|
879
|
+
for (const row of FIXTURE_ANALYTICS_OBSERVATIONS.filter((o) => o.jobId === post.jobId)) {
|
|
880
|
+
const key = `${row.metric}|${row.window ?? "no window"}`;
|
|
881
|
+
const held = latest.get(key);
|
|
882
|
+
if (held === undefined || row.observedAtUtc >= held.observedAtUtc)
|
|
883
|
+
latest.set(key, row);
|
|
884
|
+
}
|
|
885
|
+
const metrics = [...latest.values()]
|
|
886
|
+
.sort((a, b) => a.metric.localeCompare(b.metric))
|
|
887
|
+
.map((row) => ({
|
|
888
|
+
metric: row.metric,
|
|
889
|
+
state: row.state,
|
|
890
|
+
value: row.value,
|
|
891
|
+
observedAtUtc: row.observedAtUtc,
|
|
892
|
+
window: row.window,
|
|
893
|
+
source: row.source,
|
|
894
|
+
pullId: row.pullId,
|
|
895
|
+
importId: row.importId,
|
|
896
|
+
postAgeSeconds: (Date.parse(row.observedAtUtc) - Date.parse(post.publishedAtUtc)) / 1000,
|
|
897
|
+
}));
|
|
898
|
+
return { ...post, metrics };
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
function fail(code, message) {
|
|
902
|
+
return { ok: false, error: { code, message } };
|
|
903
|
+
}
|