diff --git a/services/arbiter-3.0/migrations/108_social_analytics.sql b/services/arbiter-3.0/migrations/108_social_analytics.sql new file mode 100644 index 0000000..fac127c --- /dev/null +++ b/services/arbiter-3.0/migrations/108_social_analytics.sql @@ -0,0 +1,72 @@ +-- Social Analytics Tables +-- Task #108: TikTok Analytics Dashboard (Phase 1 - Manual Entry) +-- Created by Chronicler #76 + +-- Platform enum for future expansion +CREATE TYPE social_platform AS ENUM ('tiktok', 'facebook', 'instagram', 'x', 'bluesky'); + +-- Individual post tracking +CREATE TABLE social_posts ( + id SERIAL PRIMARY KEY, + platform social_platform NOT NULL DEFAULT 'tiktok', + post_title VARCHAR(255) NOT NULL, + post_url VARCHAR(500), + posted_at TIMESTAMP NOT NULL, + video_length_seconds INTEGER, + + -- Core engagement metrics + views INTEGER DEFAULT 0, + likes INTEGER DEFAULT 0, + comments INTEGER DEFAULT 0, + shares INTEGER DEFAULT 0, + saves INTEGER DEFAULT 0, + + -- Watch metrics + total_play_time_seconds INTEGER DEFAULT 0, + avg_watch_time_seconds DECIMAL(10,2) DEFAULT 0, + watched_full_pct DECIMAL(5,2) DEFAULT 0, + drop_off_seconds INTEGER, + + -- Growth metrics + new_followers INTEGER DEFAULT 0, + + -- Traffic source (store top source) + top_traffic_source VARCHAR(50), + top_traffic_pct DECIMAL(5,2), + + -- Metadata + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + notes TEXT +); + +-- Account-level snapshots (weekly or on-demand) +CREATE TABLE social_account_snapshots ( + id SERIAL PRIMARY KEY, + platform social_platform NOT NULL DEFAULT 'tiktok', + snapshot_date DATE NOT NULL, + + -- Account metrics + total_followers INTEGER DEFAULT 0, + profile_views INTEGER DEFAULT 0, + + -- Top search queries (JSON array) + search_queries JSONB, + + -- Audience demographics (JSON) + demographics JSONB, + + created_at TIMESTAMP DEFAULT NOW(), + + -- One snapshot per platform per day + UNIQUE(platform, snapshot_date) +); + +-- Indexes for common queries +CREATE INDEX idx_social_posts_platform ON social_posts(platform); +CREATE INDEX idx_social_posts_posted_at ON social_posts(posted_at DESC); +CREATE INDEX idx_social_snapshots_platform_date ON social_account_snapshots(platform, snapshot_date DESC); + +-- Comments +COMMENT ON TABLE social_posts IS 'Individual social media post analytics (manual entry for now, API later)'; +COMMENT ON TABLE social_account_snapshots IS 'Account-level metrics snapshots for trend tracking'; diff --git a/services/arbiter-3.0/src/routes/admin/index.js b/services/arbiter-3.0/src/routes/admin/index.js index 02137fd..dd19f0d 100644 --- a/services/arbiter-3.0/src/routes/admin/index.js +++ b/services/arbiter-3.0/src/routes/admin/index.js @@ -14,6 +14,7 @@ const rolesRouter = require('./roles'); const schedulerRouter = require('./scheduler'); const discordAuditRouter = require('./discord-audit'); const systemRouter = require('./system'); +const socialRouter = require('./social'); router.use(requireTrinityAccess); @@ -81,5 +82,6 @@ router.use('/roles', rolesRouter); router.use('/scheduler', schedulerRouter); router.use('/discord', discordAuditRouter); router.use('/system', systemRouter); +router.use('/social', socialRouter); module.exports = router; diff --git a/services/arbiter-3.0/src/routes/admin/social.js b/services/arbiter-3.0/src/routes/admin/social.js new file mode 100644 index 0000000..3d82bb7 --- /dev/null +++ b/services/arbiter-3.0/src/routes/admin/social.js @@ -0,0 +1,262 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../database'); + +// GET /admin/social - Main dashboard +router.get('/', async (req, res) => { + try { + const platform = req.query.platform || 'tiktok'; + + // Get recent posts (last 30 days) + const { rows: posts } = await db.query(` + SELECT * FROM social_posts + WHERE platform = $1 + ORDER BY posted_at DESC + LIMIT 20 + `, [platform]); + + // Get aggregate stats for this platform + const { rows: stats } = await db.query(` + SELECT + COUNT(*) as total_posts, + COALESCE(SUM(views), 0) as total_views, + COALESCE(SUM(likes), 0) as total_likes, + COALESCE(SUM(comments), 0) as total_comments, + COALESCE(SUM(shares), 0) as total_shares, + COALESCE(SUM(new_followers), 0) as total_new_followers, + COALESCE(AVG(watched_full_pct), 0) as avg_completion, + COALESCE(AVG(avg_watch_time_seconds), 0) as avg_watch_time + FROM social_posts + WHERE platform = $1 + AND posted_at > NOW() - INTERVAL '30 days' + `, [platform]); + + // Get latest account snapshot + const { rows: snapshots } = await db.query(` + SELECT * FROM social_account_snapshots + WHERE platform = $1 + ORDER BY snapshot_date DESC + LIMIT 1 + `, [platform]); + + // Calculate engagement rate + const totalViews = parseInt(stats[0]?.total_views || 0); + const totalLikes = parseInt(stats[0]?.total_likes || 0); + const totalComments = parseInt(stats[0]?.total_comments || 0); + const totalShares = parseInt(stats[0]?.total_shares || 0); + const engagementRate = totalViews > 0 + ? (((totalLikes + totalComments + totalShares) / totalViews) * 100).toFixed(2) + : 0; + + res.render('admin/social/index', { + title: 'Social Analytics', + platform, + posts, + stats: { + ...stats[0], + engagementRate + }, + snapshot: snapshots[0] || null + }); + + } catch (error) { + console.error('Social Analytics Error:', error); + res.status(500).send('Error loading social analytics'); + } +}); + +// GET /admin/social/add - Add post form +router.get('/add', (req, res) => { + const platform = req.query.platform || 'tiktok'; + res.render('admin/social/add', { + title: 'Add Social Post', + platform + }); +}); + +// POST /admin/social/add - Create new post +router.post('/add', async (req, res) => { + try { + const { + platform, + post_title, + post_url, + posted_at, + video_length_seconds, + 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, + notes + } = req.body; + + await db.query(` + INSERT INTO social_posts ( + platform, post_title, post_url, posted_at, video_length_seconds, + 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, notes + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + `, [ + platform || 'tiktok', + post_title, + post_url || null, + posted_at, + video_length_seconds || null, + views || 0, + likes || 0, + comments || 0, + shares || 0, + saves || 0, + total_play_time_seconds || 0, + avg_watch_time_seconds || 0, + watched_full_pct || 0, + drop_off_seconds || null, + new_followers || 0, + top_traffic_source || null, + top_traffic_pct || null, + notes || null + ]); + + res.redirect(`/admin/social?platform=${platform || 'tiktok'}`); + + } catch (error) { + console.error('Add Post Error:', error); + res.status(500).send('Error adding post'); + } +}); + +// GET /admin/social/post/:id - View post detail +router.get('/post/:id', async (req, res) => { + try { + const { rows } = await db.query( + 'SELECT * FROM social_posts WHERE id = $1', + [req.params.id] + ); + + if (rows.length === 0) { + return res.status(404).send('Post not found'); + } + + res.render('admin/social/detail', { + title: 'Post Analytics', + post: rows[0] + }); + + } catch (error) { + console.error('Post Detail Error:', error); + res.status(500).send('Error loading post'); + } +}); + +// POST /admin/social/post/:id/update - Update post metrics +router.post('/post/:id/update', async (req, res) => { + try { + const { + 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, notes + } = req.body; + + await db.query(` + UPDATE social_posts SET + views = $1, likes = $2, comments = $3, shares = $4, saves = $5, + total_play_time_seconds = $6, avg_watch_time_seconds = $7, + watched_full_pct = $8, drop_off_seconds = $9, new_followers = $10, + top_traffic_source = $11, top_traffic_pct = $12, notes = $13, + updated_at = NOW() + WHERE id = $14 + `, [ + views || 0, likes || 0, comments || 0, shares || 0, saves || 0, + total_play_time_seconds || 0, avg_watch_time_seconds || 0, + watched_full_pct || 0, drop_off_seconds || null, new_followers || 0, + top_traffic_source || null, top_traffic_pct || null, notes || null, + req.params.id + ]); + + res.redirect(`/admin/social/post/${req.params.id}`); + + } catch (error) { + console.error('Update Post Error:', error); + res.status(500).send('Error updating post'); + } +}); + +// GET /admin/social/snapshot - Add account snapshot form +router.get('/snapshot', (req, res) => { + const platform = req.query.platform || 'tiktok'; + res.render('admin/social/snapshot', { + title: 'Account Snapshot', + platform + }); +}); + +// POST /admin/social/snapshot - Save account snapshot +router.post('/snapshot', async (req, res) => { + try { + const { + platform, + snapshot_date, + total_followers, + profile_views, + search_queries, + demographics + } = req.body; + + // Upsert - update if exists for this date, insert if not + 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 = EXCLUDED.total_followers, + profile_views = EXCLUDED.profile_views, + search_queries = EXCLUDED.search_queries, + demographics = EXCLUDED.demographics + `, [ + platform || 'tiktok', + snapshot_date, + total_followers || 0, + profile_views || 0, + search_queries ? JSON.parse(search_queries) : null, + demographics ? JSON.parse(demographics) : null + ]); + + res.redirect(`/admin/social?platform=${platform || 'tiktok'}`); + + } catch (error) { + console.error('Snapshot Error:', error); + res.status(500).send('Error saving snapshot'); + } +}); + +// DELETE /admin/social/post/:id - Delete post +router.post('/post/:id/delete', async (req, res) => { + try { + const { rows } = await db.query( + 'SELECT platform FROM social_posts WHERE id = $1', + [req.params.id] + ); + const platform = rows[0]?.platform || 'tiktok'; + + await db.query('DELETE FROM social_posts WHERE id = $1', [req.params.id]); + + res.redirect(`/admin/social?platform=${platform}`); + + } catch (error) { + console.error('Delete Post Error:', error); + res.status(500).send('Error deleting post'); + } +}); + +module.exports = router; diff --git a/services/arbiter-3.0/src/views/admin/social/add.ejs b/services/arbiter-3.0/src/views/admin/social/add.ejs new file mode 100644 index 0000000..a3c309d --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social/add.ejs @@ -0,0 +1,195 @@ +
+ โ† Back to Dashboard +
+ +
+
+
+

