Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/migrations/068_add_system_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- 068_add_system_users.sql
-- Create system user accounts for auto-publisher and MCP admin so that
-- moderated_by always has an audit trail.

INSERT INTO users (id, email, name, oauth_provider, oauth_provider_id, is_admin, role)
VALUES
(-1, 'auto-publisher@system.rotv', 'Auto-Publisher', 'system', 'auto-publisher', false, 'viewer'),
(-2, 'mcp@system.rotv', 'MCP Admin', 'system', 'mcp-admin', false, 'viewer')
ON CONFLICT (id) DO NOTHING;

-- Backfill: tag the 508 existing auto-published items with the system user
UPDATE poi_news SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date)
WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai';

UPDATE poi_events SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date)
WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai';
Comment on lines +12 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If both moderated_at and moderation_date are NULL (which can happen for older items published before the moderation_date column was introduced), moderated_at will remain NULL after the backfill. Coalescing with collection_date provides a safe fallback timestamp to ensure moderated_at is populated for all published items.

UPDATE poi_news SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date, collection_date)
WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai';

UPDATE poi_events SET moderated_by = -1, moderated_at = COALESCE(moderated_at, moderation_date, collection_date)
WHERE moderation_status = 'published' AND moderated_by IS NULL AND content_source = 'ai';

2 changes: 2 additions & 0 deletions backend/services/dateExtractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function parseDate(raw, timezone = 'America/New_York') {

if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
const [y, m, d] = trimmed.split('-').map(Number);
if (y < 2000 || y > 2100) return null;
const probe = new Date(y, m - 1, d);
if (probe.getFullYear() === y && probe.getMonth() === m - 1 && probe.getDate() === d) {
return trimmed;
Expand All @@ -24,6 +25,7 @@ export function parseDate(raw, timezone = 'America/New_York') {
const month = d.get('month');
const day = d.get('day');
if (!year || !month || !day) return null;
if (year < 2000 || year > 2100) return null;

return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
Expand Down
2 changes: 1 addition & 1 deletion backend/services/mcpServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
queueNewsletterJob
} from './jobScheduler.js';

const MCP_ADMIN_USER_ID = null;
import { MCP_ADMIN_USER_ID } from '../utils/systemUsers.js';

function registerTools(server, pool, boss) {

Expand Down
12 changes: 8 additions & 4 deletions backend/services/moderationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderPage } from './renderPage.js';
import { deepCrawlForArticle, isGenericUrl } from './deepCrawler.js';
import { logInfo, logError, flush as flushJobLogs } from './jobLogger.js';
import { parseDate, parseDateTime, localToUTC, scoreDateConsensus, extractUrlDate } from './dateExtractor.js';
import { AUTO_PUBLISHER_USER_ID } from '../utils/systemUsers.js';
import { scoreDate, normalizeRenderUrl, normalizeTitle } from './newsService.js';
import { denyReason, sweepDenyLists } from './filterLists.js';

Expand Down Expand Up @@ -398,27 +399,30 @@ export async function processItem(pool, contentType, contentId, { forceStatus =
}

scoring = { confidence_score: newScore / 8.0, reasoning };
const autoModeratedBy = resolvedStatus === 'published' ? AUTO_PUBLISHER_USER_ID : null;
// Only write publication_date when rescore produced a new value — writing the existing
// value back through this path can silently corrupt a previously-good timestamp
if (rescoredDate) {
await pool.query(
`UPDATE ${table} SET moderation_processed = true, moderation_status = $1,
publication_date = $2, date_consensus_score = $3,
ai_reasoning = $4, relevance_signals = $5, moderation_date = CURRENT_TIMESTAMP
ai_reasoning = $4, relevance_signals = $5, moderation_date = CURRENT_TIMESTAMP,
moderated_by = COALESCE($7, moderated_by), moderated_at = CASE WHEN $7 IS NOT NULL THEN CURRENT_TIMESTAMP ELSE moderated_at END
WHERE id = $6`,
[resolvedStatus, newDate, newScore, reasoning,
relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null,
contentId]
contentId, autoModeratedBy]
);
} else {
await pool.query(
`UPDATE ${table} SET moderation_processed = true, moderation_status = $1,
date_consensus_score = $2,
ai_reasoning = $3, relevance_signals = $4, moderation_date = CURRENT_TIMESTAMP
ai_reasoning = $3, relevance_signals = $4, moderation_date = CURRENT_TIMESTAMP,
moderated_by = COALESCE($6, moderated_by), moderated_at = CASE WHEN $6 IS NOT NULL THEN CURRENT_TIMESTAMP ELSE moderated_at END
WHERE id = $5`,
[resolvedStatus, newScore, reasoning,
relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null,
contentId]
contentId, autoModeratedBy]
);
Comment on lines 405 to 426
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two logic issues with the current moderated_by and moderated_at update logic:

  1. Overwriting Human Moderators: If an item was previously approved by a human admin (and thus has a valid human user ID in moderated_by), and it is later re-processed by processItem (e.g., due to a re-score or force status) and still resolves to 'published', the current code will overwrite the human moderator's ID with -1 (Auto-Publisher) because COALESCE(-1, moderated_by) always evaluates to -1.
  2. Retaining Moderators on Non-Published Statuses: If an item was previously 'published' (with moderated_by and moderated_at set), and is later re-processed and resolves to 'pending' or 'rejected', the current code will retain the old moderated_by and moderated_at values because COALESCE(null, moderated_by) evaluates to the existing moderated_by. A pending or rejected item should not have a moderator ID or moderation timestamp associated with it.

We can fix both issues by conditionally setting these fields based on whether the resolved status is 'published' and using COALESCE to preserve any existing human moderator.

      await pool.query(
        `UPDATE ${table} SET moderation_processed = true, moderation_status = $1,
                publication_date = $2, date_consensus_score = $3,
                ai_reasoning = $4, relevance_signals = $5, moderation_date = CURRENT_TIMESTAMP,
                moderated_by = CASE WHEN $1 = 'published' THEN COALESCE(moderated_by, $7) ELSE NULL END,
                moderated_at = CASE WHEN $1 = 'published' THEN COALESCE(moderated_at, CURRENT_TIMESTAMP) ELSE NULL END
         WHERE id = $6`,
        [resolvedStatus, newDate, newScore, reasoning,
         relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null,
         contentId, autoModeratedBy]
      );
    } else {
      await pool.query(
        `UPDATE ${table} SET moderation_processed = true, moderation_status = $1,
                date_consensus_score = $2,
                ai_reasoning = $3, relevance_signals = $4, moderation_date = CURRENT_TIMESTAMP,
                moderated_by = CASE WHEN $1 = 'published' THEN COALESCE(moderated_by, $6) ELSE NULL END,
                moderated_at = CASE WHEN $1 = 'published' THEN COALESCE(moderated_at, CURRENT_TIMESTAMP) ELSE NULL END
         WHERE id = $5`,
        [resolvedStatus, newScore, reasoning,
         relevanceVotes.length > 0 ? JSON.stringify(relevanceVotes) : null,
         contentId, autoModeratedBy]
      );
    }

}

Expand Down
2 changes: 2 additions & 0 deletions backend/utils/systemUsers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const AUTO_PUBLISHER_USER_ID = -1;
export const MCP_ADMIN_USER_ID = -2;
Loading