[object Object]

← back to Bhv Review Poller

bhv-review-poller: launchd poller that emails Steve via George on App Store review state-change (30-min interval, com.steve.bhv-review-poller)

7e67226aaa5bc34bd325e589995458de11ed6895 · 2026-08-06 12:31:03 -0700 · Steve Abrams

Files touched

Diff

commit 7e67226aaa5bc34bd325e589995458de11ed6895
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:31:03 2026 -0700

    bhv-review-poller: launchd poller that emails Steve via George on App Store review state-change (30-min interval, com.steve.bhv-review-poller)
---
 .gitignore |  5 +++++
 check.mjs  | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 run.sh     | 44 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 120 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b031c7a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+data/
+*.log
+.env*
+.DS_Store
diff --git a/check.mjs b/check.mjs
new file mode 100644
index 0000000..b6eb954
--- /dev/null
+++ b/check.mjs
@@ -0,0 +1,71 @@
+// Beverly Hills Videos — App Store review-state poller (READ-ONLY against Apple).
+// Emits one JSON line to stdout: {state, prev, changed, versionString, reviewState, stamp, meaning}
+// Persists last state to data/last-state.txt and a heartbeat to data/latest.json.
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const DIR = path.dirname(fileURLToPath(import.meta.url));
+const KEY_ID = '72Y2TZT54R';
+const ISS = 'cfbd63ed-301b-465c-aad7-49e94420ad70';
+const APP = '6798763975';
+const P8 = '/Users/macstudio3/.appstoreconnect/private_keys/AuthKey_72Y2TZT54R.p8';
+
+// Human meaning for each terminal/interesting state
+const MEANING = {
+  WAITING_FOR_REVIEW: 'In Apple’s queue, not yet picked up.',
+  IN_REVIEW: 'Apple has STARTED reviewing the app.',
+  PENDING_DEVELOPER_RELEASE: 'APPROVED — waiting for you to release it.',
+  PENDING_APPLE_RELEASE: 'APPROVED — scheduled for Apple release.',
+  READY_FOR_SALE: 'APPROVED and LIVE on the App Store.',
+  REJECTED: 'REJECTED by Apple — needs changes + resubmit.',
+  METADATA_REJECTED: 'Metadata REJECTED — fix listing text/screenshots + resubmit.',
+  DEVELOPER_REJECTED: 'You rejected the build (removed from review).',
+  INVALID_BINARY: 'Binary invalid — needs a new build.',
+  PENDING_CONTRACT: 'Blocked on an Apple agreement/contract.',
+};
+const APPROVED = new Set(['PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'READY_FOR_SALE']);
+const REJECTED = new Set(['REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']);
+
+function jwt() {
+  const b64u = o => Buffer.from(typeof o === 'string' ? o : JSON.stringify(o)).toString('base64url');
+  const now = Math.floor(Date.now() / 1000);
+  const si = `${b64u({ alg: 'ES256', kid: KEY_ID, typ: 'JWT' })}.${b64u({ iss: ISS, iat: now, exp: now + 600, aud: 'appstoreconnect-v1' })}`;
+  const sig = crypto.sign('sha256', Buffer.from(si), { key: fs.readFileSync(P8, 'utf8'), dsaEncoding: 'ieee-p1363' });
+  return `${si}.${sig.toString('base64url')}`;
+}
+
+async function main() {
+  const t = jwt();
+  const g = async u => {
+    const r = await fetch('https://api.appstoreconnect.apple.com' + u, { headers: { Authorization: `Bearer ${t}` } });
+    return await r.json();
+  };
+  const vresp = await g(`/v1/apps/${APP}/appStoreVersions?limit=1&fields[appStoreVersions]=versionString,appStoreState`);
+  const v = vresp.data && vresp.data[0];
+  if (!v) throw new Error('no appStoreVersion returned: ' + JSON.stringify(vresp).slice(0, 200));
+  const state = v.attributes.appStoreState;
+  const rs = ((await g(`/v1/reviewSubmissions?filter[app]=${APP}&filter[platform]=IOS`)).data || []).find(x => x.attributes.submittedDate);
+
+  const stateFile = path.join(DIR, 'data', 'last-state.txt');
+  let prev = null;
+  try { prev = fs.readFileSync(stateFile, 'utf8').trim(); } catch {}
+  const changed = !!prev && prev !== state;
+  fs.writeFileSync(stateFile, state);
+
+  const stamp = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });
+  const out = {
+    state, prev, changed,
+    firstRun: !prev,
+    versionString: v.attributes.versionString,
+    reviewState: rs ? rs.attributes.state : null,
+    approved: APPROVED.has(state),
+    rejected: REJECTED.has(state),
+    meaning: MEANING[state] || state,
+    stamp,
+  };
+  fs.writeFileSync(path.join(DIR, 'data', 'latest.json'), JSON.stringify(out, null, 2));
+  process.stdout.write(JSON.stringify(out) + '\n');
+}
+main().catch(e => { process.stderr.write('ERR ' + e.message + '\n'); process.exit(1); });
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..f55afd1
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Beverly Hills Videos App Store review poller — runs check.mjs; on a state CHANGE,
+# emails Steve via George + posts a CNCP parking-lot card. Always exits 0 (launchd-safe).
+set -uo pipefail
+DIR="/Users/macstudio3/Projects/bhv-review-poller"
+LOG="$DIR/data/poller.log"
+NODE="/opt/homebrew/bin/node"
+JQ="/opt/homebrew/bin/jq"
+NOTIFY_TO="${BHV_NOTIFY_TO:-steve@designerwallcoverings.com}"
+cd "$DIR" || exit 0
+
+OUT="$("$NODE" check.mjs 2>>"$LOG")" || { echo "$(date '+%F %T') CHECK_FAILED" >>"$LOG"; exit 0; }
+echo "$(date '+%F %T') $OUT" >>"$LOG"
+
+CHANGED="$(printf '%s' "$OUT" | "$JQ" -r '.changed')"
+[ "$CHANGED" = "true" ] || exit 0
+
+STATE="$(printf '%s' "$OUT" | "$JQ" -r '.state')"
+PREV="$(printf '%s' "$OUT" | "$JQ" -r '.prev')"
+MEANING="$(printf '%s' "$OUT" | "$JQ" -r '.meaning')"
+VER="$(printf '%s' "$OUT" | "$JQ" -r '.versionString')"
+STAMP="$(printf '%s' "$OUT" | "$JQ" -r '.stamp')"
+APPROVED="$(printf '%s' "$OUT" | "$JQ" -r '.approved')"
+REJECTED="$(printf '%s' "$OUT" | "$JQ" -r '.rejected')"
+
+SUBJECT="📱 Beverly Hills Videos review: ${PREV} → ${STATE}"
+BODY="<p><b>App Store review state changed.</b></p><p>Beverly Hills Videos v${VER}<br>${PREV} &rarr; <b>${STATE}</b><br>${MEANING}</p>"
+[ "$APPROVED" = "true" ] && BODY="${BODY}<p>✅ Approved — the AdMob-link follow-up is now unblocked (mirror the nineoh pattern).</p>"
+[ "$REJECTED" = "true" ] && BODY="${BODY}<p>⚠️ Needs action — read Apple's Resolution Center notes, fix, and resubmit.</p>"
+BODY="${BODY}<p style='color:#888;font-size:12px'>Polled ${STAMP} PT · com.steve.bhv-review-poller</p>"
+
+# George email (canonical shared helper)
+if [ -f "$HOME/.claude/skills/_shared/george-send.sh" ]; then
+  . "$HOME/.claude/skills/_shared/george-send.sh"
+  george_send steve-office "$NOTIFY_TO" "$SUBJECT" "$BODY" >>"$LOG" 2>&1 || echo "$(date '+%F %T') GEORGE_SEND_FAILED" >>"$LOG"
+fi
+
+# CNCP parking-lot card (best-effort)
+/usr/bin/curl -sS --max-time 10 http://127.0.0.1:3333/api/parking-lot \
+  -H 'Content-Type: application/json' \
+  -d "$(printf '%s' "$OUT" | "$JQ" -c '{title:("BHV review: "+.prev+" → "+.state), note:.meaning, source:"bhv-review-poller"}')" \
+  >>"$LOG" 2>&1 || true
+
+exit 0

(oldest)  ·  back to Bhv Review Poller  ·  (newest)