Skip to content

Commit 9564613

Browse files
committed
Nightly Knowledge Influencer report for nichedb.dev
What is waiting on a human first, then niches, contributions, agent questions, money and the index. Sent by the mail command to anthony@profullstack.com at 05:05 UTC, which is late evening Pacific. The database is on Railway's private network with no public proxy and the image has no psql, so the stats script is shipped into the running container over `railway ssh` and run there with bun. It travels base64-encoded because quoting a template literal through ssh mangles every ${...}. Cron gets almost no environment, so PATH is set explicitly and both tools are checked before use; verified by running it under `env -i`. A failure emails too, because the first sign of a broken nightly job is otherwise silence. The report says when the zeroes are circumstance rather than fault: no crawl pass has been sold yet, so an empty money section is the truth about the business and not a broken query.
1 parent 136cbab commit 9564613

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

‎bin/nichedb-nightly‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Nightly Knowledge Influencer report for nichedb.dev, by email.
4+
#
5+
# nichedb's Postgres is on Railway's private network with no public proxy, so
6+
# this cannot be queried from here directly. The stats script is shipped into
7+
# the running container over `railway ssh` and executed there with `bun`, which
8+
# is the only runtime in that image (there is no psql). See the
9+
# railway-ssh-host-key-workaround note.
10+
#
11+
# Cron gets almost no environment, so PATH is set explicitly and every tool is
12+
# checked before use. A silent nightly job that stopped working months ago is
13+
# worse than no nightly job.
14+
#
15+
# nichedb-nightly send the report
16+
# nichedb-nightly --dry-run print it instead of sending
17+
#
18+
set -uo pipefail
19+
20+
export PATH="$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin"
21+
22+
PROJECT=fb518b20-ed8b-4066-a485-85c1b627063f
23+
SERVICE=fd8ab4c1-3787-461a-a9a6-3564603deee3
24+
ENVIRONMENT=production
25+
TO="${NICHEDB_REPORT_TO:-anthony@profullstack.com}"
26+
DRY_RUN=0
27+
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
28+
29+
fail() {
30+
# A failure has to reach a human too, or the first thing anyone knows is that
31+
# the reports quietly stopped.
32+
echo "nichedb-nightly: $1" >&2
33+
if [ "$DRY_RUN" = "0" ] && command -v mail >/dev/null 2>&1; then
34+
printf 'The nightly nichedb report could not be produced.\n\n%s\n' "$1" |
35+
mail send --to "$TO" --subject "NicheDB nightly FAILED" --file /dev/stdin >/dev/null 2>&1
36+
fi
37+
exit 1
38+
}
39+
40+
command -v railway >/dev/null 2>&1 || fail "railway CLI is not on PATH"
41+
command -v mail >/dev/null 2>&1 || fail "the mail command is not on PATH"
42+
43+
WORK=$(mktemp -d) || fail "could not make a temp dir"
44+
trap 'rm -rf "$WORK"' EXIT
45+
46+
cat > "$WORK/stats.mjs" <<'STATS'
47+
import { SQL } from 'bun';
48+
const sql = new SQL({ url: process.env.DATABASE_URL });
49+
50+
const money = (c) => `$${((Number(c) || 0) / 100).toFixed(2)}`;
51+
const one = async (q) => (await q)[0] ?? {};
52+
const out = [];
53+
const say = (s = '') => out.push(s);
54+
55+
const now = new Date();
56+
say(`NICHEDB - KNOWLEDGE INFLUENCERS`);
57+
say(now.toISOString().replace('T', ' ').slice(0, 16) + ' UTC');
58+
say();
59+
60+
// What is sitting on a human. This goes first because it is the only part
61+
// anyone can act on tonight.
62+
const todo = await one(sql`
63+
select
64+
(select count(*) from niche_claims where status = 'pending')::int as claims,
65+
(select count(*) from contribution_events where status = 'pending')::int as contribs,
66+
(select count(*) from agent_questions where status in ('open','researching'))::int as questions,
67+
(select count(*) from payout_accounts where address is not null and verified_at is null)::int as addresses,
68+
(select coalesce(sum(amount_minor),0) from revenue_allocations
69+
where status in ('accrued','eligible'))::bigint as owed,
70+
(select count(distinct influencer_id) from revenue_allocations
71+
where status in ('accrued','eligible'))::int as owed_people`);
72+
const waiting =
73+
todo.claims + todo.contribs + todo.questions + todo.addresses + (Number(todo.owed) > 0 ? 1 : 0);
74+
75+
say('NEEDS A HUMAN');
76+
if (waiting === 0) say(' nothing waiting');
77+
else {
78+
if (todo.claims) say(` ${todo.claims} niche claim(s) awaiting a decision /admin/knowledge`);
79+
if (todo.contribs) say(` ${todo.contribs} contribution(s) awaiting verification /admin/knowledge`);
80+
if (todo.questions) say(` ${todo.questions} agent question(s) unanswered`);
81+
if (todo.addresses) say(` ${todo.addresses} payout address(es) awaiting confirmation /admin/payouts`);
82+
if (Number(todo.owed) > 0)
83+
say(` ${money(todo.owed)} owed across ${todo.owed_people} person/people /admin/payouts`);
84+
}
85+
say();
86+
87+
const n = await one(sql`
88+
select count(*)::int as total,
89+
count(*) filter (where status = 'operated')::int as operated,
90+
count(*) filter (where status = 'open')::int as open
91+
from niches`);
92+
const members = await one(sql`
93+
select count(*)::int as active,
94+
count(distinct user_id)::int as people
95+
from niche_members where status = 'active'`);
96+
say('NICHES');
97+
say(` ${n.total} total: ${n.operated} operated, ${n.open} open for a claim`);
98+
say(` ${members.active} active membership(s) across ${members.people} person/people`);
99+
say();
100+
101+
const c = await one(sql`
102+
select count(*) filter (where status = 'verified')::int as verified,
103+
count(*) filter (where status = 'pending')::int as pending,
104+
count(*) filter (where status = 'verified' and created_at > now() - interval '24 hours')::int as verified_24h,
105+
count(*) filter (where created_at > now() - interval '24 hours')::int as new_24h,
106+
coalesce(sum(points) filter (where status = 'verified'),0)::int as points
107+
from contribution_events`);
108+
say('CONTRIBUTIONS');
109+
say(` last 24h: ${c.new_24h} recorded, ${c.verified_24h} verified`);
110+
say(` all time: ${c.verified} verified, ${c.pending} pending, ${c.points} points awarded`);
111+
const topTypes = await sql`
112+
select event_type, count(*)::int as n from contribution_events
113+
where status = 'verified' group by event_type order by count(*) desc limit 4`;
114+
if (topTypes.length) say(` most common: ${topTypes.map((t) => `${t.event_type} x${t.n}`).join(', ')}`);
115+
say();
116+
117+
const q = await one(sql`
118+
select count(*)::int as total,
119+
count(*) filter (where status in ('open','researching'))::int as open,
120+
count(*) filter (where status = 'answered')::int as answered,
121+
count(*) filter (where created_at > now() - interval '24 hours')::int as asked_24h,
122+
coalesce(extract(day from now() - min(created_at) filter (
123+
where status in ('open','researching'))), 0)::int as oldest_days
124+
from agent_questions`);
125+
const answers = await one(sql`
126+
select count(*) filter (where kind = 'answered')::int as answered,
127+
count(*) filter (where kind <> 'answered')::int as declined
128+
from agent_answers`);
129+
say('AGENT QUESTIONS');
130+
say(` ${q.asked_24h} asked in the last 24h; ${q.open} still open` +
131+
(q.open && q.oldest_days ? ` (oldest ${q.oldest_days}d)` : ''));
132+
say(` all time: ${q.total} asked, ${q.answered} settled, ${answers.declined} declined for context`);
133+
say();
134+
135+
const rev = await one(sql`
136+
select count(*)::int as events,
137+
coalesce(sum(gross_amount_minor),0)::bigint as gross,
138+
coalesce(sum(net_amount_minor),0)::bigint as net,
139+
coalesce(sum(gross_amount_minor) filter (
140+
where occurred_at > now() - interval '24 hours'),0)::bigint as gross_24h,
141+
coalesce(sum(net_amount_minor) filter (where niche_id is not null),0)::bigint as attributed
142+
from revenue_events`);
143+
const alloc = await one(sql`
144+
select coalesce(sum(amount_minor) filter (
145+
where status in ('accrued','eligible') and influencer_id is not null),0)::bigint as owed,
146+
coalesce(sum(amount_minor) filter (
147+
where status in ('scheduled','processing')),0)::bigint as scheduled,
148+
coalesce(sum(amount_minor) filter (where status = 'paid'),0)::bigint as paid,
149+
coalesce(sum(amount_minor) filter (where allocation_type = 'platform'),0)::bigint as platform
150+
from revenue_allocations`);
151+
const sales = await one(sql`
152+
select count(*)::int as n,
153+
coalesce(sum(total_cents),0)::int as cents,
154+
count(*) filter (where created_at > now() - interval '24 hours')::int as n_24h,
155+
coalesce(sum(total_cents) filter (
156+
where created_at > now() - interval '24 hours'),0)::int as cents_24h,
157+
count(distinct lower(payer))::int as payers
158+
from crawl_sales`);
159+
say('MONEY');
160+
say(` crawl passes: ${sales.n_24h} sold in 24h (${money(sales.cents_24h)}), ` +
161+
`${sales.n} all time (${money(sales.cents)}) from ${sales.payers} payer(s)`);
162+
say(` ledger: ${rev.events} event(s), ${money(rev.gross)} gross, ${money(rev.net)} shareable`);
163+
say(` ${money(rev.attributed)} attributed to a niche, ${money(alloc.platform)} to the platform`);
164+
say(` owed ${money(alloc.owed)} · scheduled ${money(alloc.scheduled)} · paid ${money(alloc.paid)}`);
165+
say();
166+
167+
const tiers = await sql`
168+
select tier_slug, count(*)::int as n from contribution_scores group by tier_slug order by count(*) desc`;
169+
if (tiers.length) {
170+
say('TIERS');
171+
say(` ${tiers.map((t) => `${t.tier_slug} x${t.n}`).join(', ')}`);
172+
say();
173+
}
174+
175+
const idx = await one(sql`
176+
select (select count(*) from items)::int as items,
177+
(select count(*) from items where first_seen_at > now() - interval '24 hours')::int as items_24h,
178+
(select count(*) from collections)::int as collections`);
179+
say('INDEX (what a crawl pass buys, and what attribution divides by)');
180+
say(` ${idx.items.toLocaleString('en-US')} items across ${idx.collections} collections, ` +
181+
`${idx.items_24h.toLocaleString('en-US')} new in 24h`);
182+
say();
183+
184+
// The honest headline. If no money has ever moved, say so rather than letting
185+
// a page of zeros imply something is broken.
186+
if (sales.n === 0 && rev.events === 0) {
187+
say('No crawl pass has ever been bought and the ledger is empty, so every');
188+
say('money line above is zero by circumstance rather than by fault. The');
189+
say('paywall is live and answering 402.');
190+
} else if (Number(alloc.owed) === 0 && members.active === 0) {
191+
say('Money is arriving but nobody operates a niche, so all of it is the');
192+
say("platform's. A claimed niche is what starts splitting it.");
193+
}
194+
195+
say();
196+
say('https://nichedb.dev/opportunities · /admin/knowledge · /admin/payouts');
197+
198+
console.log(out.join('\n'));
199+
await sql.end();
200+
STATS
201+
202+
B64=$(base64 -w0 "$WORK/stats.mjs") || fail "could not encode the stats script"
203+
204+
# Quoting a Bun template literal through ssh mangles every ${...}, so the
205+
# script travels base64-encoded and is decoded on the far side.
206+
railway ssh -p "$PROJECT" -s "$SERVICE" -e "$ENVIRONMENT" \
207+
"echo $B64 | base64 -d > /tmp/nightly.mjs && bun /tmp/nightly.mjs; rm -f /tmp/nightly.mjs" \
208+
> "$WORK/raw.txt" 2>"$WORK/err.txt"
209+
210+
# Railway prints its own chatter (deprecation notices, the SSH key it used) on
211+
# both streams; keep only the report, which starts at its own title line.
212+
sed -n '/^NICHEDB - KNOWLEDGE INFLUENCERS/,$p' "$WORK/raw.txt" > "$WORK/report.txt"
213+
214+
if [ ! -s "$WORK/report.txt" ]; then
215+
fail "no report came back from the container: $(tail -c 400 "$WORK/err.txt" 2>/dev/null)"
216+
fi
217+
218+
SUBJECT="NicheDB nightly - $(date -u +%Y-%m-%d)"
219+
220+
if [ "$DRY_RUN" = "1" ]; then
221+
echo "--- would send to $TO as: $SUBJECT ---"
222+
cat "$WORK/report.txt"
223+
exit 0
224+
fi
225+
226+
mail send --to "$TO" --subject "$SUBJECT" --file "$WORK/report.txt" >/dev/null 2>&1 ||
227+
fail "the report was produced but mail send failed"
228+
229+
echo "nichedb-nightly: sent to $TO"

0 commit comments

Comments
 (0)