๐Ÿ“ Add Post Analytics

+

Enter metrics from TikTok Studio

+
+ +
+ + + + +
+

Post Information

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+

Engagement (from top right icons)

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

Watch Metrics (from Overview tab)

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

Growth & Traffic

+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + +
+ + +
+ + Cancel + + +
+
+
+
diff --git a/services/arbiter-3.0/src/views/admin/social/detail.ejs b/services/arbiter-3.0/src/views/admin/social/detail.ejs new file mode 100644 index 0000000..5ebb08a --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social/detail.ejs @@ -0,0 +1,235 @@ +
+ โ† Back to Dashboard +
+ +
+ +
+
+
+
+

<%= post.post_title %>

+
+ ๐Ÿ“… <%= new Date(post.posted_at).toLocaleDateString() %> + <% if (post.video_length_seconds) { %> + โฑ๏ธ <%= post.video_length_seconds %>s + <% } %> + <% if (post.post_url) { %> + View on TikTok โ†’ + <% } %> +
+
+
+ + +
+
+
+
+ + +
+
+
<%= Number(post.views || 0).toLocaleString() %>
+
Views
+
+
+
<%= Number(post.likes || 0).toLocaleString() %>
+
Likes
+
+
+
<%= post.comments || 0 %>
+
Comments
+
+
+
<%= post.shares || 0 %>
+
Shares
+
+
+
<%= post.saves || 0 %>
+
Saves
+
+
+ + +
+ +
+

