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:
committed by
Claude
parent
92e460a90b
commit
f35325a597
@@ -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;
|
||||
|
||||
359
services/arbiter-3.0/src/routes/api.js
Normal file
359
services/arbiter-3.0/src/routes/api.js
Normal 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;
|
||||
Reference in New Issue
Block a user