| 1 | /**
|
|---|
| 2 | * Scheduler — release planning (premium #3).
|
|---|
| 3 | *
|
|---|
| 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.
|
|---|
| 8 | */
|
|---|
| 9 |
|
|---|
| 10 | import db from '../config/database.js';
|
|---|
| 11 | import HtmlSanitizerService from './HtmlSanitizerService.js';
|
|---|
| 12 | import ActivityPubService from './ActivityPubService.js';
|
|---|
| 13 |
|
|---|
| 14 | export function flipScheduledPosts() {
|
|---|
| 15 | try {
|
|---|
| 16 | 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,
|
|---|
| 18 | p.published_at, p.publish_at, p.created_at, u.username
|
|---|
| 19 | FROM posts p JOIN users u ON u.id = p.author_id
|
|---|
| 20 | WHERE p.status = 'scheduled' AND p.publish_at IS NOT NULL AND datetime(p.publish_at) <= datetime('now')
|
|---|
| 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 | );
|
|---|
| 26 | const ftsDel = db.prepare('DELETE FROM posts_fts WHERE post_id = ?');
|
|---|
| 27 | const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)');
|
|---|
| 28 | const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?');
|
|---|
| 29 | for (const p of due) {
|
|---|
| 30 | upd.run(p.id);
|
|---|
| 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 */ }
|
|---|
| 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,
|
|---|
| 40 | 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,
|
|---|
| 42 | }).catch(() => { /* best-effort */ });
|
|---|
| 43 | }
|
|---|
| 44 | } catch { /* non-fatal */ }
|
|---|
| 45 | }
|
|---|
| 46 | return due.length;
|
|---|
| 47 | } catch { return 0; }
|
|---|
| 48 | }
|
|---|
| 49 |
|
|---|
| 50 | let _timer = null;
|
|---|
| 51 | export function startScheduler() {
|
|---|
| 52 | flipScheduledPosts(); // run immediately on boot
|
|---|
| 53 | if (_timer) return;
|
|---|
| 54 | _timer = setInterval(flipScheduledPosts, 60 * 1000); // every minute
|
|---|
| 55 | if (_timer.unref) _timer.unref();
|
|---|
| 56 | }
|
|---|