feat: leaderboard, milestones, email capture, postcode, flag, stats bar

- Vote state now persists on page load (user_vote in proposals query)
- Stats bar: live votes-today / citizens / proposals counts
- Leaderboard section: top 10 proposals with milestone progress bars
- Milestone system: 100→viral, 500→community, 1k→publish, 5k→minister, 10k→media
- Email capture modal after 3rd vote (postcode optional)
- Milestone email notifications via Resend (requires RESEND_API_KEY secret)
- Weekly digest cron (Monday 9am AEST) to all subscribers
- Share-as-image button opens dynamic SVG in new tab
- Flag/report button hides proposals after 5 flags
- Proposals with hidden=1 excluded from all feeds
- DB: users.email, users.postcode, proposals.flag_count, proposals.hidden,
       proposal_flags, milestone_notifications tables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
King Omar 2026-06-05 01:07:30 +10:00
parent 876d0c438c
commit 5c291d29ea
3 changed files with 466 additions and 31 deletions

View file

@ -117,6 +117,51 @@
<!-- For Windows -->
<meta name="msapplication-TileColor" content="#ff0099">
<meta name="msapplication-navbutton-color" content="#ff0099">
<style>
/* ── Stats bar ── */
.stats-bar{display:flex;align-items:center;justify-content:center;gap:12px;padding:8px 16px;background:#0a0a0a;border:1px solid #222;font-family:'Roboto Mono',monospace;font-size:12px;color:#aaa;flex-wrap:wrap}
.stat-number{color:#ff0099;font-weight:700;font-size:14px}
.stat-divider{color:#333}
/* ── Leaderboard ── */
.leaderboard-section{margin:16px 0 24px;border:1px solid #ff0099;padding:0}
.leaderboard-header{background:#ff0099;color:#000;padding:8px 16px;font-family:'Roboto Mono',monospace;font-weight:700;font-size:13px;display:flex;justify-content:space-between;align-items:center}
.leaderboard-sub{font-weight:400;font-size:11px;opacity:.8}
.leaderboard-item{padding:12px 16px;border-bottom:1px solid #1a1a1a;display:flex;flex-direction:column;gap:6px;cursor:pointer;transition:background .15s}
.leaderboard-item:hover{background:#0d0d0d}
.leaderboard-item:last-child{border-bottom:none}
.leaderboard-rank{font-family:'Roboto Mono',monospace;font-size:11px;color:#666;margin-right:8px}
.leaderboard-text{font-family:'Roboto Mono',monospace;font-size:13px;color:#fff;flex:1}
.leaderboard-meta{display:flex;align-items:center;gap:12px;font-size:11px;font-family:'Roboto Mono',monospace}
.leaderboard-upvotes{color:#ff0099;font-weight:700}
.leaderboard-postcode{color:#666}
.milestone-bar-wrap{display:flex;flex-direction:column;gap:3px}
.milestone-bar-track{background:#1a1a1a;height:4px;border-radius:0;overflow:hidden}
.milestone-bar-fill{background:#ff0099;height:4px;transition:width .4s ease}
.milestone-label{font-family:'Roboto Mono',monospace;font-size:10px;color:#666}
.milestone-label span{color:#ff0099}
/* ── Email capture modal ── */
.email-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:2000;display:flex;align-items:center;justify-content:center;padding:16px}
.email-modal{background:#000;border:2px solid #ff0099;padding:28px;max-width:420px;width:100%;font-family:'Roboto Mono',monospace}
.email-modal h3{color:#ff0099;margin:0 0 8px;font-size:18px}
.email-modal p{color:#aaa;font-size:12px;margin:0 0 20px}
.email-modal-input{width:100%;background:#0a0a0a;border:1px solid #333;color:#fff;padding:10px 12px;font-family:'Roboto Mono',monospace;font-size:13px;box-sizing:border-box;margin-bottom:10px}
.email-modal-input:focus{outline:none;border-color:#ff0099}
.email-modal-btn{width:100%;background:#ff0099;color:#000;border:none;padding:12px;font-family:'Roboto Mono',monospace;font-size:14px;font-weight:700;cursor:pointer;margin-bottom:8px}
.email-modal-btn:hover{background:#ff33aa}
.email-modal-skip{background:none;border:none;color:#555;font-family:'Roboto Mono',monospace;font-size:12px;cursor:pointer;width:100%;text-align:center;padding:4px}
.email-modal-skip:hover{color:#aaa}
/* ── Flag & share-image buttons ── */
.flag-btn{background:none;border:none;color:#444;font-size:12px;cursor:pointer;padding:4px 6px;font-family:'Roboto Mono',monospace;transition:color .15s}
.flag-btn:hover{color:#ff0099}
.flag-btn.flagged{color:#ff0099}
.share-img-btn{background:none;border:none;color:#444;font-size:12px;cursor:pointer;padding:4px 6px;font-family:'Roboto Mono',monospace;transition:color .15s}
.share-img-btn:hover{color:#ff0099}
/* ── Postcode badge ── */
.postcode-badge{font-size:10px;color:#555;font-family:'Roboto Mono',monospace}
.postcode-badge span{color:#ff0099}
/* ── Inline milestone strip on proposal cards ── */
.card-milestone{margin-top:8px}
</style>
</head>
<!-- Crawling -->
@ -188,6 +233,14 @@
</header>
<div class="stats-bar" id="stats-bar">
<div class="stat-item"><span class="stat-number" id="stat-votes"></span><span> votes today</span></div>
<div class="stat-divider">|</div>
<div class="stat-item"><span class="stat-number" id="stat-users"></span><span> citizens</span></div>
<div class="stat-divider">|</div>
<div class="stat-item"><span class="stat-number" id="stat-proposals"></span><span> proposals</span></div>
</div>
<section class="new-proposal">
<textarea id="proposal-input" class="proposal-input"
placeholder="Your idea to revolutionize Australia? (280 character limit)"
@ -199,6 +252,16 @@
<div style="clear: both;"></div>
</section>
<section class="leaderboard-section">
<div class="leaderboard-header">
<span>🏆 TOP PROPOSALS</span>
<span class="leaderboard-sub">milestones trigger real action</span>
</div>
<div id="leaderboard-container">
<div class="loading-indicator"><div class="loading-spinner"></div><p>Loading...</p></div>
</div>
</section>
<section class="proposals">
<div class="filter-bar">
<div class="filter-title">Democracy Goes Brrr</div>
@ -321,22 +384,13 @@ function getMediaURL(path) {
// Initialize application
async function initializeApp() {
// Update UI elements
updateUIText();
// Load user information
await loadUserData();
// Load verified users data
loadVerifiedUsers();
// Setup event listeners
setupEventListeners();
// Initial load of proposals
fetchProposals();
// Check for proposal in URL
fetchLeaderboard();
fetchStats();
checkForProposalInUrl();
}
@ -869,12 +923,18 @@ function initializeShareButtons() {
<img src="${proposal.meme_url}" alt="Meme for petition" onerror="this.style.display='none'" />
</div>` : '';
const milestoneHtml = getMilestoneBarHtml(proposal.upvotes || 0);
const postcodeBadge = proposal.postcode_count > 0
? `<span class="postcode-badge">across <span>${proposal.postcode_count}</span> postcode${proposal.postcode_count > 1 ? 's' : ''}</span>`
: '';
proposalCard.innerHTML = `
${badgeHtml}
<div class="proposal-author">${proposal.author_name}</div>
<div class="proposal-text">${proposal.text}</div>
${memeHtml}
${petitionInfo}
<div class="card-milestone">${milestoneHtml}</div>
<div class="proposal-stats">
<div class="vote-buttons">
<button class="vote-button ${proposal.userVote === 'upvote' ? 'upvoted' : ''}" data-proposal-id="${proposal.id}" data-vote-type="upvote" onclick="event.stopPropagation(); event.preventDefault();">
@ -886,9 +946,12 @@ function initializeShareButtons() {
<span>${proposal.downvotes || 0}</span>
</button>
<span class="net-votes ${voteClass}">${netVotes > 0 ? '+' : ''}${netVotes}</span>
${postcodeBadge}
<div class="custom-copy-button" data-id="${proposal.id}" style="display: inline-block; background-color: #000000; border: 0px solid #333333; color: #aaaaaa; padding: 4px 8px; font-size: 20px; position: relative; z-index: 10; cursor: pointer; vertical-align: middle;" onclick="event.stopPropagation(); event.preventDefault(); smartCopyShare('${proposal.id}', '${encodeURIComponent(proposal.text)}');">
<img src="https://theradicalparty.com/memes/share-arrow-icon-md.png" alt="Share" style="width: 20px; height: 14px; filter: brightness(0) invert(1); pointer-events: none;">
</div>
<button class="share-img-btn" onclick="event.stopPropagation();event.preventDefault();window.open('${API_BASE_URL}/petition-svg?id=${proposal.id}','_blank')" title="Share as image">🖼</button>
<button class="flag-btn" data-proposal-id="${proposal.id}" onclick="event.stopPropagation();event.preventDefault();handleFlag('${proposal.id}',this)" title="Report this proposal"></button>
</div>
<div class="proposal-time">${formatDate(proposal.timestamp)}</div>
</div>
@ -1081,6 +1144,13 @@ async function handleVote(proposalId, voteType) {
// Also update the modal if it's open and showing this proposal
updateModalVoteUI(proposalId, voteType, wasUpvoted, wasDownvoted);
// Track vote count for email capture prompt
const vc = (parseInt(localStorage.getItem('radical_vote_count') || '0')) + 1;
localStorage.setItem('radical_vote_count', String(vc));
if (vc === 3 && !localStorage.getItem('radical_email_captured')) {
setTimeout(showEmailCaptureModal, 600);
}
// Submit vote to server in the background
try {
await submitVoteToServer(proposalId, voteType, voteType === 'upvote');
@ -2787,12 +2857,160 @@ async function batchLoadComments() {
function queueCommentLoad(proposalId) {
commentRequestQueue.add(proposalId);
if (!commentRequestTimeout) {
commentRequestTimeout = setTimeout(batchLoadComments, COMMENT_REQUEST_DELAY);
}
}
// ─── STATS BAR ────────────────────────────────────────────────────────────
async function fetchStats() {
try {
const data = await safeFetch(`${API_BASE_URL}/stats`);
const fmt = n => n >= 1000 ? (n/1000).toFixed(1) + 'k' : String(n);
document.getElementById('stat-votes').textContent = fmt(data.votes_today || 0);
document.getElementById('stat-users').textContent = fmt(data.total_users || 0);
document.getElementById('stat-proposals').textContent = fmt(data.total_proposals || 0);
} catch(e) { /* silent */ }
}
// ─── MILESTONE HELPERS ────────────────────────────────────────────────────
const MILESTONES = [100, 500, 1000, 5000, 10000];
const MILESTONE_ACTIONS = {
100: 'Going viral 🔥',
500: 'Community backed 💪',
1000: 'Published publicly 📢',
5000: 'Written to minister 📬',
10000: 'Mass media push 📺'
};
function getMilestoneInfo(upvotes) {
for (let i = 0; i < MILESTONES.length; i++) {
if (upvotes < MILESTONES[i]) {
const prev = MILESTONES[i - 1] || 0;
const pct = Math.round(((upvotes - prev) / (MILESTONES[i] - prev)) * 100);
return { next: MILESTONES[i], pct, label: MILESTONE_ACTIONS[MILESTONES[i]], reached: false };
}
}
return { next: null, pct: 100, label: MILESTONE_ACTIONS[10000], reached: true };
}
function getMilestoneBarHtml(upvotes) {
const m = getMilestoneInfo(upvotes);
if (m.reached) return `<div class="milestone-bar-wrap"><div class="milestone-label"><span>✓ All milestones reached</span></div></div>`;
return `<div class="milestone-bar-wrap">
<div class="milestone-bar-track"><div class="milestone-bar-fill" style="width:${m.pct}%"></div></div>
<div class="milestone-label">${upvotes}/${m.next} → <span>${m.label}</span></div>
</div>`;
}
// ─── LEADERBOARD ──────────────────────────────────────────────────────────
async function fetchLeaderboard() {
try {
const data = await safeFetch(`${API_BASE_URL}/leaderboard?userId=${currentUser?.id || ''}`);
renderLeaderboard(data.data || []);
} catch(e) {
document.getElementById('leaderboard-container').innerHTML = '<div style="padding:12px;font-size:12px;color:#555;font-family:monospace">Could not load leaderboard.</div>';
}
}
function renderLeaderboard(proposals) {
const container = document.getElementById('leaderboard-container');
if (!proposals.length) {
container.innerHTML = '<div style="padding:12px;font-size:12px;color:#555;font-family:monospace">No proposals yet. Be first.</div>';
return;
}
container.innerHTML = proposals.map((p, i) => {
const m = getMilestoneInfo(p.upvotes || 0);
const postcodeTxt = p.postcode_count > 0 ? `<span class="leaderboard-postcode">${p.postcode_count} postcodes</span>` : '';
return `<div class="leaderboard-item" onclick="openProposal('${p.id}')">
<div style="display:flex;align-items:flex-start;gap:8px">
<span class="leaderboard-rank">${i + 1}.</span>
<span class="leaderboard-text">${p.text.substring(0, 120)}${p.text.length > 120 ? '...' : ''}</span>
</div>
<div class="leaderboard-meta">
<span class="leaderboard-upvotes">▲ ${p.upvotes || 0}</span>
${postcodeTxt}
<span style="color:#444;font-size:10px">${formatDate(p.timestamp)}</span>
</div>
${getMilestoneBarHtml(p.upvotes || 0)}
</div>`;
}).join('');
}
function openProposal(proposalId) {
fetch(`${API_BASE_URL}/proposals/${proposalId}?userId=${currentUser?.id || 'anonymous'}`)
.then(r => r.json())
.then(proposal => { if (proposal && proposal.id) createProposalViewModal(proposal); })
.catch(() => {});
}
// ─── EMAIL CAPTURE ────────────────────────────────────────────────────────
function showEmailCaptureModal() {
if (document.querySelector('.email-modal-overlay')) return;
const overlay = document.createElement('div');
overlay.className = 'email-modal-overlay';
overlay.innerHTML = `
<div class="email-modal">
<h3>Join the movement</h3>
<p>Get notified when petitions you support hit milestones and trigger real action.</p>
<input class="email-modal-input" id="em-email" type="email" placeholder="your@email.com" autocomplete="email">
<input class="email-modal-input" id="em-postcode" type="text" placeholder="Postcode (optional — shows your area's impact)" maxlength="4" inputmode="numeric">
<button class="email-modal-btn" id="em-submit">Join Now</button>
<button class="email-modal-skip" id="em-skip">No thanks</button>
</div>`;
document.body.appendChild(overlay);
document.getElementById('em-submit').addEventListener('click', async () => {
const email = document.getElementById('em-email').value.trim();
const postcode = document.getElementById('em-postcode').value.trim();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showToastMessage('Please enter a valid email', '#ff0099'); return;
}
document.getElementById('em-submit').textContent = 'Joining...';
document.getElementById('em-submit').disabled = true;
try {
await safeFetch(`${API_BASE_URL}/email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: currentUser.id, email, postcode })
});
localStorage.setItem('radical_email_captured', '1');
if (postcode) {
currentUser.postcode = postcode;
localStorage.setItem('radical_user', JSON.stringify(currentUser));
}
showToastMessage('✓ You\'re in. Vibe, Vote, Veto.', '#11cc77');
document.body.removeChild(overlay);
} catch(e) {
showToastMessage('Failed to save — try again', '#ff0099');
document.getElementById('em-submit').textContent = 'Join Now';
document.getElementById('em-submit').disabled = false;
}
});
document.getElementById('em-skip').addEventListener('click', () => {
localStorage.setItem('radical_email_captured', 'skipped');
document.body.removeChild(overlay);
});
}
// ─── FLAG / REPORT ────────────────────────────────────────────────────────
async function handleFlag(proposalId, btn) {
if (btn.classList.contains('flagged')) return;
if (!confirm('Report this proposal as harmful or spam?')) return;
try {
await safeFetch(`${API_BASE_URL}/flag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ proposalId, userId: currentUser.id })
});
btn.classList.add('flagged');
btn.title = 'Reported';
showToastMessage('Reported. The community will review it.', '#11cc77');
} catch(e) {
showToastMessage('Could not report — try again', '#ff0099');
}
}
</script>
<div class="music-prompt" id="music-prompt">hit me</div>

251
worker.js
View file

@ -77,6 +77,9 @@ function getCacheControl(path) {
}
export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(sendWeeklyDigest(env));
},
async fetch(request, env, ctx) {
try {
const url = new URL(request.url);
@ -132,6 +135,14 @@ export default {
return await createOrUpdateCommentVote(request, env);
} else if (path === '/api/comment-votes' && request.method === 'GET') {
return await getCommentVotes(request, env);
} else if (path === '/api/leaderboard' && request.method === 'GET') {
return await getLeaderboard(request, env);
} else if (path === '/api/stats' && request.method === 'GET') {
return await getSiteStats(request, env);
} else if (path === '/api/email' && request.method === 'POST') {
return await saveUserEmail(request, env);
} else if (path === '/api/flag' && request.method === 'POST') {
return await flagProposal(request, env);
} else if (path === '/api/petition-stats' && request.method === 'GET') {
return await getPetitionStats(request, env);
} else if (path === '/api/petition-svg' && request.method === 'GET') {
@ -1050,23 +1061,25 @@ async function getProposals(request, env) {
);
const offset = (page - 1) * limit;
const sortBy = url.searchParams.get('sortBy') || 'newest';
const userId = url.searchParams.get('userId') || '';
console.log(`Getting proposals with sorting: ${sortBy}, page: ${page}, limit: ${limit}`);
// Define the base query
let query = `
SELECT
p.id, p.text, p.timestamp, p.trending, p.meme_url,
SELECT
p.id, p.text, p.timestamp, p.trending, p.meme_url, p.flag_count,
u.name as author_name, u.id as author_id,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures,
(SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners
(SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners,
(SELECT vote_type FROM votes WHERE proposal_id = p.id AND user_id = ?) as user_vote,
(SELECT COUNT(DISTINCT u2.postcode) FROM votes v2 JOIN users u2 ON v2.user_id = u2.id WHERE v2.proposal_id = p.id AND v2.vote_type = 'upvote' AND u2.postcode IS NOT NULL) as postcode_count
FROM proposals p
JOIN users u ON p.author_id = u.id
WHERE (p.hidden = 0 OR p.hidden IS NULL)
`;
// Add sorting
if (sortBy === 'newest') {
query += ` ORDER BY p.timestamp DESC`;
} else if (sortBy === 'oldest') {
@ -1076,17 +1089,19 @@ async function getProposals(request, env) {
} else if (sortBy === 'controversial') {
query += ` ORDER BY (upvotes + downvotes) DESC, ABS(upvotes - downvotes) ASC, p.timestamp DESC`;
} else {
// Default to newest
query += ` ORDER BY p.timestamp DESC`;
}
// Add pagination
query += ` LIMIT ? OFFSET ?`;
console.log(`Executing query with pagination: limit=${limit}, offset=${offset}`);
// Execute the query
const proposals = await env.DB.prepare(query).bind(limit, offset).all();
const proposals = await env.DB.prepare(query).bind(userId, limit, offset).all();
// Normalise snake_case → camelCase for the frontend
if (proposals.results) {
proposals.results.forEach(p => { p.userVote = p.user_vote; });
}
console.log(`Retrieved ${proposals.results?.length || 0} proposals`);
@ -1613,9 +1628,13 @@ async function createOrUpdateVote(request, env) {
}
}
return createResponse({
proposalId,
userId,
if (voteType === 'upvote') {
checkAndNotifyMilestones(proposalId, env).catch(e => console.error('Milestone check error:', e));
}
return createResponse({
proposalId,
userId,
voteType,
isPetition: voteType === 'upvote' && isPetition,
petitionVerified: voteType === 'upvote' && isPetition
@ -2257,7 +2276,201 @@ async function serveCustomizedHtml(proposalId, request, env) {
});
} catch (error) {
console.error('Error generating customized HTML:', error);
// Fall back to regular page if something goes wrong
return await fetch(request);
}
}
// ─── NEW FEATURES ──────────────────────────────────────────────────────────
const MILESTONES = [100, 500, 1000, 5000, 10000];
const MILESTONE_ACTIONS = {
100: 'Going viral 🔥',
500: 'Community backed 💪',
1000: 'Published publicly 📢',
5000: 'Written to the relevant minister 📬',
10000: 'Mass media push 📺'
};
async function getLeaderboard(request, env) {
try {
const url = new URL(request.url);
const userId = url.searchParams.get('userId') || '';
const query = `
SELECT
p.id, p.text, p.timestamp, p.trending, p.meme_url,
u.name as author_name,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes,
(SELECT vote_type FROM votes WHERE proposal_id = p.id AND user_id = ?) as user_vote,
(SELECT COUNT(DISTINCT u2.postcode) FROM votes v2 JOIN users u2 ON v2.user_id = u2.id WHERE v2.proposal_id = p.id AND v2.vote_type = 'upvote' AND u2.postcode IS NOT NULL) as postcode_count
FROM proposals p
JOIN users u ON p.author_id = u.id
WHERE (p.hidden = 0 OR p.hidden IS NULL)
ORDER BY upvotes DESC
LIMIT 10
`;
const results = await env.DB.prepare(query).bind(userId).all();
if (results.results) {
results.results.forEach(p => { p.userVote = p.user_vote; });
}
return createResponse({ data: results.results }, 200, 0);
} catch (error) {
return createResponse({ error: 'Failed to get leaderboard', details: logError(error, { action: 'get_leaderboard' }) }, 500);
}
}
async function getSiteStats(request, env) {
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const stats = await env.DB.prepare(`
SELECT
(SELECT COUNT(*) FROM votes WHERE timestamp >= ?) as votes_today,
(SELECT COUNT(*) FROM users) as total_users,
(SELECT COUNT(*) FROM proposals WHERE hidden = 0 OR hidden IS NULL) as total_proposals,
(SELECT COUNT(*) FROM votes) as total_votes
`).bind(today.getTime()).first();
return createResponse(stats, 200, 30);
} catch (error) {
return createResponse({ error: 'Failed to get stats' }, 500);
}
}
async function saveUserEmail(request, env) {
try {
const data = await request.json();
const { userId, email, postcode } = data;
if (!userId || !email) return createResponse({ error: 'Missing required fields' }, 400);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) return createResponse({ error: 'Invalid email format' }, 400);
const postcodeClean = postcode ? String(postcode).replace(/\D/g, '').slice(0, 4) : null;
await env.DB.prepare(`UPDATE users SET email = ?, postcode = ?, notify_milestones = 1 WHERE id = ?`)
.bind(email, postcodeClean || null, userId).run();
return createResponse({ success: true }, 200, 0);
} catch (error) {
return createResponse({ error: 'Failed to save email', details: logError(error, { action: 'save_email' }) }, 500);
}
}
async function flagProposal(request, env) {
try {
const data = await request.json();
const { proposalId, userId } = data;
if (!proposalId || !userId) return createResponse({ error: 'Missing required fields' }, 400);
const existing = await env.DB.prepare(`SELECT id FROM proposal_flags WHERE proposal_id = ? AND user_id = ?`).bind(proposalId, userId).first();
if (existing) return createResponse({ error: 'Already flagged' }, 400);
const timestamp = Date.now();
await env.DB.prepare(`INSERT INTO proposal_flags (proposal_id, user_id, timestamp) VALUES (?, ?, ?)`).bind(proposalId, userId, timestamp).run();
await env.DB.prepare(`UPDATE proposals SET flag_count = COALESCE(flag_count, 0) + 1 WHERE id = ?`).bind(proposalId).run();
const proposal = await env.DB.prepare(`SELECT flag_count FROM proposals WHERE id = ?`).bind(proposalId).first();
if (proposal && proposal.flag_count >= 5) {
await env.DB.prepare(`UPDATE proposals SET hidden = 1 WHERE id = ?`).bind(proposalId).run();
}
return createResponse({ success: true, proposalId }, 200, 0);
} catch (error) {
return createResponse({ error: 'Failed to flag proposal', details: logError(error, { action: 'flag_proposal' }) }, 500);
}
}
async function checkAndNotifyMilestones(proposalId, env) {
try {
const row = await env.DB.prepare(`SELECT COUNT(*) as count FROM votes WHERE proposal_id = ? AND vote_type = 'upvote'`).bind(proposalId).first();
const count = row.count;
for (const milestone of MILESTONES) {
if (count >= milestone) {
const existing = await env.DB.prepare(`SELECT id FROM milestone_notifications WHERE proposal_id = ? AND milestone = ?`).bind(proposalId, milestone).first();
if (!existing) {
await env.DB.prepare(`INSERT INTO milestone_notifications (proposal_id, milestone, sent_at) VALUES (?, ?, ?)`).bind(proposalId, milestone, Date.now()).run();
await sendMilestoneEmails(proposalId, milestone, env);
}
}
}
} catch (error) {
console.error('Milestone check failed:', error);
}
}
async function sendMilestoneEmails(proposalId, milestone, env) {
if (!env.RESEND_API_KEY) return;
try {
const proposal = await env.DB.prepare(`
SELECT p.text, u.name as author_name FROM proposals p JOIN users u ON p.author_id = u.id WHERE p.id = ?
`).bind(proposalId).first();
if (!proposal) return;
const subscribers = await env.DB.prepare(`
SELECT DISTINCT u.email FROM votes v JOIN users u ON v.user_id = u.id
WHERE v.proposal_id = ? AND v.vote_type = 'upvote' AND u.email IS NOT NULL AND u.notify_milestones = 1
`).bind(proposalId).all();
const action = MILESTONE_ACTIONS[milestone];
const proposalUrl = `https://theradicalparty.com/?proposal=${proposalId}`;
for (const { email } of subscribers.results) {
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'RADICAL <noreply@theradicalparty.com>',
to: [email],
subject: `🔥 ${milestone.toLocaleString()} signatures — this petition is being ${action}`,
html: `<div style="font-family:monospace;background:#000;color:#fff;padding:24px;max-width:600px">
<h2 style="color:#ff0099">RADICAL</h2>
<p>A petition you backed just hit <strong style="color:#ff0099">${milestone.toLocaleString()} signatures</strong>!</p>
<blockquote style="border-left:3px solid #ff0099;margin:16px 0;padding:8px 16px;font-size:1.1em">"${proposal.text}"</blockquote>
<p>What happens next: <strong>${action}</strong></p>
<p><a href="${proposalUrl}" style="color:#ff0099">View the petition </a></p>
<hr style="border-color:#333;margin-top:32px">
<p style="font-size:0.8em;color:#666">You're receiving this because you signed this petition on RADICAL. <a href="https://theradicalparty.com" style="color:#666">Unsubscribe</a></p>
</div>`
})
}).catch(e => console.error(`Email to ${email} failed:`, e));
}
} catch (error) {
console.error('sendMilestoneEmails error:', error);
}
}
async function sendWeeklyDigest(env) {
if (!env.RESEND_API_KEY) return;
try {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const top5 = await env.DB.prepare(`
SELECT p.id, p.text,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes
FROM proposals p
WHERE (p.hidden = 0 OR p.hidden IS NULL)
ORDER BY upvotes DESC LIMIT 5
`).all();
const stats = await env.DB.prepare(`
SELECT COUNT(*) as new_votes FROM votes WHERE timestamp >= ?
`).bind(weekAgo).first();
const subscribers = await env.DB.prepare(`
SELECT DISTINCT email FROM users WHERE email IS NOT NULL AND notify_milestones = 1
`).all();
const proposalRows = top5.results.map((p, i) =>
`<tr><td style="padding:8px;color:#ff0099">${i + 1}</td><td style="padding:8px">${escapeHTML(p.text.substring(0, 120))}${p.text.length > 120 ? '...' : ''}</td><td style="padding:8px;color:#ff0099;text-align:right">${p.upvotes} ▲</td></tr>`
).join('');
const html = `<div style="font-family:monospace;background:#000;color:#fff;padding:24px;max-width:600px">
<h2 style="color:#ff0099">RADICAL Weekly Digest</h2>
<p style="color:#aaa">${stats.new_votes} votes cast this week</p>
<h3>🏆 Top Proposals This Week</h3>
<table style="width:100%;border-collapse:collapse">${proposalRows}</table>
<p style="margin-top:24px"><a href="https://theradicalparty.com" style="color:#ff0099;font-weight:bold"> Vote now at theradicalparty.com</a></p>
<hr style="border-color:#333;margin-top:32px">
<p style="font-size:0.8em;color:#666">You're receiving this weekly digest from RADICAL.</p>
</div>`;
for (const { email } of subscribers.results) {
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: 'RADICAL <noreply@theradicalparty.com>',
to: [email],
subject: `RADICAL Weekly — ${stats.new_votes} votes this week`,
html
})
}).catch(e => console.error(`Weekly digest to ${email} failed:`, e));
}
console.log(`Weekly digest sent to ${subscribers.results.length} subscribers`);
} catch (error) {
console.error('sendWeeklyDigest error:', error);
}
}

View file

@ -45,5 +45,9 @@ zone_name = "theradicalparty.com"
pattern = "theradicalparty.com/styles.css"
zone_name = "theradicalparty.com"
# Weekly digest — Monday 9am AEST (Sunday 23:00 UTC)
[triggers]
crons = ["0 23 * * 0"]