| [b9dc94c] | 1 | /**
|
|---|
| [834bcc3] | 2 | * Scheduler — release planning (premium #3).
|
|---|
| [b9dc94c] | 3 | *
|
|---|
| [834bcc3] | 4 | * Scheduled posts have status 'scheduled' + publish_at (future). A lightweight
|
|---|
| 5 | * timer flips them to 'published' once publish_at is reached. This means public
|
|---|
| 6 | * queries (status='published') need NO changes — a scheduled post simply isn't
|
|---|
| 7 | * 'published' yet and therefore invisible until that moment.
|
|---|
| [b9dc94c] | 8 | */
|
|---|
| 9 |
|
|---|
| 10 | import db from '../config/database.js';
|
|---|
| 11 | import HtmlSanitizerService from './HtmlSanitizerService.js';
|
|---|
| [5a6a457] | 12 | import ActivityPubService from './ActivityPubService.js';
|
|---|
| [b9dc94c] | 13 |
|
|---|
| 14 | export function flipScheduledPosts() {
|
|---|
| 15 | try {
|
|---|
| 16 | const due = db.prepare(`
|
|---|
| [0688b5f] | 17 | SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.language, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
|
|---|
| [5a6a457] | 18 | p.published_at, p.publish_at, p.created_at, u.username
|
|---|
| [b9dc94c] | 19 | FROM posts p JOIN users u ON u.id = p.author_id
|
|---|
| [015ce46] | 20 | WHERE p.status = 'scheduled' AND p.publish_at IS NOT NULL AND datetime(p.publish_at) <= datetime('now')
|
|---|
| [b9dc94c] | 21 | `).all();
|
|---|
| 22 | if (!due.length) return 0;
|
|---|
| 23 | const upd = db.prepare(
|
|---|
| 24 | "UPDATE posts SET status = 'published', published_at = COALESCE(published_at, publish_at, CURRENT_TIMESTAMP) WHERE id = ?"
|
|---|
| 25 | );
|
|---|
| [3dd99d3] | 26 | const ftsDel = db.prepare('DELETE FROM posts_fts WHERE post_id = ?');
|
|---|
| [b9dc94c] | 27 | const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)');
|
|---|
| [5a6a457] | 28 | const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?');
|
|---|
| [b9dc94c] | 29 | for (const p of due) {
|
|---|
| 30 | upd.run(p.id);
|
|---|
| [3dd99d3] | 31 | // Delete-before-insert so a re-scheduled (previously published) post doesn't
|
|---|
| 32 | // get a duplicate FTS row → duplicate search hits.
|
|---|
| 33 | try { ftsDel.run(p.id); fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ }
|
|---|
| [80c36a1] | 34 | // ActivityPub: federate the now-published post to followers (fan_only → followers-only).
|
|---|
| 35 | try {
|
|---|
| 36 | const site = siteStmt.get(p.site_id);
|
|---|
| 37 | if (site) {
|
|---|
| 38 | ActivityPubService.deliverCreate(site, {
|
|---|
| 39 | id: p.id, slug: p.slug, title: p.title || p.slug,
|
|---|
| [0688b5f] | 40 | content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt, language: p.language,
|
|---|
| [0403187] | 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,
|
|---|
| [80c36a1] | 42 | }).catch(() => { /* best-effort */ });
|
|---|
| 43 | }
|
|---|
| 44 | } catch { /* non-fatal */ }
|
|---|
| [b9dc94c] | 45 | }
|
|---|
| 46 | return due.length;
|
|---|
| 47 | } catch { return 0; }
|
|---|
| 48 | }
|
|---|
| 49 |
|
|---|
| [0403187] | 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.
|
|---|
| 53 | export 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 |
|
|---|
| [b9dc94c] | 75 | let _timer = null;
|
|---|
| [0403187] | 76 | function tick() { flipScheduledPosts(); closeExpiredPolls(); }
|
|---|
| [b9dc94c] | 77 | export function startScheduler() {
|
|---|
| [0403187] | 78 | tick(); // run immediately on boot
|
|---|
| [b9dc94c] | 79 | if (_timer) return;
|
|---|
| [0403187] | 80 | _timer = setInterval(tick, 60 * 1000); // every minute
|
|---|
| [b9dc94c] | 81 | if (_timer.unref) _timer.unref();
|
|---|
| 82 | }
|
|---|