Changeset 0403187 in Klonkt for src/services/Scheduler.js


Ignore:
Timestamp:
07/01/2026 12:53:32 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
bb3f67c
Parents:
731e431
Message:

feat(fediverse): host your own polls (federate as AS2 Question)

A post can carry a poll that federates as an AS2 Question so remote (Mastodon)
followers vote from their own app; votes are tallied server-side and the fresh
counts are pushed back as Update(Question). Voting is fediverse-only; the site
shows live, read-only results. Complements the existing inbound poll support.

  • src/config/database.js — posts.poll_json (our poll definition) + poll_votes table (post_id, actor_uri, choice; UNIQUE) backing the tally + per-actor dedupe.
  • src/services/ActivityPubService.js — parseOwnPoll/pollTally/ownPollView helpers; buildNote emits a Question (oneOf/anyOf + replies.totalItems + endTime/closed + votersCount) for a poll post; handleInbox records a ballot (Note with name + inReplyTo our poll) before the reply path, deduped per actor; a debounced Update(Question) pushes fresh counts to followers; votersCount added to AP_CONTEXT.
  • src/services/Scheduler.js — closeExpiredPolls() marks a poll closed once its endTime passes and pushes the final tally; runs on the existing 60s tick.
  • src/routes/posts.js — parsePollForm() turns the editor fields into poll_json on create/save (a poll with votes is frozen), passes poll_json to the federation hooks, and hands the post page a render-ready ownPollView.
  • src/views/pages/post-edit.ejs — poll section (options, multiple-choice, duration); disabled once the poll has votes.
  • src/views/pages/post.ejs — display-only poll with result bars + voter/close meta.
  • src/services/i18n.js — poll.* + pedit.poll_* strings (nl/en/de).
  • test/polls.test.js — Question shape, tally, percentages, closed state, AS2 term.
  • CHANGELOG(.nl/.de).md — "Create your own polls" under Unreleased.

Co-Authored-By: Claude <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/Scheduler.js

    r731e431 r0403187  
    1515  try {
    1616    const due = db.prepare(`
    17       SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.fan_only, p.nsfw, p.content_warning,
     17      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
    1818             p.published_at, p.publish_at, p.created_at, u.username
    1919      FROM posts p JOIN users u ON u.id = p.author_id
     
    3939            id: p.id, slug: p.slug, title: p.title || p.slug,
    4040            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null,
    41             published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning,
     41            published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
    4242          }).catch(() => { /* best-effort */ });
    4343        }
     
    4848}
    4949
     50// Close hosted polls whose endTime has passed: mark them closed (once) and push the final
     51// tally + closed state to followers as Update(Question). The `closed` flag in poll_json
     52// guards against re-sending — a poll is only processed on the tick that crosses its endTime.
     53export function closeExpiredPolls() {
     54  try {
     55    const due = db.prepare(`
     56      SELECT id, poll_json FROM posts
     57      WHERE poll_json IS NOT NULL
     58        AND status = 'published'
     59        AND json_extract(poll_json, '$.endTime') IS NOT NULL
     60        AND IFNULL(json_extract(poll_json, '$.closed'), 0) = 0
     61        AND datetime(json_extract(poll_json, '$.endTime')) <= datetime('now')
     62    `).all();
     63    if (!due.length) return 0;
     64    const upd = db.prepare('UPDATE posts SET poll_json = ? WHERE id = ?');
     65    for (const p of due) {
     66      let d; try { d = JSON.parse(p.poll_json); } catch { continue; }
     67      d.closed = true;
     68      upd.run(JSON.stringify(d), p.id);
     69      ActivityPubService.deliverPollUpdate(p.id).catch(() => { /* best-effort */ });
     70    }
     71    return due.length;
     72  } catch { return 0; }
     73}
     74
    5075let _timer = null;
     76function tick() { flipScheduledPosts(); closeExpiredPolls(); }
    5177export function startScheduler() {
    52   flipScheduledPosts();                 // run immediately on boot
     78  tick();                               // run immediately on boot
    5379  if (_timer) return;
    54   _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute
     80  _timer = setInterval(tick, 60 * 1000); // every minute
    5581  if (_timer.unref) _timer.unref();
    5682}
Note: See TracChangeset for help on using the changeset viewer.