@bongos/core 1.24.11 → 1.24.13
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/.bongos-core.json +160 -85
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/api/openapi.json +175 -3
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +92 -20
- package/docs/copy-registry.json +777 -33
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +68 -4
- package/docs/page-readings.json +856 -802
- package/migrations/core_288_credit_log_idea_unique.sql +51 -0
- package/modules/economy/credits.js +6 -5
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/craft-mode.js +2 -2
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +83 -0
- package/modules/hall-ui/public/market-watch-item.js +150 -0
- package/modules/hall-ui/public/market-watch-item.states.json +143 -0
- package/modules/hall-ui/public/market-watch-lib.js +243 -0
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +169 -0
- package/modules/hall-ui/public/market-watch.html +111 -0
- package/modules/hall-ui/public/market-watch.js +129 -0
- package/modules/hall-ui/public/market-watch.states.json +14 -0
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/project-settings.js +2 -2
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +3 -2
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/market-watch.md +40 -0
- package/modules/lifecycle/autopickup.js +46 -24
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/project-settings.js +24 -16
- package/modules/market-watch/db.js +15 -10
- package/modules/market-watch/module.json +2 -1
- package/modules/market-watch/pages.js +323 -0
- package/modules/market-watch/proposals.js +66 -4
- package/modules/market-watch/research.js +27 -0
- package/modules/market-watch/routes/pages.js +116 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/bongos/project-settings.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/autopickup_cap.mjs +122 -60
- package/tests/credit_log_idea_unique_db.mjs +98 -0
- package/tests/fixtures/market-watch-seed-rows.cjs +60 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/market_watch_data_db.mjs +1 -0
- package/tests/market_watch_module.mjs +2 -1
- package/tests/market_watch_pages.mjs +356 -0
- package/tests/market_watch_rules.mjs +63 -3
- package/tests/market_watch_run.mjs +44 -0
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// - the claim is marked but the task is not a page-tweak round (AUTO_PICKUP_NOT_A_TWEAK)
|
|
12
12
|
// - the project has tweak.autopickup off (AUTO_PICKUP_OFF)
|
|
13
13
|
// - this calendar month already holds `cap` marked claims (AUTO_PICKUP_CAP_REACHED)
|
|
14
|
+
// - the claimer already used their rank's per-person allowance (AUTO_PICKUP_CAP_REACHED, cap_from rank)
|
|
14
15
|
// - this round already has two marked claims (AUTO_PICKUP_ROUND_LIMIT)
|
|
15
16
|
//
|
|
16
17
|
// THE LOCK. The count and the claim insert must not interleave with another
|
|
@@ -56,26 +57,37 @@ async function countForRound(exec, taskId) {
|
|
|
56
57
|
return Number(rows[0] && rows[0].n) || 0;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
function
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) {
|
|
68
|
-
return { cap: caps[rank], from: 'rank' };
|
|
69
|
-
}
|
|
70
|
-
return { cap: s.cap, from: 'project' };
|
|
60
|
+
// The same month, narrowed to ONE builder's marked claims (task 1005019): the
|
|
61
|
+
// ledger count a per-person rank allowance is measured against.
|
|
62
|
+
const BUILDER_MONTH_COUNT_SQL = `${MONTH_COUNT_SQL}
|
|
63
|
+
AND c.builder_id = $1`;
|
|
64
|
+
|
|
65
|
+
async function countThisMonthFor(exec, builderId) {
|
|
66
|
+
const { rows } = await exec.query(BUILDER_MONTH_COUNT_SQL, [builderId]);
|
|
67
|
+
return Number(rows[0] && rows[0].n) || 0;
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
70
|
+
// rankAllowance(settings, rank) — the per-PERSON monthly allowance the project set
|
|
71
|
+
// for this rank, or null when it set none (task 1005019, the owner's 2026-10-30
|
|
72
|
+
// ruling on task 1005003). Each builder of the rank gets this many rounds of their
|
|
73
|
+
// own; the project cap still bounds everyone's total. Pure.
|
|
74
|
+
function rankAllowance(settings, rank) {
|
|
75
|
+
const caps = (settings && settings.rank_caps) || {};
|
|
76
|
+
if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) return caps[rank];
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// autoPickupRefusal(client, task, settings, rank, builderId) — null when the marked
|
|
81
|
+
// claim may proceed, else the refusal object claimTask throws. `settings` is the
|
|
82
|
+
// resolved { mode, cap, rank_caps }, read BEFORE the transaction opens (the
|
|
76
83
|
// claimPermissionsFor precedent: a settings read is not part of the claim's
|
|
77
|
-
// consistency). `rank`
|
|
78
|
-
|
|
84
|
+
// consistency). `rank` and `builderId` are the claimer's, read inside it.
|
|
85
|
+
//
|
|
86
|
+
// Two limits, checked in this order, both under the lock:
|
|
87
|
+
// 1. the PROJECT cap, on everyone's marked claims this month (cap_from 'project');
|
|
88
|
+
// 2. the claimer's RANK allowance, on THEIR OWN marked claims this month
|
|
89
|
+
// (cap_from 'rank') — only when the project set one for their rank.
|
|
90
|
+
async function autoPickupRefusal(client, task, settings, rank = null, builderId = null) {
|
|
79
91
|
if (!task || task.source !== PAGE_TWEAK_SOURCE) {
|
|
80
92
|
return { code: 'AUTO_PICKUP_NOT_A_TWEAK', message: 'Only a page tweak round can be claimed with the auto_pickup mark.' };
|
|
81
93
|
}
|
|
@@ -92,12 +104,21 @@ async function autoPickupRefusal(client, task, settings, rank = null) {
|
|
|
92
104
|
};
|
|
93
105
|
}
|
|
94
106
|
const used = await countThisMonth(client);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
if (used >= s.cap) {
|
|
108
|
+
return {
|
|
109
|
+
code: 'AUTO_PICKUP_CAP_REACHED', used, cap: s.cap, cap_from: 'project',
|
|
110
|
+
message: `The project limit is reached: auto-pickup has used this month's ${s.cap} rounds for the whole project, so submitted pages wait for a person until next month.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const allowance = rankAllowance(s, rank);
|
|
114
|
+
if (allowance != null) {
|
|
115
|
+
const mine = await countThisMonthFor(client, builderId);
|
|
116
|
+
if (mine >= allowance) {
|
|
117
|
+
return {
|
|
118
|
+
code: 'AUTO_PICKUP_CAP_REACHED', used: mine, cap: allowance, cap_from: 'rank',
|
|
119
|
+
message: `Your own limit is reached: each ${rank} builder gets ${allowance} auto-pickup rounds a month, and you have used ${mine}, so submitted pages wait for a person until next month.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
101
122
|
}
|
|
102
123
|
return null;
|
|
103
124
|
}
|
|
@@ -106,6 +127,7 @@ module.exports = {
|
|
|
106
127
|
LOCK_KEY,
|
|
107
128
|
MAX_AUTO_APPLIES_PER_ROUND,
|
|
108
129
|
countThisMonth,
|
|
109
|
-
|
|
130
|
+
countThisMonthFor,
|
|
131
|
+
rankAllowance,
|
|
110
132
|
autoPickupRefusal,
|
|
111
133
|
};
|
|
@@ -374,7 +374,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
374
374
|
// limit are counted under a project-wide advisory lock that this transaction
|
|
375
375
|
// holds through the insert below, so two runners cannot both take the last slot.
|
|
376
376
|
if (autoPickup) {
|
|
377
|
-
const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank);
|
|
377
|
+
const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank, builderId);
|
|
378
378
|
if (refusal) throw refusal;
|
|
379
379
|
}
|
|
380
380
|
|
|
@@ -102,9 +102,10 @@ function shipModeRow(resolved) {
|
|
|
102
102
|
|
|
103
103
|
// --- the monthly tweak-round cap (task 1005003) -----------------------------
|
|
104
104
|
//
|
|
105
|
-
// The project cap and one optional
|
|
106
|
-
// page. The ranks come from the kernel's live
|
|
107
|
-
// page; a rank with no number
|
|
105
|
+
// The project cap and one optional per-PERSON limit per builder rank (task
|
|
106
|
+
// 1005019), editable on the settings page. The ranks come from the kernel's live
|
|
107
|
+
// ladder, never re-typed here or on the page; a rank with no number has no limit
|
|
108
|
+
// of its own, only the project's.
|
|
108
109
|
function tweakCapRow(resolved) {
|
|
109
110
|
const r = resolved || {};
|
|
110
111
|
const rankCaps = r.rank_caps || {};
|
|
@@ -118,8 +119,9 @@ function tweakCapRow(resolved) {
|
|
|
118
119
|
max: api.projectSettings.MAX_AUTOPICKUP_CAP,
|
|
119
120
|
ranks: api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: Object.prototype.hasOwnProperty.call(rankCaps, rank) ? rankCaps[rank] : null })),
|
|
120
121
|
},
|
|
121
|
-
sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month. A builder rank can
|
|
122
|
-
+ 'a
|
|
122
|
+
sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month for the whole project. A builder rank can `
|
|
123
|
+
+ 'also have a limit per person: each builder of that rank gets that many rounds of their own, within the project total. '
|
|
124
|
+
+ 'A rank left blank has only the project limit. When a limit is reached, pages wait for a person.',
|
|
123
125
|
source: r.source ? r.source.cap : 'default',
|
|
124
126
|
// Only a cap value this row draws; an unusable on/off switch is not this row's.
|
|
125
127
|
invalid: (r.invalid || []).find((i) => /autopickup_(monthly_cap|rank_cap\.)/.test(String(i && i.where))) || null,
|
|
@@ -567,22 +569,28 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
567
569
|
// rank: any signed-in builder. The runner reads it before it tries a round, and
|
|
568
570
|
// the studio shows the count; it carries no reward internals or file paths.
|
|
569
571
|
//
|
|
570
|
-
// cap is the project's; rank_caps the per-
|
|
571
|
-
// 1005003).
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
572
|
+
// cap is the project's total; rank_caps the per-PERSON allowance each rank gets
|
|
573
|
+
// (tasks 1005003, 1005019). my_rank_cap is the CALLER's own allowance (null when
|
|
574
|
+
// their rank has none) and used_by_me_this_month their own marked claims, and
|
|
575
|
+
// remaining_this_month is the smaller of what is left of the project total and
|
|
576
|
+
// of their own allowance — the number the claim gate will hold them to.
|
|
577
|
+
async function autopickupBody(builder = null) {
|
|
575
578
|
const resolved = await api.projectSettings.resolveTweakAutopickup();
|
|
576
579
|
const used = await autopickup.countThisMonth();
|
|
577
|
-
const
|
|
580
|
+
const allowance = autopickup.rankAllowance(resolved, builder && builder.rank);
|
|
581
|
+
const usedByMe = allowance != null && builder ? await autopickup.countThisMonthFor(api.pool, builder.id) : null;
|
|
582
|
+
const remaining = Math.max(0, Math.min(
|
|
583
|
+
resolved.cap - used,
|
|
584
|
+
allowance != null ? allowance - (usedByMe || 0) : Infinity,
|
|
585
|
+
));
|
|
578
586
|
return {
|
|
579
587
|
mode: resolved.mode,
|
|
580
588
|
cap: resolved.cap,
|
|
581
589
|
rank_caps: resolved.rank_caps || {},
|
|
582
|
-
|
|
583
|
-
|
|
590
|
+
my_rank_cap: allowance,
|
|
591
|
+
used_by_me_this_month: usedByMe,
|
|
584
592
|
used_this_month: used,
|
|
585
|
-
remaining_this_month:
|
|
593
|
+
remaining_this_month: remaining,
|
|
586
594
|
per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
|
|
587
595
|
source: resolved.source,
|
|
588
596
|
invalid: resolved.invalid,
|
|
@@ -593,7 +601,7 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
593
601
|
router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
|
|
594
602
|
try {
|
|
595
603
|
res.set('Cache-Control', 'no-store');
|
|
596
|
-
res.json(await autopickupBody(req.builder
|
|
604
|
+
res.json(await autopickupBody(req.builder || null));
|
|
597
605
|
} catch (err) {
|
|
598
606
|
log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
|
|
599
607
|
res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
|
|
@@ -640,7 +648,7 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
640
648
|
for (const [rank, n] of rankEdits) {
|
|
641
649
|
await api.projectSettings.set(`${api.projectSettings.KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`, n === null ? '' : n, req.builder.id);
|
|
642
650
|
}
|
|
643
|
-
res.json({ ok: true, ...(await autopickupBody(req.builder
|
|
651
|
+
res.json({ ok: true, ...(await autopickupBody(req.builder || null)) });
|
|
644
652
|
} catch (err) {
|
|
645
653
|
log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
|
|
646
654
|
res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
|
|
@@ -130,14 +130,16 @@ const FOUND_IN_SQL = `
|
|
|
130
130
|
JOIN market_watch_items i ON i.id = e.item_id
|
|
131
131
|
WHERE i.project = $1 AND e.run_id IS NOT NULL AND ($2::bigint IS NULL OR e.item_id = $2)`;
|
|
132
132
|
|
|
133
|
+
// `knownRuns` (here, in getItem, caseFile and scoreHistory): the numbered runs when the caller
|
|
134
|
+
// already read them, so one page load reads market_watch_runs once (task 1004939).
|
|
133
135
|
// listItems — the home desk: every tracked item with its latest overall score, last
|
|
134
136
|
// finding, level and streak.
|
|
135
|
-
async function listItems(pool, { project, kind = null, includeRemoved = false, id = null } = {}) {
|
|
137
|
+
async function listItems(pool, { project, kind = null, includeRemoved = false, id = null, knownRuns = null } = {}) {
|
|
136
138
|
need(project);
|
|
137
139
|
const [{ rows: items }, { rows: found }, runs] = await Promise.all([
|
|
138
140
|
pool.query(ITEMS_SQL, [project, kind, includeRemoved, id]),
|
|
139
141
|
pool.query(FOUND_IN_SQL, [project, id]),
|
|
140
|
-
numberedRuns(pool, { project }),
|
|
142
|
+
knownRuns || numberedRuns(pool, { project }),
|
|
141
143
|
]);
|
|
142
144
|
const byItem = new Map();
|
|
143
145
|
for (const f of found) {
|
|
@@ -154,8 +156,8 @@ async function listItems(pool, { project, kind = null, includeRemoved = false, i
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
// getItem — one item, with removed ones included (its page still opens).
|
|
157
|
-
async function getItem(pool, { project, id }) {
|
|
158
|
-
const rows = await listItems(pool, { project, includeRemoved: true, id });
|
|
159
|
+
async function getItem(pool, { project, id, knownRuns = null }) {
|
|
160
|
+
const rows = await listItems(pool, { project, includeRemoved: true, id, knownRuns });
|
|
159
161
|
return rows[0] || null;
|
|
160
162
|
}
|
|
161
163
|
|
|
@@ -168,11 +170,11 @@ const CASE_FILE_SQL = `
|
|
|
168
170
|
ORDER BY e.pinned DESC, e.found_on DESC, e.id DESC`;
|
|
169
171
|
|
|
170
172
|
// caseFile — the item page's sortable table: found, run, type, takeaway, confidence, source.
|
|
171
|
-
async function caseFile(pool, { project, itemId }) {
|
|
173
|
+
async function caseFile(pool, { project, itemId, knownRuns = null }) {
|
|
172
174
|
need(project);
|
|
173
175
|
const [{ rows }, runs] = await Promise.all([
|
|
174
176
|
pool.query(CASE_FILE_SQL, [project, itemId]),
|
|
175
|
-
numberedRuns(pool, { project }),
|
|
177
|
+
knownRuns || numberedRuns(pool, { project }),
|
|
176
178
|
]);
|
|
177
179
|
const runN = new Map(runs.map((r) => [String(r.id), r.n]));
|
|
178
180
|
return rows.map((e) => ({ ...e, id: Number(e.id), run: e.run_id == null ? null : runN.get(String(e.run_id)) ?? null }));
|
|
@@ -185,11 +187,11 @@ const SCORES_SQL = `
|
|
|
185
187
|
WHERE i.project = $1 AND s.item_id = $2`;
|
|
186
188
|
|
|
187
189
|
// scoreHistory — the item page's sparklines, one per dimension plus 'overall'.
|
|
188
|
-
async function scoreHistory(pool, { project, itemId }) {
|
|
190
|
+
async function scoreHistory(pool, { project, itemId, knownRuns = null }) {
|
|
189
191
|
need(project);
|
|
190
192
|
const [{ rows }, runs] = await Promise.all([
|
|
191
193
|
pool.query(SCORES_SQL, [project, itemId]),
|
|
192
|
-
numberedRuns(pool, { project }),
|
|
194
|
+
knownRuns || numberedRuns(pool, { project }),
|
|
193
195
|
]);
|
|
194
196
|
return sparklines(rows, runs);
|
|
195
197
|
}
|
|
@@ -312,7 +314,8 @@ async function settingsView(pool, { project, now = new Date(), recent = 8 } = {}
|
|
|
312
314
|
}
|
|
313
315
|
|
|
314
316
|
const REMAINING_SQL = `
|
|
315
|
-
SELECT i.id, i.project, i.kind, i.name, i.url, i.description, i.why_tracked
|
|
317
|
+
SELECT i.id, i.project, i.kind, i.name, i.url, i.description, i.why_tracked,
|
|
318
|
+
COALESCE(i.facts->'watch_sources', '[]'::jsonb) AS watch_sources
|
|
316
319
|
FROM market_watch_items i
|
|
317
320
|
JOIN market_watch_runs r ON r.id = $2 AND r.project = i.project
|
|
318
321
|
WHERE i.project = $1 AND i.status = 'active'
|
|
@@ -324,7 +327,9 @@ const REMAINING_SQL = `
|
|
|
324
327
|
// no finished row in this run. An item already done is never returned, so it is never
|
|
325
328
|
// researched, or charged, twice (ADR 0393 §2). An item that ERRORED is finished too: the
|
|
326
329
|
// model ran and was paid for (task 1004937), so a resume does not buy it a second call.
|
|
327
|
-
// Each row carries its project, so the run can refuse one that is not its own
|
|
330
|
+
// Each row carries its project, so the run can refuse one that is not its own, and the
|
|
331
|
+
// links members added through an approved add_source proposal (facts.watch_sources,
|
|
332
|
+
// task 1005017), so the run reads those too.
|
|
328
333
|
async function remainingItems(pool, { project, runId }) {
|
|
329
334
|
const { rows } = await pool.query(REMAINING_SQL, [need(project), runId]);
|
|
330
335
|
return rows.map((r) => ({ ...r, id: Number(r.id) }));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"key": "market-watch",
|
|
3
3
|
"title": "Market Watch (a weekly AI watch on a project's market)",
|
|
4
|
-
"description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940), its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
|
|
4
|
+
"description": "A weekly AI watch on a project's competitors, partners and topics, with a sourced log per tracked item and a weekly question to members (goal 1000137). Its data model (tables, read queries and the hub seed of the 2026-10-28 research), its weekly run (an hourly poller that researches each tracked item on the project's own Anthropic key, under the monthly cap), the weekly ask card in the hall's attention inbox (task 1004940), its home and tracked-item pages (task 1004939), its rules page (the watch rules in plain words, members' proposed changes, and the owner's or the government's decision on each, task 1004941) and the owner's settings (on/off, monthly cap, cadence, the project key; task 1004938) are in. Off by default, and it cannot be turned on behind an open join door with no payer, because every run will spend the project's AI budget.",
|
|
5
5
|
"version": "0.4.0",
|
|
6
6
|
"coreVersion": "^1.22.0",
|
|
7
7
|
"category": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"migrations": true,
|
|
16
16
|
"routes": [
|
|
17
17
|
"ask",
|
|
18
|
+
"pages",
|
|
18
19
|
"rules",
|
|
19
20
|
"settings"
|
|
20
21
|
],
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/market-watch/pages.js — what the Market Watch home (the watchtower desk) and
|
|
4
|
+
// the tracked-item page (the dossier) read and write (task 1004939, goal 1000137). The
|
|
5
|
+
// designs of record are docs/design/mocks/market-watch/home.html (option D) and item.html
|
|
6
|
+
// (option B).
|
|
7
|
+
//
|
|
8
|
+
// READS go through db.js (levels, streaks, run numbers, sparklines, spend and the next
|
|
9
|
+
// due date are derived there) plus the two queries below that only these pages need: the
|
|
10
|
+
// latest score per item and dimension (the landscape scope's dots) and the entries found
|
|
11
|
+
// in the last seven days (this week's reports). Every query filters on the project key.
|
|
12
|
+
//
|
|
13
|
+
// SHAPES are pure (shapeHome, shapeItem): the routes call them on what the reads return,
|
|
14
|
+
// and tests/market_watch_pages.mjs calls them on the committed seed, so the page's
|
|
15
|
+
// numbers are checked without a database.
|
|
16
|
+
//
|
|
17
|
+
// THE ONE WRITE is a proposal (market_watch_proposals, origin 'member'). Every button on
|
|
18
|
+
// the item page that would change what is watched files one: re-score, move between
|
|
19
|
+
// partner and competitor, add a source, correct an entry, and stop tracking (a
|
|
20
|
+
// remove_item). The home's "send a new scout" files an add_item. Approving them is the
|
|
21
|
+
// rules page's (task 1004941); nothing here changes an item, a score or an entry.
|
|
22
|
+
|
|
23
|
+
const db = require('./db');
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const WEEK_DAYS = 7;
|
|
27
|
+
const KINDS = Object.freeze(['competitor', 'partner', 'topic']);
|
|
28
|
+
const REPORT_LIMIT = 8;
|
|
29
|
+
const STREAK_SLOTS = 9;
|
|
30
|
+
const REASON_MAX = 280;
|
|
31
|
+
const NAME_MAX = 120;
|
|
32
|
+
const URL_MAX = 500;
|
|
33
|
+
const DIM_RE = /^[a-z][a-z0-9_]{0,59}$/;
|
|
34
|
+
// The four the item page's "Propose a change" offers, then the two the other buttons file.
|
|
35
|
+
const ITEM_KINDS = Object.freeze(['rescore', 'move_kind', 'add_source', 'correct_entry', 'remove_item']);
|
|
36
|
+
const PROPOSAL_KINDS = Object.freeze([...ITEM_KINDS, 'add_item']);
|
|
37
|
+
|
|
38
|
+
const num = (v) => (v == null ? null : Number(v));
|
|
39
|
+
const isoDay = (d) => (d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10));
|
|
40
|
+
|
|
41
|
+
// ---- reads only these pages need -------------------------------------------
|
|
42
|
+
|
|
43
|
+
const LATEST_SCORES_SQL = `
|
|
44
|
+
SELECT DISTINCT ON (s.item_id, s.dimension) s.item_id, s.dimension, s.value
|
|
45
|
+
FROM market_watch_score_snapshots s
|
|
46
|
+
JOIN market_watch_items i ON i.id = s.item_id
|
|
47
|
+
JOIN market_watch_runs r ON r.id = s.run_id
|
|
48
|
+
WHERE i.project = $1 AND i.status <> 'removed'
|
|
49
|
+
ORDER BY s.item_id, s.dimension, r.created_at DESC, r.id DESC`;
|
|
50
|
+
|
|
51
|
+
async function latestScores(pool, { project }) {
|
|
52
|
+
const { rows } = await pool.query(LATEST_SCORES_SQL, [project]);
|
|
53
|
+
return rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const WEEK_ENTRIES_SQL = `
|
|
57
|
+
SELECT e.id, e.item_id, e.run_id, e.type, e.found_on, e.takeaway, e.confidence,
|
|
58
|
+
e.source_url, e.source_label, e.pinned
|
|
59
|
+
FROM market_watch_log_entries e
|
|
60
|
+
JOIN market_watch_items i ON i.id = e.item_id
|
|
61
|
+
WHERE i.project = $1 AND i.status <> 'removed' AND e.found_on >= $2::date
|
|
62
|
+
ORDER BY e.found_on DESC, e.id DESC
|
|
63
|
+
LIMIT 1000`;
|
|
64
|
+
|
|
65
|
+
async function weekEntries(pool, { project, now = new Date() }) {
|
|
66
|
+
const since = isoDay(new Date(new Date(now).getTime() - WEEK_DAYS * DAY_MS));
|
|
67
|
+
const { rows } = await pool.query(WEEK_ENTRIES_SQL, [project, since]);
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- pure shapes ------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
// The labels a kind's dimensions carry, from the rules in use: { <key>: label }.
|
|
74
|
+
function rubricLabels(rules, kind) {
|
|
75
|
+
const rubric = (rules && rules.active && rules.active.rubric) || {};
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const d of Array.isArray(rubric[kind]) ? rubric[kind] : []) if (d && d.key) out[d.key] = String(d.label || d.key);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// One report per item: a pinned entry first, then the surest, then an insight over a
|
|
82
|
+
// datapoint, then the newest. Items ordered by their overall score, best first.
|
|
83
|
+
const TYPE_RANK = { insight: 0, news: 1, datapoint: 2 };
|
|
84
|
+
function pickReports(entries, itemsById, limit = REPORT_LIMIT) {
|
|
85
|
+
const best = new Map();
|
|
86
|
+
const better = (a, b) => (Number(!!b.pinned) - Number(!!a.pinned))
|
|
87
|
+
|| (Number(b.confidence) - Number(a.confidence))
|
|
88
|
+
|| ((TYPE_RANK[a.type] ?? 3) - (TYPE_RANK[b.type] ?? 3))
|
|
89
|
+
|| (isoDay(b.found_on) > isoDay(a.found_on) ? 1 : isoDay(b.found_on) < isoDay(a.found_on) ? -1 : 0)
|
|
90
|
+
|| (Number(b.id) - Number(a.id));
|
|
91
|
+
for (const e of entries) {
|
|
92
|
+
const it = itemsById.get(String(e.item_id));
|
|
93
|
+
if (!it) continue;
|
|
94
|
+
const k = String(e.item_id);
|
|
95
|
+
if (!best.has(k) || better(e, best.get(k)) < 0) best.set(k, e);
|
|
96
|
+
}
|
|
97
|
+
const all = [...best.values()]
|
|
98
|
+
.map((e) => {
|
|
99
|
+
const it = itemsById.get(String(e.item_id));
|
|
100
|
+
return {
|
|
101
|
+
id: Number(e.id),
|
|
102
|
+
item_id: Number(e.item_id),
|
|
103
|
+
item_name: it.name,
|
|
104
|
+
kind: it.kind,
|
|
105
|
+
overall: it.overall,
|
|
106
|
+
type: e.type,
|
|
107
|
+
takeaway: e.takeaway,
|
|
108
|
+
confidence: Number(e.confidence),
|
|
109
|
+
found_on: isoDay(e.found_on),
|
|
110
|
+
source_url: e.source_url,
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
.sort((a, b) => ((b.overall ?? -1) - (a.overall ?? -1)) || a.item_name.localeCompare(b.item_name));
|
|
114
|
+
// Competitors and partners are scored on different scales, so one ranking across both
|
|
115
|
+
// would be a false comparison: take each kind's best in turn instead.
|
|
116
|
+
const queues = KINDS.map((k) => all.filter((r) => r.kind === k));
|
|
117
|
+
const out = [];
|
|
118
|
+
while (out.length < limit && queues.some((q) => q.length)) {
|
|
119
|
+
for (const q of queues) if (q.length && out.length < limit) out.push(q.shift());
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// shapeHome — the watchtower desk: scouts, the run streak, the next sweep and budget,
|
|
125
|
+
// the landscape's axes and dots, and this week's reports.
|
|
126
|
+
function shapeHome({ items, runs, settings, scores, entries, rules, now = new Date() }) {
|
|
127
|
+
const live = items.filter((it) => it.status !== 'removed');
|
|
128
|
+
const itemsById = new Map(live.map((it) => [String(it.id), it]));
|
|
129
|
+
const reports = pickReports(entries, itemsById);
|
|
130
|
+
const weekByKind = {};
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
const it = itemsById.get(String(e.item_id));
|
|
133
|
+
if (it) (weekByKind[it.kind] = weekByKind[it.kind] || new Set()).add(String(it.id));
|
|
134
|
+
}
|
|
135
|
+
const scouts = KINDS.map((kind) => {
|
|
136
|
+
const mine = live.filter((it) => it.kind === kind);
|
|
137
|
+
return {
|
|
138
|
+
kind,
|
|
139
|
+
watched: mine.filter((it) => it.status === 'active').length,
|
|
140
|
+
level: mine.reduce((m, it) => Math.max(m, it.level || 0), 0),
|
|
141
|
+
reports: weekByKind[kind] ? weekByKind[kind].size : 0,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
const ranRuns = runs.filter((r) => r.n != null);
|
|
145
|
+
const streak = runs.slice(-STREAK_SLOTS).map((r) => ({ n: r.n, ran: r.n != null, date: isoDay(r.created_at), reason: r.reason || null }));
|
|
146
|
+
|
|
147
|
+
const labels = rubricLabels(rules, 'competitor');
|
|
148
|
+
const byItem = new Map();
|
|
149
|
+
for (const s of scores) {
|
|
150
|
+
const k = String(s.item_id);
|
|
151
|
+
if (!byItem.has(k)) byItem.set(k, {});
|
|
152
|
+
byItem.get(k)[s.dimension] = num(s.value);
|
|
153
|
+
}
|
|
154
|
+
const seen = new Set(Object.keys(labels));
|
|
155
|
+
const dots = live.filter((it) => it.kind === 'competitor').map((it) => {
|
|
156
|
+
const sc = byItem.get(String(it.id)) || {};
|
|
157
|
+
for (const k of Object.keys(sc)) if (k !== 'overall') seen.add(k);
|
|
158
|
+
return { id: it.id, name: it.name, overall: it.overall, level: it.level, scores: sc };
|
|
159
|
+
});
|
|
160
|
+
const axes = [...seen].map((key) => ({ key, label: labels[key] || key.replace(/_/g, ' ') }));
|
|
161
|
+
axes.push({ key: 'overall', label: 'overall likeness' });
|
|
162
|
+
|
|
163
|
+
const s = settings.settings;
|
|
164
|
+
const cap = Number(s.monthly_cap_usd) || 0;
|
|
165
|
+
const spent = Number(settings.spentThisMonthUsd) || 0;
|
|
166
|
+
return {
|
|
167
|
+
generated_at: new Date(now).toISOString(),
|
|
168
|
+
watch: { enabled: !!s.enabled, configured: !!s.configured, cadence: s.cadence },
|
|
169
|
+
weeks_watched: ranRuns.length,
|
|
170
|
+
scouts,
|
|
171
|
+
streak,
|
|
172
|
+
next_sweep: { due: settings.nextRunDue ? new Date(settings.nextRunDue).toISOString() : null, enabled: !!s.enabled },
|
|
173
|
+
budget: { cap_usd: cap, spent_usd: Math.round(spent * 100) / 100, left_usd: Math.max(0, Math.round((cap - spent) * 100) / 100) },
|
|
174
|
+
axes,
|
|
175
|
+
dots,
|
|
176
|
+
reports,
|
|
177
|
+
hot: reports.map((r) => r.item_id),
|
|
178
|
+
// Every tracked item by name: the desk's "open a file" picker, the way to any item
|
|
179
|
+
// page that is not a dot (a partner, a topic) or a report this week.
|
|
180
|
+
index: live.map((it) => ({ id: it.id, kind: it.kind, name: it.name }))
|
|
181
|
+
.sort((a, b) => (KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind)) || a.name.localeCompare(b.name)),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// shapeItem — the dossier: the card (level, streak, overall line, one sparkline per
|
|
186
|
+
// dimension), why it is watched, its known facts and the sortable case file.
|
|
187
|
+
function shapeItem({ item, entries, history, rules, openProposals = 0 }) {
|
|
188
|
+
const labels = rubricLabels(rules, item.kind);
|
|
189
|
+
const series = history.series || {};
|
|
190
|
+
const keys = [...Object.keys(labels), ...Object.keys(series).filter((k) => k !== 'overall' && !(k in labels))];
|
|
191
|
+
const point = (vals) => {
|
|
192
|
+
const v = (vals || []).filter((x) => x != null);
|
|
193
|
+
return { first: v.length ? v[0] : null, latest: v.length ? v[v.length - 1] : null };
|
|
194
|
+
};
|
|
195
|
+
const dimensions = keys.map((key) => ({ key, label: labels[key] || key.replace(/_/g, ' '), series: series[key] || [], ...point(series[key]) }));
|
|
196
|
+
return {
|
|
197
|
+
item: {
|
|
198
|
+
id: item.id, kind: item.kind, name: item.name, url: item.url, category: item.category,
|
|
199
|
+
description: item.description, why_tracked: item.why_tracked, facts: item.facts || {},
|
|
200
|
+
status: item.status, added_via: item.added_via, created_at: item.created_at,
|
|
201
|
+
overall: item.overall, level: item.level, streak: item.streak,
|
|
202
|
+
},
|
|
203
|
+
runs: (history.runs || []).map((r) => ({ n: r.n, date: isoDay(r.date) })),
|
|
204
|
+
overall: { series: series.overall || [], ...point(series.overall) },
|
|
205
|
+
dimensions,
|
|
206
|
+
moved: dimensions.filter((d) => d.first != null && d.latest != null && d.first !== d.latest).length,
|
|
207
|
+
entries: entries.map((e) => ({
|
|
208
|
+
id: Number(e.id), run: e.run, type: e.type, found_on: isoDay(e.found_on), takeaway: e.takeaway,
|
|
209
|
+
dimension: e.dimension, source_url: e.source_url, source_label: e.source_label,
|
|
210
|
+
confidence: Number(e.confidence), pinned: !!e.pinned,
|
|
211
|
+
})),
|
|
212
|
+
open_proposals: Number(openProposals) || 0,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- the views --------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
// Each view reads the numbered runs ONCE and hands them to every db.js read that would
|
|
219
|
+
// otherwise read market_watch_runs again (listItems, getItem, caseFile, scoreHistory).
|
|
220
|
+
async function homeView(pool, { project, now = new Date() }) {
|
|
221
|
+
const runs = await db.numberedRuns(pool, { project });
|
|
222
|
+
const [items, settings, scores, entries, rules] = await Promise.all([
|
|
223
|
+
db.listItems(pool, { project, knownRuns: runs }),
|
|
224
|
+
db.settingsView(pool, { project, now }),
|
|
225
|
+
latestScores(pool, { project }),
|
|
226
|
+
weekEntries(pool, { project, now }),
|
|
227
|
+
db.rules(pool, { project }),
|
|
228
|
+
]);
|
|
229
|
+
return shapeHome({ items, runs, settings, scores, entries, rules, now });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function itemView(pool, { project, id }) {
|
|
233
|
+
const runs = await db.numberedRuns(pool, { project });
|
|
234
|
+
const item = await db.getItem(pool, { project, id, knownRuns: runs });
|
|
235
|
+
if (!item) return null;
|
|
236
|
+
const [entries, history, rules, open] = await Promise.all([
|
|
237
|
+
db.caseFile(pool, { project, itemId: id, knownRuns: runs }),
|
|
238
|
+
db.scoreHistory(pool, { project, itemId: id, knownRuns: runs }),
|
|
239
|
+
db.rules(pool, { project }),
|
|
240
|
+
db.proposals(pool, { project, status: 'open', itemId: id, limit: 100 }),
|
|
241
|
+
]);
|
|
242
|
+
return shapeItem({ item, entries, history, rules, openProposals: open.length });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- proposals --------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
const oneLine = (s) => String(s == null ? '' : s).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
248
|
+
const isHttp = (u) => /^https?:\/\/[^\s/]+\.[^\s]+$/i.test(u);
|
|
249
|
+
|
|
250
|
+
// normalizeProposal(body, { item, rubricKeys }) → { ok, value } | { ok: false, error, message }.
|
|
251
|
+
// `item` is the tracked item the proposal is about (null for add_item); rubricKeys is the
|
|
252
|
+
// set of dimensions the item's kind is scored on. Everything a member typed is trimmed
|
|
253
|
+
// to one line and bounded; nothing here trusts the client's idea of the item's kind.
|
|
254
|
+
function normalizeProposal(body, { item = null, rubricKeys = null } = {}) {
|
|
255
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
256
|
+
const kind = String(b.kind || '');
|
|
257
|
+
if (!PROPOSAL_KINDS.includes(kind)) return { ok: false, error: 'invalid_kind', message: 'Pick what should change.' };
|
|
258
|
+
const reason = oneLine(b.reason);
|
|
259
|
+
if (!reason) return { ok: false, error: 'reason_required', message: 'Say why, in one line.' };
|
|
260
|
+
if (reason.length > REASON_MAX) return { ok: false, error: 'reason_too_long', message: `Keep the reason to ${REASON_MAX} characters.` };
|
|
261
|
+
if (kind === 'add_item') {
|
|
262
|
+
const name = oneLine(b.name);
|
|
263
|
+
const scout = String(b.scout || '');
|
|
264
|
+
const url = oneLine(b.url);
|
|
265
|
+
if (!KINDS.includes(scout)) return { ok: false, error: 'invalid_scout', message: 'Pick a competitor, a partner or a topic.' };
|
|
266
|
+
if (!name || name.length > NAME_MAX) return { ok: false, error: 'invalid_name', message: `Name it in up to ${NAME_MAX} characters.` };
|
|
267
|
+
if (url && (url.length > URL_MAX || !isHttp(url))) return { ok: false, error: 'invalid_url', message: 'A link starts with http:// or https://.' };
|
|
268
|
+
return { ok: true, value: { kind, item_id: null, log_entry_id: null, reason, payload: { kind: scout, name, url: url || null } } };
|
|
269
|
+
}
|
|
270
|
+
if (!item) return { ok: false, error: 'item_not_found', message: 'That item is not tracked here.' };
|
|
271
|
+
const base = { kind, item_id: item.id, log_entry_id: null, reason };
|
|
272
|
+
if (kind === 'rescore') {
|
|
273
|
+
const dimension = String(b.dimension || '');
|
|
274
|
+
const value = Number(b.value);
|
|
275
|
+
const known = dimension === 'overall' || (rubricKeys ? rubricKeys.has(dimension) : DIM_RE.test(dimension));
|
|
276
|
+
if (!DIM_RE.test(dimension) || !known) return { ok: false, error: 'invalid_dimension', message: 'Pick a score to change.' };
|
|
277
|
+
if (b.value === '' || b.value == null || !Number.isFinite(value) || value < 0 || value > 10) return { ok: false, error: 'invalid_value', message: 'A score is from 0 to 10.' };
|
|
278
|
+
return { ok: true, value: { ...base, payload: { dimension, value: Math.round(value * 10) / 10 } } };
|
|
279
|
+
}
|
|
280
|
+
if (kind === 'move_kind') {
|
|
281
|
+
if (item.kind === 'topic') return { ok: false, error: 'cannot_move_topic', message: 'A topic is not a partner or a competitor.' };
|
|
282
|
+
return { ok: true, value: { ...base, payload: { from: item.kind, to: item.kind === 'partner' ? 'competitor' : 'partner' } } };
|
|
283
|
+
}
|
|
284
|
+
if (kind === 'add_source') {
|
|
285
|
+
const url = oneLine(b.url);
|
|
286
|
+
if (!url || url.length > URL_MAX || !isHttp(url)) return { ok: false, error: 'invalid_url', message: 'A source is a link that starts with http:// or https://.' };
|
|
287
|
+
return { ok: true, value: { ...base, payload: { source_url: url } } };
|
|
288
|
+
}
|
|
289
|
+
if (kind === 'correct_entry') {
|
|
290
|
+
const entry = String(b.log_entry_id || '');
|
|
291
|
+
if (!/^[1-9]\d{0,17}$/.test(entry)) return { ok: false, error: 'invalid_entry', message: 'Pick the entry to correct.' };
|
|
292
|
+
return { ok: true, value: { ...base, log_entry_id: Number(entry), payload: {} } };
|
|
293
|
+
}
|
|
294
|
+
return { ok: true, value: { ...base, payload: {} } }; // remove_item: stop tracking
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The INSERT checks ownership in SQL: the item must be this project's, and an entry to
|
|
298
|
+
// correct must be that item's. A row from another project inserts nothing.
|
|
299
|
+
const INSERT_ITEM_PROPOSAL_SQL = `
|
|
300
|
+
INSERT INTO market_watch_proposals (project, kind, origin, item_id, log_entry_id, payload, reason, proposed_by)
|
|
301
|
+
SELECT $1, $2, 'member', i.id, $4, $5::jsonb, $6, $7
|
|
302
|
+
FROM market_watch_items i
|
|
303
|
+
WHERE i.id = $3 AND i.project = $1
|
|
304
|
+
AND ($4::bigint IS NULL OR EXISTS (SELECT 1 FROM market_watch_log_entries e WHERE e.id = $4 AND e.item_id = i.id))
|
|
305
|
+
RETURNING id, kind, status, created_at`;
|
|
306
|
+
const INSERT_NEW_ITEM_PROPOSAL_SQL = `
|
|
307
|
+
INSERT INTO market_watch_proposals (project, kind, origin, payload, reason, proposed_by)
|
|
308
|
+
VALUES ($1, 'add_item', 'member', $2::jsonb, $3, $4)
|
|
309
|
+
RETURNING id, kind, status, created_at`;
|
|
310
|
+
|
|
311
|
+
async function insertProposal(pool, { project, proposal, builderId }) {
|
|
312
|
+
const p = proposal;
|
|
313
|
+
const { rows: [row] } = p.kind === 'add_item'
|
|
314
|
+
? await pool.query(INSERT_NEW_ITEM_PROPOSAL_SQL, [project, JSON.stringify(p.payload), p.reason, builderId])
|
|
315
|
+
: await pool.query(INSERT_ITEM_PROPOSAL_SQL, [project, p.kind, p.item_id, p.log_entry_id, JSON.stringify(p.payload), p.reason, builderId]);
|
|
316
|
+
return row ? { ...row, id: Number(row.id) } : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = {
|
|
320
|
+
homeView, itemView, insertProposal,
|
|
321
|
+
shapeHome, shapeItem, pickReports, rubricLabels, normalizeProposal,
|
|
322
|
+
PROPOSAL_KINDS, KINDS,
|
|
323
|
+
};
|