From f35325a5972ab640cf77401c6ef3a7057f47e4b6 Mon Sep 17 00:00:00 2001 From: "Claude (Chronicler #76)" Date: Fri, 10 Apr 2026 21:00:19 +0000 Subject: [PATCH] Add internal API routes for n8n social analytics integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #108 Phase 2: Social Analytics Automation New endpoints (token-based auth via INTERNAL_API_TOKEN): - POST /api/internal/social/sync - Upsert single post metrics - POST /api/internal/social/sync/batch - Batch upsert (max 100 posts) - POST /api/internal/social/snapshot - Upsert account-level stats - GET /api/internal/social/digest - Summary data for Discord webhook Architecture: - Rube MCP -> n8n -> Arbiter /api/internal/* -> PostgreSQL - Bearer token auth (not session-based) - COALESCE for partial updates (only update provided fields) Next: Generate INTERNAL_API_TOKEN and add to .env on Command Center 🔥 Fire + Frost + Foundation = Where Love Builds Legacy 💙❄️ --- services/arbiter-3.0/src/index.js | 2 + services/arbiter-3.0/src/routes/api.js | 359 +++++++++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 services/arbiter-3.0/src/routes/api.js diff --git a/services/arbiter-3.0/src/index.js b/services/arbiter-3.0/src/index.js index 640da88..1d56546 100644 --- a/services/arbiter-3.0/src/index.js +++ b/services/arbiter-3.0/src/index.js @@ -14,6 +14,7 @@ const authRoutes = require('./routes/auth'); const adminRoutes = require('./routes/admin/index'); const webhookRoutes = require('./routes/webhook'); const stripeRoutes = require('./routes/stripe'); +const apiRoutes = require('./routes/api'); const { registerEvents } = require('./discord/events'); const { linkCommand } = require('./discord/commands'); const { createServerCommand } = require('./discord/createserver'); @@ -114,6 +115,7 @@ app.use('/auth', authRoutes); app.use('/admin', csrfProtection, adminRoutes); app.use('/webhook', webhookRoutes); app.use('/stripe', stripeRoutes); // Checkout and portal routes (uses JSON body) +app.use('/api/internal', apiRoutes); // Internal API for n8n (token-based auth) // Start Application const PORT = process.env.PORT || 3500; diff --git a/services/arbiter-3.0/src/routes/api.js b/services/arbiter-3.0/src/routes/api.js new file mode 100644 index 0000000..db48934 --- /dev/null +++ b/services/arbiter-3.0/src/routes/api.js @@ -0,0 +1,359 @@ +/** + * Internal API Routes + * + * These endpoints are for machine-to-machine communication (n8n, external services). + * Authentication is via Bearer token, not session-based. + * + * Token is set in environment variable: INTERNAL_API_TOKEN + */ + +const express = require('express'); +const router = express.Router(); +const db = require('../database'); + +// ============================================================================= +// MIDDLEWARE: Internal Token Authentication +// ============================================================================= + +const verifyInternalToken = (req, res, next) => { + const authHeader = req.headers.authorization; + const expectedToken = process.env.INTERNAL_API_TOKEN; + + if (!expectedToken) { + console.error('⚠️ INTERNAL_API_TOKEN not configured'); + return res.status(500).json({ error: 'Server misconfiguration' }); + } + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing or invalid Authorization header' }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + if (token !== expectedToken) { + return res.status(403).json({ error: 'Invalid token' }); + } + + next(); +}; + +// Apply token auth to all routes in this router +router.use(verifyInternalToken); + +// ============================================================================= +// POST /api/internal/social/sync +// Upsert social post metrics from n8n +// ============================================================================= + +router.post('/social/sync', async (req, res) => { + const { platform, platform_post_id, post_title, post_url, posted_at, metrics } = req.body; + + // Validate required fields + if (!platform || !platform_post_id) { + return res.status(400).json({ + error: 'Missing required fields', + required: ['platform', 'platform_post_id'] + }); + } + + // Validate platform enum + const validPlatforms = ['tiktok', 'facebook', 'instagram', 'x', 'bluesky']; + if (!validPlatforms.includes(platform)) { + return res.status(400).json({ + error: 'Invalid platform', + valid: validPlatforms + }); + } + + try { + // Check if post exists + const { rows: existing } = await db.query( + 'SELECT id FROM social_posts WHERE platform = $1 AND platform_post_id = $2', + [platform, platform_post_id] + ); + + if (existing.length > 0) { + // UPDATE existing post + const updateFields = []; + const updateValues = []; + let paramIndex = 1; + + // Build dynamic update based on provided metrics + if (metrics) { + const metricFields = [ + 'views', 'likes', 'comments', 'shares', 'saves', + 'total_play_time_seconds', 'avg_watch_time_seconds', + 'watched_full_pct', 'drop_off_seconds', 'new_followers', + 'top_traffic_source', 'top_traffic_pct' + ]; + + for (const field of metricFields) { + if (metrics[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex}`); + updateValues.push(metrics[field]); + paramIndex++; + } + } + } + + // Always update timestamp + updateFields.push(`updated_at = NOW()`); + + if (updateFields.length > 1) { // More than just updated_at + updateValues.push(platform, platform_post_id); + + await db.query(` + UPDATE social_posts + SET ${updateFields.join(', ')} + WHERE platform = $${paramIndex} AND platform_post_id = $${paramIndex + 1} + `, updateValues); + } + + console.log(`📊 [Social Sync] Updated ${platform}:${platform_post_id}`); + return res.json({ + success: true, + action: 'updated', + post_id: existing[0].id + }); + + } else { + // INSERT new post + const insertResult = await db.query(` + INSERT INTO social_posts ( + platform, platform_post_id, post_title, post_url, posted_at, + views, likes, comments, shares, saves, + total_play_time_seconds, avg_watch_time_seconds, watched_full_pct, + drop_off_seconds, new_followers, top_traffic_source, top_traffic_pct + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING id + `, [ + platform, + platform_post_id, + post_title || 'Untitled', + post_url || null, + posted_at || new Date().toISOString(), + metrics?.views || 0, + metrics?.likes || 0, + metrics?.comments || 0, + metrics?.shares || 0, + metrics?.saves || 0, + metrics?.total_play_time_seconds || 0, + metrics?.avg_watch_time_seconds || 0, + metrics?.watched_full_pct || 0, + metrics?.drop_off_seconds || null, + metrics?.new_followers || 0, + metrics?.top_traffic_source || null, + metrics?.top_traffic_pct || null + ]); + + console.log(`📊 [Social Sync] Created ${platform}:${platform_post_id} -> id ${insertResult.rows[0].id}`); + return res.json({ + success: true, + action: 'created', + post_id: insertResult.rows[0].id + }); + } + + } catch (error) { + console.error('❌ [Social Sync] Error:', error); + return res.status(500).json({ error: 'Database operation failed', details: error.message }); + } +}); + +// ============================================================================= +// POST /api/internal/social/sync/batch +// Batch upsert multiple posts at once (for efficiency) +// ============================================================================= + +router.post('/social/sync/batch', async (req, res) => { + const { posts } = req.body; + + if (!posts || !Array.isArray(posts)) { + return res.status(400).json({ error: 'posts array required' }); + } + + if (posts.length > 100) { + return res.status(400).json({ error: 'Maximum 100 posts per batch' }); + } + + const results = { + success: true, + processed: 0, + created: 0, + updated: 0, + errors: [] + }; + + for (const post of posts) { + try { + const { platform, platform_post_id, post_title, post_url, posted_at, metrics } = post; + + if (!platform || !platform_post_id) { + results.errors.push({ post, error: 'Missing platform or platform_post_id' }); + continue; + } + + // Check if exists + const { rows: existing } = await db.query( + 'SELECT id FROM social_posts WHERE platform = $1 AND platform_post_id = $2', + [platform, platform_post_id] + ); + + if (existing.length > 0) { + // Update + await db.query(` + UPDATE social_posts SET + views = COALESCE($1, views), + likes = COALESCE($2, likes), + comments = COALESCE($3, comments), + shares = COALESCE($4, shares), + saves = COALESCE($5, saves), + updated_at = NOW() + WHERE platform = $6 AND platform_post_id = $7 + `, [ + metrics?.views, metrics?.likes, metrics?.comments, + metrics?.shares, metrics?.saves, + platform, platform_post_id + ]); + results.updated++; + } else { + // Insert + await db.query(` + INSERT INTO social_posts ( + platform, platform_post_id, post_title, post_url, posted_at, + views, likes, comments, shares, saves + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, [ + platform, platform_post_id, + post_title || 'Untitled', + post_url || null, + posted_at || new Date().toISOString(), + metrics?.views || 0, metrics?.likes || 0, + metrics?.comments || 0, metrics?.shares || 0, + metrics?.saves || 0 + ]); + results.created++; + } + results.processed++; + + } catch (error) { + results.errors.push({ post, error: error.message }); + } + } + + console.log(`📊 [Social Sync Batch] Processed ${results.processed}, Created ${results.created}, Updated ${results.updated}`); + res.json(results); +}); + +// ============================================================================= +// POST /api/internal/social/snapshot +// Upsert account-level snapshot (followers, profile views, etc.) +// ============================================================================= + +router.post('/social/snapshot', async (req, res) => { + const { platform, snapshot_date, total_followers, profile_views, search_queries, demographics } = req.body; + + if (!platform) { + return res.status(400).json({ error: 'platform required' }); + } + + const validPlatforms = ['tiktok', 'facebook', 'instagram', 'x', 'bluesky']; + if (!validPlatforms.includes(platform)) { + return res.status(400).json({ error: 'Invalid platform', valid: validPlatforms }); + } + + try { + const date = snapshot_date || new Date().toISOString().split('T')[0]; + + await db.query(` + INSERT INTO social_account_snapshots + (platform, snapshot_date, total_followers, profile_views, search_queries, demographics) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (platform, snapshot_date) + DO UPDATE SET + total_followers = COALESCE(EXCLUDED.total_followers, social_account_snapshots.total_followers), + profile_views = COALESCE(EXCLUDED.profile_views, social_account_snapshots.profile_views), + search_queries = COALESCE(EXCLUDED.search_queries, social_account_snapshots.search_queries), + demographics = COALESCE(EXCLUDED.demographics, social_account_snapshots.demographics) + `, [ + platform, + date, + total_followers || 0, + profile_views || 0, + search_queries || null, + demographics || null + ]); + + console.log(`📊 [Social Snapshot] ${platform} @ ${date}`); + res.json({ success: true, platform, snapshot_date: date }); + + } catch (error) { + console.error('❌ [Social Snapshot] Error:', error); + res.status(500).json({ error: 'Database operation failed', details: error.message }); + } +}); + +// ============================================================================= +// GET /api/internal/social/digest +// Get summary data for Discord digest notification +// ============================================================================= + +router.get('/social/digest', async (req, res) => { + const period = req.query.period || '24h'; + + let interval; + switch (period) { + case '7d': interval = '7 days'; break; + case '30d': interval = '30 days'; break; + case '24h': + default: interval = '24 hours'; + } + + try { + // Get per-platform stats + const { rows: platformStats } = await db.query(` + SELECT + platform, + COUNT(*) as posts, + COALESCE(SUM(views), 0) as views, + COALESCE(SUM(likes), 0) as likes, + COALESCE(SUM(comments), 0) as comments, + COALESCE(SUM(shares), 0) as shares, + COALESCE(SUM(new_followers), 0) as new_followers + FROM social_posts + WHERE updated_at > NOW() - INTERVAL '${interval}' + GROUP BY platform + `); + + // Get latest follower counts from snapshots + const { rows: latestSnapshots } = await db.query(` + SELECT DISTINCT ON (platform) + platform, total_followers, profile_views, snapshot_date + FROM social_account_snapshots + ORDER BY platform, snapshot_date DESC + `); + + // Calculate totals + const totals = platformStats.reduce((acc, p) => ({ + views: acc.views + parseInt(p.views || 0), + likes: acc.likes + parseInt(p.likes || 0), + comments: acc.comments + parseInt(p.comments || 0), + shares: acc.shares + parseInt(p.shares || 0), + new_followers: acc.new_followers + parseInt(p.new_followers || 0) + }), { views: 0, likes: 0, comments: 0, shares: 0, new_followers: 0 }); + + res.json({ + period, + generated_at: new Date().toISOString(), + platforms: platformStats, + snapshots: latestSnapshots, + totals + }); + + } catch (error) { + console.error('❌ [Social Digest] Error:', error); + res.status(500).json({ error: 'Failed to generate digest' }); + } +}); + +module.exports = router;