social-autoposter 1.3.2 → 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 +408 -10
- package/package.json +1 -1
- package/scripts/_insert_post_020.py +145 -0
- package/scripts/add_nightowl_to_config.py +521 -0
- package/scripts/cleanup_harness_tabs.py +49 -0
- package/scripts/dm_short_links.py +172 -19
- package/scripts/export_kent_handoff.py +62 -0
- package/scripts/generation_trace.py +54 -5
- package/scripts/mint_external_pool.py +205 -0
- package/scripts/mint_kent_pool.py +267 -0
- package/scripts/post_reddit.py +5 -1
- package/scripts/reddit_browser.py +54 -0
- package/scripts/twitter_browser.py +33 -0
- package/skill/run-reddit-threads.sh +7 -1
- package/skill/run-twitter-cycle.sh +14 -9
package/bin/server.js
CHANGED
|
@@ -3562,15 +3562,200 @@ 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));
|
|
3572
3578
|
}
|
|
3573
3579
|
|
|
3580
|
+
// GET /api/activity/deletion-requests - list activity keys currently flagged
|
|
3581
|
+
// pending deletion. Dashboard pulls this on tab activation so the trash icon
|
|
3582
|
+
// renders in "pending" state for already-flagged rows. We expose only the
|
|
3583
|
+
// minimal fields the UI needs (key + status + requested_at) so this is
|
|
3584
|
+
// cheap to poll alongside /api/activity.
|
|
3585
|
+
if (p === '/api/activity/deletion-requests' && req.method === 'GET') {
|
|
3586
|
+
return (async () => {
|
|
3587
|
+
const rows = await pq(
|
|
3588
|
+
"SELECT activity_key, status, requested_at FROM deletion_requests " +
|
|
3589
|
+
"WHERE status = 'pending' ORDER BY requested_at DESC LIMIT 5000"
|
|
3590
|
+
);
|
|
3591
|
+
return json(res, { requests: rows || [] });
|
|
3592
|
+
})().catch(e => json(res, { error: e.message }, 500));
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
// POST /api/activity/mark-deletion - flag an activity row for manual review
|
|
3596
|
+
// and email i@m13v.com with the details. We do NOT actually delete the
|
|
3597
|
+
// underlying post/reply/mention; the user reviews the email and decides.
|
|
3598
|
+
// Idempotent on activity_key (UNIQUE constraint -> ON CONFLICT DO NOTHING),
|
|
3599
|
+
// so a double-click or a re-fire after a page reload won't send a duplicate
|
|
3600
|
+
// email. The email body is built from the payload the dashboard already
|
|
3601
|
+
// has in hand so the server doesn't need to re-query the source table.
|
|
3602
|
+
if (p === '/api/activity/mark-deletion' && req.method === 'POST') {
|
|
3603
|
+
return readBody(req).then(async body => {
|
|
3604
|
+
let payload;
|
|
3605
|
+
try { payload = JSON.parse(body || '{}'); }
|
|
3606
|
+
catch { return json(res, { error: 'invalid JSON' }, 400); }
|
|
3607
|
+
const key = String(payload.key || '').trim();
|
|
3608
|
+
if (!key || key.length > 64) return json(res, { error: 'missing key' }, 400);
|
|
3609
|
+
// Map prefix -> kind label + numeric record id. Keys come from the
|
|
3610
|
+
// /api/activity SQL UNION and follow the convention prefix||id.
|
|
3611
|
+
// Longer prefixes must be matched before shorter ones (ktp before k).
|
|
3612
|
+
const PREFIXES = [
|
|
3613
|
+
['ktp', 'seo_top_post'], ['kru', 'seo_roundup'], ['kt', 'seo_top_page'],
|
|
3614
|
+
['kr', 'seo_reddit_page'], ['k', 'seo_serp_page'],
|
|
3615
|
+
['rr', 'post_resurrected'], ['pi', 'seo_page_improvement'], ['xp', 'seo_expired_page'],
|
|
3616
|
+
['dr', 'dm_reply'], ['d', 'dm'], ['s', 'reply_skipped'], ['r', 'reply'],
|
|
3617
|
+
['m', 'mention'], ['p', 'post'], ['g', 'gsc_query'],
|
|
3618
|
+
];
|
|
3619
|
+
let kind = 'unknown';
|
|
3620
|
+
let recordId = null;
|
|
3621
|
+
for (const [pref, label] of PREFIXES) {
|
|
3622
|
+
if (key.startsWith(pref) && /^\d+$/.test(key.slice(pref.length))) {
|
|
3623
|
+
kind = label;
|
|
3624
|
+
recordId = parseInt(key.slice(pref.length), 10);
|
|
3625
|
+
break;
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
const platform = String(payload.platform || '').slice(0, 64);
|
|
3629
|
+
const project = String(payload.project || '').slice(0, 128);
|
|
3630
|
+
// Non-admin: only allow flagging rows on projects in their claim. Posts
|
|
3631
|
+
// tab is admin-only today, but the Activity tab is shared.
|
|
3632
|
+
if (!req.user.admin) {
|
|
3633
|
+
const allowed = new Set(req.user.projects || []);
|
|
3634
|
+
if (!project || !allowed.has(project)) {
|
|
3635
|
+
return json(res, { error: 'project not allowed' }, 403);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
const link = String(payload.link || '').slice(0, 2000);
|
|
3639
|
+
const summary = String(payload.summary || '').slice(0, 2000);
|
|
3640
|
+
const bodyText = String(payload.body || '').slice(0, 8000);
|
|
3641
|
+
const detail = String(payload.detail || '').slice(0, 500);
|
|
3642
|
+
const eventType = String(payload.type || '').slice(0, 64);
|
|
3643
|
+
const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : null;
|
|
3644
|
+
const requestedBy = (req.user && (req.user.email || req.user.sub)) || 'dashboard';
|
|
3645
|
+
|
|
3646
|
+
// Insert (or fetch the existing row if already pending). Idempotent.
|
|
3647
|
+
const insRows = await pq(
|
|
3648
|
+
"INSERT INTO deletion_requests " +
|
|
3649
|
+
"(activity_key, kind, record_id, platform, project, link, summary, occurred_at, requested_by, status) " +
|
|
3650
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending') " +
|
|
3651
|
+
"ON CONFLICT (activity_key) DO NOTHING " +
|
|
3652
|
+
"RETURNING id, email_sent_at",
|
|
3653
|
+
[key, kind, recordId, platform, project, link, summary, occurredAt, requestedBy]
|
|
3654
|
+
);
|
|
3655
|
+
let alreadyExisted = false;
|
|
3656
|
+
let drId, emailSentAt;
|
|
3657
|
+
if (insRows && insRows.length) {
|
|
3658
|
+
drId = insRows[0].id;
|
|
3659
|
+
emailSentAt = insRows[0].email_sent_at;
|
|
3660
|
+
} else {
|
|
3661
|
+
alreadyExisted = true;
|
|
3662
|
+
const ex = await pq(
|
|
3663
|
+
"SELECT id, email_sent_at, requested_at FROM deletion_requests WHERE activity_key = $1",
|
|
3664
|
+
[key]
|
|
3665
|
+
);
|
|
3666
|
+
if (ex && ex.length) {
|
|
3667
|
+
drId = ex[0].id;
|
|
3668
|
+
emailSentAt = ex[0].email_sent_at;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
// Only attempt to send the notification email when we don't already
|
|
3673
|
+
// have one on file for this row. This is the second idempotency gate
|
|
3674
|
+
// (race-safe under the UNIQUE constraint above).
|
|
3675
|
+
let emailStatus = alreadyExisted && emailSentAt ? 'already_sent' : 'pending';
|
|
3676
|
+
if (!emailSentAt) {
|
|
3677
|
+
try {
|
|
3678
|
+
const env = loadEnv();
|
|
3679
|
+
const resendKey = (env.RESEND_API_KEY || process.env.RESEND_API_KEY || '').trim();
|
|
3680
|
+
const notifyTo = (env.NOTIFICATION_EMAIL || process.env.NOTIFICATION_EMAIL || 'i@m13v.com').trim();
|
|
3681
|
+
if (!resendKey) {
|
|
3682
|
+
emailStatus = 'no_api_key';
|
|
3683
|
+
await pq(
|
|
3684
|
+
"UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
|
|
3685
|
+
['RESEND_API_KEY missing in .env', drId]
|
|
3686
|
+
);
|
|
3687
|
+
} else {
|
|
3688
|
+
const fromAddr = 'Social Autoposter <matt@mail.omi.me>';
|
|
3689
|
+
const subj = 'Deletion request: ' + (platform || 'unknown') + ' / ' +
|
|
3690
|
+
(project || 'unknown') + ' / ' + (kind || 'unknown') + ' #' + (recordId || '?');
|
|
3691
|
+
const occurredStr = occurredAt ? occurredAt.toISOString() : '(unknown)';
|
|
3692
|
+
// Plain text; no dashes (per global CLAUDE.md rule). Resend
|
|
3693
|
+
// recommends both html and text but text alone is fine.
|
|
3694
|
+
const textBody =
|
|
3695
|
+
'A row was flagged for deletion from the social autoposter dashboard.\n\n' +
|
|
3696
|
+
'Activity key: ' + key + '\n' +
|
|
3697
|
+
'Kind: ' + kind + '\n' +
|
|
3698
|
+
'Record id: ' + (recordId == null ? '(unknown)' : recordId) + '\n' +
|
|
3699
|
+
'Event type: ' + (eventType || '(unknown)') + '\n' +
|
|
3700
|
+
'Platform: ' + (platform || '(unknown)') + '\n' +
|
|
3701
|
+
'Project: ' + (project || '(unknown)') + '\n' +
|
|
3702
|
+
'Occurred at: ' + occurredStr + '\n' +
|
|
3703
|
+
'Link: ' + (link || '(none)') + '\n' +
|
|
3704
|
+
'Detail: ' + (detail || '(none)') + '\n' +
|
|
3705
|
+
'Requested by: ' + requestedBy + '\n\n' +
|
|
3706
|
+
'Summary:\n' + (summary || '(none)') + '\n\n' +
|
|
3707
|
+
'Body:\n' + (bodyText || '(none)') + '\n';
|
|
3708
|
+
const r = await fetch('https://api.resend.com/emails', {
|
|
3709
|
+
method: 'POST',
|
|
3710
|
+
headers: {
|
|
3711
|
+
'Authorization': 'Bearer ' + resendKey,
|
|
3712
|
+
'Content-Type': 'application/json',
|
|
3713
|
+
},
|
|
3714
|
+
body: JSON.stringify({
|
|
3715
|
+
from: fromAddr,
|
|
3716
|
+
to: [notifyTo],
|
|
3717
|
+
subject: subj,
|
|
3718
|
+
text: textBody,
|
|
3719
|
+
}),
|
|
3720
|
+
});
|
|
3721
|
+
const rj = await r.json().catch(() => ({}));
|
|
3722
|
+
if (r.ok && rj && rj.id) {
|
|
3723
|
+
await pq(
|
|
3724
|
+
"UPDATE deletion_requests SET email_sent_at = NOW(), email_id = $1 WHERE id = $2",
|
|
3725
|
+
[String(rj.id), drId]
|
|
3726
|
+
);
|
|
3727
|
+
emailStatus = 'sent';
|
|
3728
|
+
} else {
|
|
3729
|
+
const errMsg = (rj && (rj.message || rj.error)) || ('HTTP ' + r.status);
|
|
3730
|
+
await pq(
|
|
3731
|
+
"UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
|
|
3732
|
+
[String(errMsg).slice(0, 500), drId]
|
|
3733
|
+
);
|
|
3734
|
+
emailStatus = 'send_failed';
|
|
3735
|
+
console.error('[mark-deletion] resend failed:', errMsg);
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
} catch (e) {
|
|
3739
|
+
emailStatus = 'send_failed';
|
|
3740
|
+
await pq(
|
|
3741
|
+
"UPDATE deletion_requests SET email_error = $1 WHERE id = $2",
|
|
3742
|
+
[String(e.message || e).slice(0, 500), drId]
|
|
3743
|
+
).catch(() => {});
|
|
3744
|
+
console.error('[mark-deletion] exception:', e.message);
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
return json(res, {
|
|
3749
|
+
status: 'pending',
|
|
3750
|
+
already_existed: alreadyExisted,
|
|
3751
|
+
email_status: emailStatus,
|
|
3752
|
+
key,
|
|
3753
|
+
kind,
|
|
3754
|
+
record_id: recordId,
|
|
3755
|
+
});
|
|
3756
|
+
}).catch(e => json(res, { error: e.message }, 500));
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3574
3759
|
// GET /api/style/stats - posts grouped by engagement_style over a trailing window (default 24h)
|
|
3575
3760
|
if (p === '/api/style/stats' && req.method === 'GET') {
|
|
3576
3761
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -5674,6 +5859,42 @@ const HTML = `<!DOCTYPE html>
|
|
|
5674
5859
|
.reply-text { color: var(--text); margin-top: 2px; }
|
|
5675
5860
|
.hidden { display: none; }
|
|
5676
5861
|
|
|
5862
|
+
/* Mark-for-deletion trash button (Activity + Top tabs).
|
|
5863
|
+
States:
|
|
5864
|
+
.sa-del-btn default trash icon (greyed out, low opacity)
|
|
5865
|
+
.sa-del-btn:hover red glow, full opacity
|
|
5866
|
+
.sa-del-btn.is-loading spinner overlay, button disabled
|
|
5867
|
+
.sa-del-btn.is-pending amber color, tooltip "pending deletion"
|
|
5868
|
+
The button text/label is intentionally absent; the icon shows on hover
|
|
5869
|
+
via title/data-tooltip. */
|
|
5870
|
+
.sa-del-btn {
|
|
5871
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
5872
|
+
width: 24px; height: 24px; padding: 0;
|
|
5873
|
+
background: transparent; border: 1px solid transparent; border-radius: 6px;
|
|
5874
|
+
color: var(--text-muted, #888); cursor: pointer; opacity: 0.5;
|
|
5875
|
+
transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
|
5876
|
+
line-height: 1; font-size: 14px;
|
|
5877
|
+
vertical-align: middle;
|
|
5878
|
+
}
|
|
5879
|
+
.sa-del-btn:hover { opacity: 1; color: #ef4444; border-color: rgba(239, 68, 68, 0.35); }
|
|
5880
|
+
.sa-del-btn:focus-visible { outline: 1px solid #ef4444; outline-offset: 1px; opacity: 1; }
|
|
5881
|
+
.sa-del-btn svg { width: 14px; height: 14px; display: block; }
|
|
5882
|
+
.sa-del-btn.is-loading { pointer-events: none; opacity: 1; color: var(--cyan, #06b6d4); }
|
|
5883
|
+
.sa-del-btn.is-loading svg { opacity: 0; }
|
|
5884
|
+
.sa-del-btn.is-loading::after {
|
|
5885
|
+
content: ''; position: absolute; width: 12px; height: 12px;
|
|
5886
|
+
border: 2px solid rgba(6, 182, 212, 0.25); border-top-color: var(--cyan, #06b6d4);
|
|
5887
|
+
border-radius: 50%; animation: saDelSpin 0.8s linear infinite;
|
|
5888
|
+
}
|
|
5889
|
+
.sa-del-btn { position: relative; }
|
|
5890
|
+
@keyframes saDelSpin { to { transform: rotate(360deg); } }
|
|
5891
|
+
.sa-del-btn.is-pending {
|
|
5892
|
+
opacity: 1; color: #f59e0b; border-color: rgba(245, 158, 11, 0.35);
|
|
5893
|
+
background: rgba(245, 158, 11, 0.08);
|
|
5894
|
+
}
|
|
5895
|
+
.sa-del-btn.is-pending:hover { color: #f59e0b; border-color: rgba(245, 158, 11, 0.55); }
|
|
5896
|
+
.sa-del-btn.is-failed { color: #ef4444; opacity: 1; border-color: rgba(239, 68, 68, 0.55); }
|
|
5897
|
+
|
|
5677
5898
|
/* Activity tab */
|
|
5678
5899
|
.activity-controls { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; }
|
|
5679
5900
|
.activity-filter-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
@@ -6295,7 +6516,7 @@ const HTML = `<!DOCTYPE html>
|
|
|
6295
6516
|
<button type="button" class="style-stats-pill" data-value="30d">Last 30d</button>
|
|
6296
6517
|
</div>
|
|
6297
6518
|
</div>
|
|
6298
|
-
<details class="style-stats-section" id="cost-stats" open>
|
|
6519
|
+
<details class="style-stats-section sa-admin-only" id="cost-stats" open>
|
|
6299
6520
|
<summary>
|
|
6300
6521
|
<span class="style-stats-title"><span class="style-stats-caret">▶</span><span id="cost-stats-heading">Cost per Activity (last 24 hours)</span></span>
|
|
6301
6522
|
<span class="style-stats-total" id="cost-stats-total"></span>
|
|
@@ -6570,13 +6791,16 @@ const HTML = `<!DOCTYPE html>
|
|
|
6570
6791
|
<th class="activity-sortable" data-sort="summary">
|
|
6571
6792
|
<span class="activity-header-label">What <span class="activity-sort-arrow" data-sort-arrow="summary"></span></span>
|
|
6572
6793
|
</th>
|
|
6573
|
-
<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">
|
|
6574
6795
|
<span class="activity-header-label">Cost <span class="activity-sort-arrow" data-sort-arrow="cost_usd"></span></span>
|
|
6575
6796
|
</th>
|
|
6797
|
+
<th style="width:40px;text-align:center;">
|
|
6798
|
+
<span class="activity-header-label" title="Mark for deletion"> </span>
|
|
6799
|
+
</th>
|
|
6576
6800
|
</tr>
|
|
6577
6801
|
</thead>
|
|
6578
6802
|
<tbody id="activity-body">
|
|
6579
|
-
<tr><td colspan="
|
|
6803
|
+
<tr><td colspan="6" style="text-align:center;color:var(--text);padding:40px;">Loading…</td></tr>
|
|
6580
6804
|
</tbody>
|
|
6581
6805
|
</table>
|
|
6582
6806
|
</div>
|
|
@@ -8848,6 +9072,146 @@ function escapeHtml(s) {
|
|
|
8848
9072
|
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
8849
9073
|
}
|
|
8850
9074
|
|
|
9075
|
+
// ===== Mark-for-deletion helpers (Activity tab + Top tab) =====
|
|
9076
|
+
// _saDelPending is a client-side Set of activity keys (e.g. 'p123', 'r456')
|
|
9077
|
+
// known to already be flagged for deletion. Populated on tab activation by
|
|
9078
|
+
// loadDeletionRequests() and updated optimistically when the user clicks the
|
|
9079
|
+
// trash icon. We never delete from this set; pending is a one-way state.
|
|
9080
|
+
const _saDelPending = new Set();
|
|
9081
|
+
const _saDelFailed = new Set();
|
|
9082
|
+
|
|
9083
|
+
function renderDeleteBtnHtml(e) {
|
|
9084
|
+
if (!e || !e.key) return '';
|
|
9085
|
+
const isPending = _saDelPending.has(e.key);
|
|
9086
|
+
const isFailed = !isPending && _saDelFailed.has(e.key);
|
|
9087
|
+
// Stash the minimal payload the POST endpoint wants on the button itself
|
|
9088
|
+
// so the click handler can read it without re-querying state. Encoded
|
|
9089
|
+
// as a single base64 blob to keep HTML clean and skip per-field escaping.
|
|
9090
|
+
const payload = {
|
|
9091
|
+
key: e.key,
|
|
9092
|
+
type: e.type || '',
|
|
9093
|
+
platform: e.platform || '',
|
|
9094
|
+
project: e.project || '',
|
|
9095
|
+
link: e.link || '',
|
|
9096
|
+
summary: e.summary || '',
|
|
9097
|
+
body: e.body || '',
|
|
9098
|
+
detail: e.detail || '',
|
|
9099
|
+
occurred_at: e.occurred_at || null,
|
|
9100
|
+
};
|
|
9101
|
+
let b64 = '';
|
|
9102
|
+
try { b64 = btoa(unescape(encodeURIComponent(JSON.stringify(payload)))); }
|
|
9103
|
+
catch { b64 = ''; }
|
|
9104
|
+
const title = isPending
|
|
9105
|
+
? 'pending deletion'
|
|
9106
|
+
: (isFailed ? 'delete request failed, click to retry' : 'mark for deletion');
|
|
9107
|
+
const cls = 'sa-del-btn' + (isPending ? ' is-pending' : '') + (isFailed ? ' is-failed' : '');
|
|
9108
|
+
// Trash can SVG (Heroicons-style, 24x24 viewBox). currentColor for theming.
|
|
9109
|
+
const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
9110
|
+
'<path d="M3 6h18"/>' +
|
|
9111
|
+
'<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>' +
|
|
9112
|
+
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>' +
|
|
9113
|
+
'<path d="M10 11v6"/>' +
|
|
9114
|
+
'<path d="M14 11v6"/>' +
|
|
9115
|
+
'</svg>';
|
|
9116
|
+
return '<button type="button" class="' + cls + '" ' +
|
|
9117
|
+
'data-sa-del="1" ' +
|
|
9118
|
+
'data-sa-del-payload="' + b64 + '" ' +
|
|
9119
|
+
'aria-label="' + escapeHtml(title) + '" ' +
|
|
9120
|
+
'title="' + escapeHtml(title) + '">' + icon + '</button>';
|
|
9121
|
+
}
|
|
9122
|
+
|
|
9123
|
+
async function handleDeleteBtnClick(btn) {
|
|
9124
|
+
if (!btn || btn.classList.contains('is-loading')) return;
|
|
9125
|
+
// No-op if already pending (just give the hover tooltip).
|
|
9126
|
+
if (btn.classList.contains('is-pending')) return;
|
|
9127
|
+
let payload = null;
|
|
9128
|
+
try {
|
|
9129
|
+
const b64 = btn.getAttribute('data-sa-del-payload') || '';
|
|
9130
|
+
if (b64) payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
|
|
9131
|
+
} catch (e) { /* ignored - bail below */ }
|
|
9132
|
+
if (!payload || !payload.key) return;
|
|
9133
|
+
btn.classList.remove('is-failed');
|
|
9134
|
+
_saDelFailed.delete(payload.key);
|
|
9135
|
+
btn.classList.add('is-loading');
|
|
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
|
+
};
|
|
9158
|
+
try {
|
|
9159
|
+
const r = await fetch('/api/activity/mark-deletion', {
|
|
9160
|
+
method: 'POST',
|
|
9161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
9162
|
+
body: JSON.stringify(payload),
|
|
9163
|
+
});
|
|
9164
|
+
const j = await r.json().catch(() => ({}));
|
|
9165
|
+
if (r.ok && (j.status === 'pending' || j.already_existed)) {
|
|
9166
|
+
applyPendingState();
|
|
9167
|
+
} else {
|
|
9168
|
+
_saDelPending.delete(payload.key);
|
|
9169
|
+
_saDelFailed.add(payload.key);
|
|
9170
|
+
btn.classList.remove('is-loading');
|
|
9171
|
+
btn.classList.add('is-failed');
|
|
9172
|
+
const msg = (j && j.error) ? String(j.error) : ('HTTP ' + r.status);
|
|
9173
|
+
btn.setAttribute('title', 'failed: ' + msg + ' (click to retry)');
|
|
9174
|
+
}
|
|
9175
|
+
} catch (e) {
|
|
9176
|
+
_saDelPending.delete(payload.key);
|
|
9177
|
+
btn.classList.remove('is-loading');
|
|
9178
|
+
_saDelFailed.add(payload.key);
|
|
9179
|
+
btn.classList.add('is-failed');
|
|
9180
|
+
btn.setAttribute('title', 'failed: ' + (e.message || 'network error') + ' (click to retry)');
|
|
9181
|
+
}
|
|
9182
|
+
}
|
|
9183
|
+
|
|
9184
|
+
async function loadDeletionRequests() {
|
|
9185
|
+
try {
|
|
9186
|
+
const r = await fetch('/api/activity/deletion-requests');
|
|
9187
|
+
if (!r.ok) return;
|
|
9188
|
+
const j = await r.json();
|
|
9189
|
+
const list = (j && j.requests) || [];
|
|
9190
|
+
list.forEach(row => {
|
|
9191
|
+
if (row && row.activity_key) _saDelPending.add(row.activity_key);
|
|
9192
|
+
});
|
|
9193
|
+
} catch (e) {
|
|
9194
|
+
// Silent - the buttons just stay in the default state until the user
|
|
9195
|
+
// refreshes the tab. No UX regression vs not having the feature.
|
|
9196
|
+
}
|
|
9197
|
+
}
|
|
9198
|
+
|
|
9199
|
+
// Global delegated click handler so every trash button on every tab works
|
|
9200
|
+
// without per-row wiring. Idempotent: re-wiring is a no-op because we set a
|
|
9201
|
+
// flag on document.body.
|
|
9202
|
+
function _saInstallDeleteListener() {
|
|
9203
|
+
if (document.body && document.body.dataset.saDelInstalled === '1') return;
|
|
9204
|
+
document.addEventListener('click', ev => {
|
|
9205
|
+
const btn = ev.target.closest && ev.target.closest('button[data-sa-del]');
|
|
9206
|
+
if (!btn) return;
|
|
9207
|
+
ev.preventDefault();
|
|
9208
|
+
ev.stopPropagation();
|
|
9209
|
+
handleDeleteBtnClick(btn);
|
|
9210
|
+
});
|
|
9211
|
+
if (document.body) document.body.dataset.saDelInstalled = '1';
|
|
9212
|
+
}
|
|
9213
|
+
// ===== end mark-for-deletion helpers =====
|
|
9214
|
+
|
|
8851
9215
|
const PLATFORM_ICONS = {
|
|
8852
9216
|
reddit: '<svg viewBox="0 0 24 24" aria-label="reddit"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.436 13.158c.023.16.034.323.034.49 0 2.498-2.908 4.522-6.494 4.522-3.587 0-6.494-2.024-6.494-4.523 0-.167.011-.33.033-.489a1.44 1.44 0 01-.822-1.297 1.444 1.444 0 012.448-1.036 7.967 7.967 0 014.337-1.374l.82-3.865a.277.277 0 01.328-.215l2.69.57a1.004 1.004 0 011.813.068 1.005 1.005 0 01-1.813.875l-2.406-.51-.736 3.47a7.98 7.98 0 014.298 1.379 1.44 1.44 0 011.996.432c.35.56.2 1.29-.332 1.652-.02.013-.04.025-.06.037zM9.17 13.14a1.02 1.02 0 100-2.041 1.02 1.02 0 000 2.041zm6.69-1.02a1.02 1.02 0 10-2.04 0 1.02 1.02 0 002.04 0zm-1.01 3.32a.33.33 0 00-.467 0c-.56.56-1.63.605-1.944.605s-1.384-.046-1.944-.606a.33.33 0 00-.467.467c.887.887 2.587.957 2.411.957.176 0 1.524-.07 2.411-.957a.33.33 0 000-.466z"/></svg>',
|
|
8853
9217
|
twitter: '<svg viewBox="0 0 24 24" aria-label="twitter"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>',
|
|
@@ -9057,7 +9421,7 @@ async function loadActivityStats() {
|
|
|
9057
9421
|
// post's first-ever snapshot so day 1 never attributes lifetime counts
|
|
9058
9422
|
// to a capture day; expect those lines to sit at 0 until at least two
|
|
9059
9423
|
// consecutive days of snapshots have accumulated per post.
|
|
9060
|
-
|
|
9424
|
+
let DAILY_METRICS = [
|
|
9061
9425
|
{ id: 'views', label: 'Views', color: '#6366f1', endpoint: '/api/views/per-day', valueKey: 'views_gained', platformAware: true },
|
|
9062
9426
|
{ id: 'upvotes', label: 'Upvotes', color: '#f97316', endpoint: '/api/upvotes/per-day', valueKey: 'upvotes_gained', platformAware: true },
|
|
9063
9427
|
{ id: 'comments', label: 'Comments', color: '#14b8a6', endpoint: '/api/comments/per-day', valueKey: 'comments_gained', platformAware: true },
|
|
@@ -9070,7 +9434,7 @@ const DAILY_METRICS = [
|
|
|
9070
9434
|
// platformAware: filters cost to the chosen platform's activity rows.
|
|
9071
9435
|
// format: 'usd' so the legend pill, Y-axis ticks, bar labels, and
|
|
9072
9436
|
// hover tooltip render as dollars (e.g. $12.34) instead of K/M counts.
|
|
9073
|
-
{ 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 },
|
|
9074
9438
|
// All funnel metrics count UNIQUE VISITORS (distinct_id), not raw events.
|
|
9075
9439
|
// A user iterating on the same project (multiple prompts, multiple
|
|
9076
9440
|
// schedule retries, multiple pageviews) is 1, not N. See
|
|
@@ -9770,6 +10134,7 @@ async function loadDailyMetrics() {
|
|
|
9770
10134
|
const qsAware = platformAwareParams.join('&');
|
|
9771
10135
|
const qsProj = projectOnlyParams.join('&');
|
|
9772
10136
|
try {
|
|
10137
|
+
const costAvail = window.SA_IS_ADMIN !== false;
|
|
9773
10138
|
const [views, upvotes, comments, clicks, bookings, funnel, cost] = await Promise.all([
|
|
9774
10139
|
fetchOne('/api/views/per-day?' + qsAware),
|
|
9775
10140
|
fetchOne('/api/upvotes/per-day?' + qsAware),
|
|
@@ -9777,7 +10142,7 @@ async function loadDailyMetrics() {
|
|
|
9777
10142
|
fetchOne('/api/clicks/per-day?' + qsAware),
|
|
9778
10143
|
fetchOne('/api/bookings/per-day?' + qsProj),
|
|
9779
10144
|
fetchOne('/api/funnel/per-day?' + qsProj),
|
|
9780
|
-
fetchOne('/api/cost/per-day?'
|
|
10145
|
+
costAvail ? fetchOne('/api/cost/per-day?' + qsAware) : Promise.resolve({ rows: [], failed: false }),
|
|
9781
10146
|
]);
|
|
9782
10147
|
const allFailed = [views, upvotes, comments, clicks, bookings, funnel, cost].every(r => r.failed);
|
|
9783
10148
|
if (allFailed) {
|
|
@@ -11076,6 +11441,7 @@ function renderCostStats(payload) {
|
|
|
11076
11441
|
let _costStatsLoadedFor = null;
|
|
11077
11442
|
let _costStatsLoading = false;
|
|
11078
11443
|
async function loadCostStats(force) {
|
|
11444
|
+
if (window.SA_IS_ADMIN === false) return;
|
|
11079
11445
|
if (_costStatsLoading) return;
|
|
11080
11446
|
const hours = currentStatusWindow().hours;
|
|
11081
11447
|
const row = document.getElementById('cost-stats-platform-pills');
|
|
@@ -11583,9 +11949,29 @@ function renderTopPosts(payload) {
|
|
|
11583
11949
|
filterMode: 'dropdown',
|
|
11584
11950
|
filterOptions: ageThresholdOptions(),
|
|
11585
11951
|
filterPredicate: filterPredicateAge },
|
|
11586
|
-
{ key: 'our_content', label: 'Content', type: 'text', align: 'left', widthPct:
|
|
11952
|
+
{ key: 'our_content', label: 'Content', type: 'text', align: 'left', widthPct: 46,
|
|
11587
11953
|
formatter: renderTopContentCell,
|
|
11588
11954
|
filterMode: 'none' },
|
|
11955
|
+
{ key: 'id', label: '', type: 'text', align: 'center', widthPct: 2,
|
|
11956
|
+
// Per-row "mark for deletion" trash button. The Top tab only shows
|
|
11957
|
+
// posts, so we synthesize an Activity-style event key with the 'p'
|
|
11958
|
+
// prefix and pass through the platform/project/content the email
|
|
11959
|
+
// would want to surface. The button itself + click handler live in
|
|
11960
|
+
// the shared sa-del-btn helpers.
|
|
11961
|
+
formatter: (_v, r) => {
|
|
11962
|
+
const ev = {
|
|
11963
|
+
key: 'p' + r.id,
|
|
11964
|
+
type: r.is_thread ? 'posted_thread' : 'posted_comment',
|
|
11965
|
+
platform: r.platform || '',
|
|
11966
|
+
project: r.project_name || '',
|
|
11967
|
+
link: r.our_url || '',
|
|
11968
|
+
summary: r.our_content || '',
|
|
11969
|
+
body: r.our_content || '',
|
|
11970
|
+
occurred_at: r.posted_at || null,
|
|
11971
|
+
};
|
|
11972
|
+
return renderDeleteBtnHtml(ev);
|
|
11973
|
+
},
|
|
11974
|
+
filterMode: 'none' },
|
|
11589
11975
|
],
|
|
11590
11976
|
});
|
|
11591
11977
|
}
|
|
@@ -13449,7 +13835,7 @@ function renderActivity(events) {
|
|
|
13449
13835
|
sorted.length + ' of ' + events.length + ' events';
|
|
13450
13836
|
renderPagination(sorted.length);
|
|
13451
13837
|
if (!page.length) {
|
|
13452
|
-
body.innerHTML = '<tr><td colspan="
|
|
13838
|
+
body.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text);padding:40px;">No matching events</td></tr>';
|
|
13453
13839
|
return;
|
|
13454
13840
|
}
|
|
13455
13841
|
const rows = page.map(e => {
|
|
@@ -13526,7 +13912,8 @@ function renderActivity(events) {
|
|
|
13526
13912
|
'</div>' +
|
|
13527
13913
|
'</td>' +
|
|
13528
13914
|
'<td class="activity-summary">' + summaryHtml + '</td>' +
|
|
13529
|
-
'<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>' +
|
|
13916
|
+
'<td style="text-align:center;">' + renderDeleteBtnHtml(e) + '</td>' +
|
|
13530
13917
|
'</tr>';
|
|
13531
13918
|
}).join('');
|
|
13532
13919
|
body.innerHTML = rows;
|
|
@@ -13582,6 +13969,9 @@ function activateTab(name) {
|
|
|
13582
13969
|
if (name === 'activity') {
|
|
13583
13970
|
buildActivityFilters();
|
|
13584
13971
|
if (!_tabLoaded.activity) _tabLoaded.activity = true;
|
|
13972
|
+
// Refresh the pending-deletion Set so the trash icon renders in
|
|
13973
|
+
// "pending" state for already-flagged rows on tab activation.
|
|
13974
|
+
loadDeletionRequests();
|
|
13585
13975
|
startActivityAutoRefresh();
|
|
13586
13976
|
} else {
|
|
13587
13977
|
stopActivityAutoRefresh();
|
|
@@ -13597,6 +13987,9 @@ function activateTab(name) {
|
|
|
13597
13987
|
}
|
|
13598
13988
|
if (name === 'top') {
|
|
13599
13989
|
initTopFilters();
|
|
13990
|
+
// Same pending-deletion refresh as Activity: makes the trash icon
|
|
13991
|
+
// show the right state immediately when the user lands on Top.
|
|
13992
|
+
loadDeletionRequests();
|
|
13600
13993
|
if (_topSubtab === 'pages') {
|
|
13601
13994
|
loadTopPages();
|
|
13602
13995
|
} else if (_topSubtab === 'dms') {
|
|
@@ -13623,6 +14016,10 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|
|
13623
14016
|
try { window.posthog && window.posthog.capture('tab_view', { tab: name }); } catch (e) {}
|
|
13624
14017
|
});
|
|
13625
14018
|
});
|
|
14019
|
+
// Install the global delegated click handler for "mark for deletion" trash
|
|
14020
|
+
// buttons. Idempotent (early-exits if already installed). Runs once the body
|
|
14021
|
+
// is parsed so the listener is in place before any tab render fires.
|
|
14022
|
+
_saInstallDeleteListener();
|
|
13626
14023
|
// On first paint, restore the persisted main tab. The HTML defaults to
|
|
13627
14024
|
// "stats" — only override if the saved value is a known tab and is currently
|
|
13628
14025
|
// visible to the user (some tabs are admin-only).
|
|
@@ -13811,6 +14208,7 @@ function saStartApp() {
|
|
|
13811
14208
|
document.body.classList.remove('sa-authed-pending');
|
|
13812
14209
|
const isCloud = document.body.classList.contains('sa-cloud');
|
|
13813
14210
|
const isAdmin = window.SA_IS_ADMIN !== false;
|
|
14211
|
+
if (!isAdmin) DAILY_METRICS = DAILY_METRICS.filter(m => !m.adminOnly);
|
|
13814
14212
|
try { window.posthog && window.posthog.capture('dashboard_opened', { is_admin: isAdmin, is_cloud: isCloud }); } catch (e) {}
|
|
13815
14213
|
// Status + pending are local-only (UI hidden by body.sa-cloud). Endpoints
|
|
13816
14214
|
// are admin-only too, so skipping them on cloud also stops 403 spam for
|
package/package.json
CHANGED