|
| 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