โฑ๏ธ Watch Performance

+
+
+ Avg Watch Time + <%= Number(post.avg_watch_time_seconds || 0).toFixed(2) %>s +
+
+ Watched Full Video + + <%= Number(post.watched_full_pct || 0).toFixed(1) %>% + +
+ <% if (post.drop_off_seconds) { %> +
+ Drop-off Point + <%= post.drop_off_seconds %>s +
+ <% } %> +
+ Total Play Time + + <% + const totalSecs = post.total_play_time_seconds || 0; + const hrs = Math.floor(totalSecs / 3600); + const mins = Math.floor((totalSecs % 3600) / 60); + const secs = totalSecs % 60; + %> + <%= hrs %>h:<%= mins %>m:<%= secs %>s + +
+
+
+ + +
+

๐Ÿ“Š Traffic & Growth

+
+ <% if (post.top_traffic_source) { %> +
+ Top Source + + <%= post.top_traffic_source %> + <% if (post.top_traffic_pct) { %> + (<%= post.top_traffic_pct %>%) + <% } %> + +
+ <% } %> +
+ New Followers + <%= post.new_followers || 0 %> +
+
+ Engagement Rate + <% + const engRate = post.views > 0 + ? (((post.likes + post.comments + post.shares) / post.views) * 100).toFixed(2) + : 0; + %> + <%= engRate %>% +
+
+
+
+ + +
+
+

