social-autoposter 1.3.3 → 1.3.4

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/bin/server.js CHANGED
@@ -3562,10 +3562,16 @@ async function handleApi(req, res) {
3562
3562
  const rows = await pq(q);
3563
3563
  let events = (rows && rows.length && rows[0].json_agg) ? rows[0].json_agg : [];
3564
3564
  // Non-admin: drop events not tagged with an allowed project (including
3565
- // octolens mentions, which have no project column).
3565
+ // octolens mentions, which have no project column). Strip Claude cost
3566
+ // fields; cost is operator-internal (API spend not charged to clients).
3566
3567
  if (!req.user.admin) {
3567
3568
  const allowed = new Set(req.user.projects);
3568
3569
  events = events.filter(e => e.project && allowed.has(e.project));
3570
+ events.forEach(e => {
3571
+ delete e.cost_usd;
3572
+ delete e.cost_usd_orchestrator;
3573
+ delete e.cost_usd_estimated;
3574
+ });
3569
3575
  }
3570
3576
  return json(res, { events });
3571
3577
  })().catch(e => json(res, { error: e.message }, 500));
@@ -6510,7 +6516,7 @@ const HTML = `<!DOCTYPE html>
6510
6516
  <button type="button" class="style-stats-pill" data-value="30d">Last 30d</button>
6511
6517
  </div>
6512
6518
  </div>
6513
- <details class="style-stats-section" id="cost-stats" open>
6519
+ <details class="style-stats-section sa-admin-only" id="cost-stats" open>
6514
6520
  <summary>
6515
6521
  <span class="style-stats-title"><span class="style-stats-caret">&#9654;</span><span id="cost-stats-heading">Cost per Activity (last 24 hours)</span></span>
6516
6522
  <span class="style-stats-total" id="cost-stats-total"></span>
@@ -6785,7 +6791,7 @@ const HTML = `<!DOCTYPE html>
6785
6791
  <th class="activity-sortable" data-sort="summary">
6786
6792
  <span class="activity-header-label">What <span class="activity-sort-arrow" data-sort-arrow="summary"></span></span>
6787
6793
  </th>
6788
- <th style="width:90px;text-align:right;" class="activity-sortable" data-sort="cost_usd">
6794
+ <th style="width:90px;text-align:right;" class="activity-sortable sa-admin-only" data-sort="cost_usd">
6789
6795
  <span class="activity-header-label">Cost <span class="activity-sort-arrow" data-sort-arrow="cost_usd"></span></span>
6790
6796
  </th>
6791
6797
  <th style="width:40px;text-align:center;">
