Add internal API routes for n8n social analytics integration

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 💙❄️
This commit is contained in:
Claude (Chronicler #76)
2026-04-10 21:00:19 +00:00
committed by Claude
parent 92e460a90b
commit f35325a597
2 changed files with 361 additions and 0 deletions

View File

@@ -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;

View File

@@ -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;