๐Ÿ“ Update Metrics

+

Refresh numbers from TikTok Studio

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+ + <% if (post.notes) { %> +
+

๐Ÿ“ Notes

+

<%= post.notes %>

+
+ <% } %> +
diff --git a/services/arbiter-3.0/src/views/admin/social/index.ejs b/services/arbiter-3.0/src/views/admin/social/index.ejs new file mode 100644 index 0000000..352a7fd --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social/index.ejs @@ -0,0 +1,184 @@ +
+
+

๐Ÿ“Š Social Analytics

+

Track post performance and audience growth

+
+
+ + + Add Post + + + ๐Ÿ“ธ Snapshot + +
+
+ + +
+ +
+ + +
+
+
Posts
+
<%= stats.total_posts || 0 %>
+
+
+
Views
+
<%= Number(stats.total_views || 0).toLocaleString() %>
+
+
+
Likes
+
<%= Number(stats.total_likes || 0).toLocaleString() %>
+
+
+
Comments
+
<%= Number(stats.total_comments || 0).toLocaleString() %>
+
+
+
Shares
+
<%= Number(stats.total_shares || 0).toLocaleString() %>
+
+
+
New Followers
+
<%= Number(stats.total_new_followers || 0).toLocaleString() %>
+
+
+
Engagement
+
<%= stats.engagementRate %>%
+
+
+
Avg Watch
+
<%= Number(stats.avg_watch_time || 0).toFixed(1) %>s
+
+
+ +<% if (snapshot) { %> + +
+
+
+ Account Snapshot (<%= new Date(snapshot.snapshot_date).toLocaleDateString() %>) +
+
+ Followers: + <%= Number(snapshot.total_followers || 0).toLocaleString() %> +
+
+ Profile Views: + <%= Number(snapshot.profile_views || 0).toLocaleString() %> +
+
+
+ <% if (snapshot.search_queries && snapshot.search_queries.length > 0) { %> +
+ Top Searches +
+ <% snapshot.search_queries.slice(0, 3).forEach(q => { %> + <%= q %> + <% }); %> +
+
+ <% } %> +
+
+<% } %> + + +
+
+

Recent Posts

+

Last 30 days performance

+
+
+ + + + + + + + + + + + + + + <% if (posts && posts.length > 0) { %> + <% posts.forEach(post => { %> + + + + + + + + + + + <% }); %> + <% } else { %> + + + + <% } %> + +
PostViewsLikesCommentsSharesCompletionAvg WatchActions
+
+ <%= post.post_title.substring(0, 50) %><%= post.post_title.length > 50 ? '...' : '' %> +
+
+ <%= new Date(post.posted_at).toLocaleDateString() %> + <% if (post.video_length_seconds) { %> + ยท <%= post.video_length_seconds %>s + <% } %> +
+
+ <%= Number(post.views || 0).toLocaleString() %> + + <%= Number(post.likes || 0).toLocaleString() %> + + <%= post.comments || 0 %> + + <%= post.shares || 0 %> + + + <%= Number(post.watched_full_pct || 0).toFixed(1) %>% + + + <%= Number(post.avg_watch_time_seconds || 0).toFixed(1) %>s + + + View + +
+ No posts tracked yet. Add your first post +
+
+
diff --git a/services/arbiter-3.0/src/views/admin/social/snapshot.ejs b/services/arbiter-3.0/src/views/admin/social/snapshot.ejs new file mode 100644 index 0000000..646b248 --- /dev/null +++ b/services/arbiter-3.0/src/views/admin/social/snapshot.ejs @@ -0,0 +1,76 @@ +
+ โ† Back to Dashboard +
+ +
+
+
+

๐Ÿ“ธ Account Snapshot

+

Record account-level metrics from TikTok Studio Overview

+
+ +
+ + + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

Copy from Overview โ†’ Search queries section

+
+ +
+ + +

From Viewers tab (optional but useful)

+
+ +
+ + Cancel + + +
+
+
+