@@ -9128,6 +9134,27 @@ async function handleDeleteBtnClick(btn) {
9128
9134
  _saDelFailed.delete(payload.key);
9129
9135
  btn.classList.add('is-loading');
9130
9136
  btn.setAttribute('title', 'sending...');
9137
+ // Optimistic add: the Resend roundtrip is ~15s and the activity table can
9138
+ // re-render mid-flight, detaching this button node from the DOM. The fresh
9139
+ // button reads _saDelPending at render time, so adding the key up-front
9140
+ // guarantees the new button paints pending immediately instead of
9141
+ // reverting to the default trash icon.
9142
+ _saDelPending.add(payload.key);
9143
+ const applyPendingState = () => {
9144
+ document.querySelectorAll('button.sa-del-btn[data-sa-del-payload]').forEach(b => {
9145
+ let k = null;
9146
+ try {
9147
+ const raw = b.getAttribute('data-sa-del-payload') || '';
9148
+ if (raw) k = JSON.parse(decodeURIComponent(escape(atob(raw)))).key;
9149
+ } catch {}
9150
+ if (k === payload.key) {
9151
+ b.classList.remove('is-loading', 'is-failed');
9152
+ b.classList.add('is-pending');
9153
+ b.setAttribute('title', 'pending deletion');
9154
+ b.setAttribute('aria-label', 'pending deletion');
9155
+ }
9156
+ });
9157
+ };
9131
9158
  try {
9132
9159
  const r = await fetch('/api/activity/mark-deletion', {
9133
9160
  method: 'POST',
@@ -9135,19 +9162,18 @@ async function handleDeleteBtnClick(btn) {
9135
9162
  body: JSON.stringify(payload),
9136
9163
  });
9137
9164
  const j = await r.json().catch(() => ({}));
9138
- btn.classList.remove('is-loading');
9139
9165
  if (r.ok && (j.status === 'pending' || j.already_existed)) {
9140
- _saDelPending.add(payload.key);
9141
- btn.classList.add('is-pending');
9142
- btn.setAttribute('title', 'pending deletion');
9143
- btn.setAttribute('aria-label', 'pending deletion');
9166
+ applyPendingState();
9144
9167
  } else {
9168
+ _saDelPending.delete(payload.key);
9145
9169
  _saDelFailed.add(payload.key);
9170
+ btn.classList.remove('is-loading');
9146
9171
  btn.classList.add('is-failed');
9147
9172
  const msg = (j && j.error) ? String(j.error) : ('HTTP ' + r.status);
9148
9173
  btn.setAttribute('title', 'failed: ' + msg + ' (click to retry)');
9149
9174
  }
9150
9175
  } catch (e) {
9176
+ _saDelPending.delete(payload.key);
9151
9177
  btn.classList.remove('is-loading');
9152
9178
  _saDelFailed.add(payload.key);
9153
9179
  btn.classList.add('is-failed');
@@ -9395,7 +9421,7 @@ async function loadActivityStats() {
9395
9421
  // post's first-ever snapshot so day 1 never attributes lifetime counts
9396
9422
  // to a capture day; expect those lines to sit at 0 until at least two
9397
9423
  // consecutive days of snapshots have accumulated per post.
9398
- const DAILY_METRICS = [
9424
+ let DAILY_METRICS = [
9399
9425
  { id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
9400
9426
  { id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
9401
9427
  { id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
@@ -9408,7 +9434,7 @@ const DAILY_METRICS = [
9408
9434
  // platformAware: filters cost to the chosen platform's activity rows.
9409
9435
  // format: 'usd' so the legend pill, Y-axis ticks, bar labels, and
9410
9436
  // hover tooltip render as dollars (e.g. $12.34) instead of K/M counts.
9411
- { id: 'cost', label: 'Cost (USD)', color: '#dc2626', endpoint: '/api/cost/per-day', valueKey: 'cost_usd', platformAware: true, format: 'usd' },
9437
+ { id: 'cost', label: 'Cost (USD)', color: '#dc2626', endpoint: '/api/cost/per-day', valueKey: 'cost_usd', platformAware: true, format: 'usd', adminOnly: true },
9412
9438
  // All funnel metrics count UNIQUE VISITORS (distinct_id), not raw events.
9413
9439
  // A user iterating on the same project (multiple prompts, multiple
9414
9440
  // schedule retries, multiple pageviews) is 1, not N. See
@@ -10108,6 +10134,7 @@ async function loadDailyMetrics() {
10108
10134
  const qsAware = platformAwareParams.join('&');
10109
10135
  const qsProj = projectOnlyParams.join('&');
10110
10136
  try {
10137
+ const costAvail = window.SA_IS_ADMIN !== false;
10111
10138
  const [views, upvotes, comments, clicks, bookings, funnel, cost] = await Promise.all([
10112
10139
  fetchOne('/api/views/per-day?' + qsAware),
10113
10140
  fetchOne('/api/upvotes/per-day?' + qsAware),
@@ -10115,7 +10142,7 @@ async function loadDailyMetrics() {
10115
10142
  fetchOne('/api/clicks/per-day?' + qsAware),
10116
10143
  fetchOne('/api/bookings/per-day?' + qsProj),
10117
10144
  fetchOne('/api/funnel/per-day?' + qsProj),
10118
- fetchOne('/api/cost/per-day?' + qsAware),
10145
+ costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
10119
10146
  ]);
10120
10147
  const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost].every(r => r.failed);
10121
10148
  if (allFailed) {
@@ -11414,6 +11441,7 @@ function renderCostStats(payload) {
11414
11441
  let _costStatsLoadedFor = null;
11415
11442
  let _costStatsLoading = false;
11416
11443
  async function loadCostStats(force) {
11444
+ if (window.SA_IS_ADMIN === false) return;
11417
11445
  if (_costStatsLoading) return;
11418
11446
  const hours = currentStatusWindow().hours;
11419
11447
  const row = document.getElementById('cost-stats-platform-pills');
@@ -13884,7 +13912,7 @@ function renderActivity(events) {
13884
13912
  '</div>' +
13885
13913
  '</td>' +
13886
13914
  '<td class="activity-summary">' + summaryHtml + '</td>' +
13887
- '<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
13915
+ '<td class="sa-admin-only" style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-secondary);">' + fmtCostCell(e.cost_usd, e.cost_usd_orchestrator, e.cost_usd_estimated) + '</td>' +
13888
13916
  '<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
13889
13917
  '</tr>';
13890
13918
  }).join('');
@@ -14180,6 +14208,7 @@ function saStartApp() {
14180
14208
  document.body.classList.remove('sa-authed-pending');
14181
14209
  const isCloud = document.body.classList.contains('sa-cloud');
14182
14210
  const isAdmin = window.SA_IS_ADMIN !== false;
14211
+ if (!isAdmin) DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
14183
14212
  try { window.posthog && window.posthog.capture('dashboard_opened', { is_admin: isAdmin, is_cloud: isCloud }); } catch (e) {}
14184
14213
  // Status + pending are local-only (UI hidden by body.sa-cloud). Endpoints
14185
14214
  // are admin-only too, so skipping them on cloud also stops 403 spam for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "social-autoposter",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Automated social posting pipeline for Reddit, X/Twitter, LinkedIn, and Moltbook. Install as a Claude Code agent skill.",
5
5
  "bin": {
6
6
  "social-autoposter": "bin/cli.js"
@@ -522,6 +522,11 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
522
522
  platform UTMs + code in utm_content), so the LLM's URL in the comment text
523
523
  is ignored for routing -- visitors always land on the destination we baked
524
524
  in. Pool depth managed by scripts/mint_external_pool.py.
525
+
526
+ Routes DB ops through social-autoposter-website /api/v1/post-links/* so
527
+ VMs / sandboxes without DATABASE_URL still mint successfully. The `conn`
528
+ arg is accepted for backward compatibility but ignored. Set
529
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 to fall back to the direct-Neon path.
525
530
  """
526
531
  target_url = _ensure_scheme((target_url or '').strip())
527
532
  if not target_url or target_url == 'https://':
@@ -540,45 +545,68 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
540
545
  'detail': f"no website for project={project_name!r} in config.json",
541
546
  }
542
547
 
548
+ platform_norm = (platform or '').lower()
549
+ if platform_norm == 'x':
550
+ platform_norm = 'twitter'
551
+
543
552
  project_cfg = next((p for p in projects if p.get('name') == project_name), None)
553
+ use_legacy = os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1'
554
+
544
555
  if project_cfg and project_cfg.get('external_short_links'):
545
- # Pool path. Atomically claim the oldest unclaimed pool row matching
546
- # (project_name, platform). FOR UPDATE SKIP LOCKED makes concurrent
547
- # cycles take different rows instead of contending on the same one.
548
- platform_norm = (platform or '').lower()
549
- if platform_norm == 'x':
550
- platform_norm = 'twitter'
551
- cur = conn.execute(
552
- "SELECT code, target_url FROM post_links "
553
- "WHERE project_name = %s AND platform = %s "
554
- " AND post_id IS NULL AND reply_id IS NULL "
555
- " AND minted_session LIKE 'pool:%%' "
556
- "ORDER BY minted_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED",
557
- (project_name, platform_norm),
558
- )
559
- row = cur.fetchone()
560
- if not row:
561
- return {
562
- 'ok': False,
563
- 'error': 'pool_exhausted',
564
- 'project': project_name,
565
- 'platform': platform_norm,
566
- 'detail': (f"external_short_links pool empty for {project_name}/{platform_norm}; "
567
- f"re-mint via scripts/mint_external_pool.py and send updated CSV to client"),
568
- }
569
- row = dict(row)
570
- pool_code = row['code']
571
- pool_target = row['target_url']
572
- # Re-stamp minted_session with the caller's session so backfill_post_id /
573
- # backfill_reply_id finds this row when log_post returns. Without this,
574
- # the pool row's minted_session stays 'pool:<slug>-<platform>' and the
575
- # backfill UPDATE matches nothing.
576
- conn.execute(
577
- "UPDATE post_links SET minted_session = %s "
578
- "WHERE code = %s",
579
- (minted_session, pool_code),
580
- )
581
- conn.commit()
556
+ # Pool path. Atomically claim the oldest unclaimed pool row.
557
+ if use_legacy:
558
+ cur = conn.execute(
559
+ "SELECT code, target_url FROM post_links "
560
+ "WHERE project_name = %s AND platform = %s "
561
+ " AND post_id IS NULL AND reply_id IS NULL "
562
+ " AND minted_session LIKE 'pool:%%' "
563
+ "ORDER BY minted_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED",
564
+ (project_name, platform_norm),
565
+ )
566
+ row = cur.fetchone()
567
+ if not row:
568
+ return {
569
+ 'ok': False,
570
+ 'error': 'pool_exhausted',
571
+ 'project': project_name,
572
+ 'platform': platform_norm,
573
+ }
574
+ row = dict(row)
575
+ pool_code = row['code']
576
+ pool_target = row['target_url']
577
+ conn.execute(
578
+ "UPDATE post_links SET minted_session = %s WHERE code = %s",
579
+ (minted_session, pool_code),
580
+ )
581
+ conn.commit()
582
+ else:
583
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
584
+ from http_api import api_post
585
+ try:
586
+ resp = api_post(
587
+ "/api/v1/post-links/claim-pool",
588
+ {
589
+ "project_name": project_name,
590
+ "platform": platform_norm,
591
+ "minted_session": minted_session,
592
+ },
593
+ ok_on_conflict=True,
594
+ )
595
+ except Exception as e:
596
+ return {'ok': False, 'error': 'api_unreachable', 'detail': str(e)}
597
+ if not resp or not resp.get('ok'):
598
+ err = (resp or {}).get('error') or {}
599
+ code = err.get('code') if isinstance(err, dict) else None
600
+ return {
601
+ 'ok': False,
602
+ 'error': code or 'pool_exhausted',
603
+ 'project': project_name,
604
+ 'platform': platform_norm,
605
+ 'detail': err.get('message') if isinstance(err, dict) else str(err),
606
+ }
607
+ data = resp.get('data') or {}
608
+ pool_code = data.get('code')
609
+ pool_target = data.get('target_url')
582
610
  return {
583
611
  'ok': True,
584
612
  'code': pool_code,
@@ -596,22 +624,54 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
596
624
  platform=platform,
597
625
  )
598
626
 
599
- # Posts mint fresh codes every call no idempotency on (post_id, target_url)
600
- # because post_id is NULL at mint time. The minted_session UUID groups the
601
- # codes so the caller can backfill them all in one UPDATE after log_post
602
- # returns. If a wrap is retried (rare), we get duplicate codes pointing at
603
- # the same target_url; orphans of failed posts are bounded and harmless.
627
+ # Fresh mint: try up to 8 random codes before giving up on collision.
628
+ if use_legacy:
629
+ for _ in range(8):
630
+ code = _gen_code()
631
+ try:
632
+ conn.execute(
633
+ "INSERT INTO post_links (code, platform, project_name, "
634
+ " target_url, kind, project_at_mint, minted_session) "
635
+ "VALUES (%s, %s, %s, %s, %s, %s, %s)",
636
+ (code, platform, project_name, final_target, kind,
637
+ matched_project, minted_session),
638
+ )
639
+ conn.commit()
640
+ return {
641
+ 'ok': True,
642
+ 'code': code,
643
+ 'short_url': f"{website}/r/{code}",
644
+ 'target_url': final_target,
645
+ 'kind': kind,
646
+ }
647
+ except Exception as e:
648
+ if 'duplicate key' in str(e).lower() and 'post_links_pkey' in str(e).lower():
649
+ conn.execute("ROLLBACK")
650
+ continue
651
+ raise
652
+ return {'ok': False, 'error': 'code_collision_after_8_tries'}
653
+
654
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
655
+ from http_api import api_post
604
656
  for _ in range(8):
605
657
  code = _gen_code()
606
658
  try:
607
- conn.execute(
608
- "INSERT INTO post_links (code, platform, project_name, "
609
- " target_url, kind, project_at_mint, minted_session) "
610
- "VALUES (%s, %s, %s, %s, %s, %s, %s)",
611
- (code, platform, project_name, final_target, kind,
612
- matched_project, minted_session),
659
+ resp = api_post(
660
+ "/api/v1/post-links/mint",
661
+ {
662
+ "code": code,
663
+ "platform": platform,
664
+ "project_name": project_name,
665
+ "target_url": final_target,
666
+ "kind": kind,
667
+ "project_at_mint": matched_project,
668
+ "minted_session": minted_session,
669
+ },
670
+ ok_on_conflict=True,
613
671
  )
614
- conn.commit()
672
+ except Exception as e:
673
+ return {'ok': False, 'error': 'api_unreachable', 'detail': str(e)}
674
+ if resp and resp.get('ok'):
615
675
  return {
616
676
  'ok': True,
617
677
  'code': code,
@@ -619,11 +679,15 @@ def _mint_one_post(conn, *, target_url: str, projects: list, platform: str,
619
679
  'target_url': final_target,
620
680
  'kind': kind,
621
681
  }
622
- except Exception as e:
623
- if 'duplicate key' in str(e).lower() and 'post_links_pkey' in str(e).lower():
624
- conn.execute("ROLLBACK")
625
- continue
626
- raise
682
+ err = (resp or {}).get('error') or {}
683
+ err_code = err.get('code') if isinstance(err, dict) else None
684
+ if err_code == 'code_collision':
685
+ continue # try another random code
686
+ return {
687
+ 'ok': False,
688
+ 'error': err_code or 'mint_api_error',
689
+ 'detail': err.get('message') if isinstance(err, dict) else str(err),
690
+ }
627
691
  return {'ok': False, 'error': 'code_collision_after_8_tries'}
628
692
 
629
693
 
@@ -653,7 +717,11 @@ def wrap_text_for_post(*, text: str, platform: str, project_name: str) -> dict:
653
717
 
654
718
  minted_session = str(uuid.uuid4())
655
719
  projects = _load_projects()
656
- conn = dbmod.get_conn()
720
+ # Conn is only opened for the legacy direct-Neon path (env-gated). The
721
+ # default API path doesn't need it; lazy-open avoids a wasted Neon
722
+ # connection on VMs that don't have DATABASE_URL.
723
+ use_legacy = os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1'
724
+ conn = dbmod.get_conn() if use_legacy else None
657
725
  try:
658
726
  seen = {}
659
727
  codes = []
@@ -704,7 +772,26 @@ def wrap_text_for_post(*, text: str, platform: str, project_name: str) -> dict:
704
772
  'skipped': skipped,
705
773
  }
706
774
  finally:
707
- conn.close()
775
+ if conn is not None:
776
+ conn.close()
777
+
778
+
779
+ def _backfill_via_api(*, minted_session: str, post_id: int | None = None,
780
+ reply_id: int | None = None) -> int:
781
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
782
+ from http_api import api_post
783
+ body: dict = {"minted_session": minted_session}
784
+ if post_id is not None:
785
+ body["post_id"] = int(post_id)
786
+ if reply_id is not None:
787
+ body["reply_id"] = int(reply_id)
788
+ try:
789
+ resp = api_post("/api/v1/post-links/backfill", body)
790
+ except Exception:
791
+ return 0
792
+ if not resp or not resp.get('ok'):
793
+ return 0
794
+ return int((resp.get('data') or {}).get('updated') or 0)
708
795
 
709
796
 
710
797
  def backfill_post_id(*, minted_session: str, post_id: int) -> int:
@@ -714,9 +801,14 @@ def backfill_post_id(*, minted_session: str, post_id: int) -> int:
714
801
  Caller should NOT raise on rowcount==0 because some posts have no URLs
715
802
  and minted_session was None — the caller should skip the backfill in
716
803
  that case.
804
+
805
+ Routes through /api/v1/post-links/backfill by default. Set
806
+ SOCIAL_AUTOPOSTER_LEGACY_NEON=1 for the direct-Neon path.
717
807
  """
718
808
  if not minted_session or post_id is None:
719
809
  return 0
810
+ if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') != '1':
811
+ return _backfill_via_api(minted_session=minted_session, post_id=post_id)
720
812
  conn = dbmod.get_conn()
721
813
  try:
722
814
  cur = conn.execute(
@@ -735,6 +827,8 @@ def backfill_reply_id(*, minted_session: str, reply_id: int) -> int:
735
827
  writes to the `replies` table, not `posts`)."""
736
828
  if not minted_session or reply_id is None:
737
829
  return 0
830
+ if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') != '1':
831
+ return _backfill_via_api(minted_session=minted_session, reply_id=reply_id)
738
832
  conn = dbmod.get_conn()
739
833
  try:
740
834
  cur = conn.execute(