From 999e9fbcec3c09a8cc4b680e63708c02c38aeffd Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Fri, 23 Oct 2026 09:15:34 -0400 Subject: [PATCH] Published page: every client's posts with their numbers - Feed merges Instagram/Facebook snapshot posts (with insights) and everything Quorla published - Filters (client, platform, 7/30/90 days), sort by interactions/reach/views, via-Quorla badge - YouTube view/like/comment counts fetched nightly and on demand (1 quota unit per 50 videos) - Snapshots now keep post thumbnails (IG media/thumbnail url, FB full_picture) Co-Authored-By: Claude Opus 5.5 --- connector/src/meta.js | 5 +- connector/src/platforms/index.js | 13 ++ connector/src/platforms/youtube.js | 10 ++ dashboard/server/index.js | 6 + dashboard/server/published.js | 124 ++++++++++++++++++ dashboard/web/src/App.jsx | 5 +- dashboard/web/src/pages/Published.jsx | 182 ++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 dashboard/server/published.js create mode 100644 dashboard/web/src/pages/Published.jsx diff --git a/connector/src/meta.js b/connector/src/meta.js index 8bfac9d..9658eb2 100644 --- a/connector/src/meta.js +++ b/connector/src/meta.js @@ -148,7 +148,7 @@ export async function igAccountInsights(igId, { sinceDays = 28, metrics = IG_ACC export async function igRecentMedia(igId, { limit = 12, withInsights = true } = {}) { const r = await graph(`${igId}/media`, { - fields: "id,caption,media_type,media_product_type,permalink,timestamp,like_count,comments_count", + fields: "id,caption,media_type,media_product_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count", limit, }); const media = r.data || []; @@ -204,7 +204,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) { needPage(account); const r = await graph( `${account.page_id}/posts`, - { fields: "id,message,created_time,permalink_url,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, + { fields: "id,message,created_time,permalink_url,full_picture,shares,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0)", limit }, { token: account.page_token } ); return (r.data || []).map((p) => ({ @@ -212,6 +212,7 @@ export async function pageRecentPosts(account, { limit = 10 } = {}) { created_time: p.created_time, permalink_url: p.permalink_url, message: p.message, + picture: p.full_picture || null, reactions: p.reactions?.summary?.total_count ?? 0, comments: p.comments?.summary?.total_count ?? 0, shares: p.shares?.count ?? 0, diff --git a/connector/src/platforms/index.js b/connector/src/platforms/index.js index 4926cb2..7b5f3d4 100644 --- a/connector/src/platforms/index.js +++ b/connector/src/platforms/index.js @@ -97,6 +97,19 @@ export async function setTarget(connId, targetId) { return vault.updateConnection(connId, { target: t }); } +/** Per-post numbers for platforms whose adapter can read them (YouTube today). ids: the platform's post ids. */ +export async function postStats(platform, client, ids) { + const a = adapter(platform); + if (!a.stats || !ids.length) return {}; + const conn = vault.connectionFor(client, platform); + if (!conn) return {}; + const { tokens } = await tokensFor(conn.id); + if (platform === "youtube") quota.ensure("youtube", null, "read"); + const out = await a.stats(tokens, ids); + if (platform === "youtube") quota.record("youtube", null, "read"); + return out; +} + /** Re-read the account (name, followers) to check the connection still works. */ export async function checkConnection(connId) { const { conn, tokens } = await tokensFor(connId); diff --git a/connector/src/platforms/youtube.js b/connector/src/platforms/youtube.js index 255cf9c..3c35ee4 100644 --- a/connector/src/platforms/youtube.js +++ b/connector/src/platforms/youtube.js @@ -32,6 +32,16 @@ export default { }; }, + /** View/like/comment counts for uploaded videos (1 quota unit per call, up to 50 ids). */ + async stats(tokens, ids) { + if (!ids.length) return {}; + const { data } = await request("YouTube", `${API}/videos`, { token: tokens.accessToken, query: { part: "statistics,snippet", id: ids.slice(0, 50).join(",") } }); + return Object.fromEntries((data.items || []).map((v) => [v.id, { + views: Number(v.statistics?.viewCount ?? 0), likes: Number(v.statistics?.likeCount ?? 0), comments: Number(v.statistics?.commentCount ?? 0), + thumb: v.snippet?.thumbnails?.medium?.url || v.snippet?.thumbnails?.default?.url || null, + }])); + }, + async publish({ tokens, post, opts, caption }) { const src = (post.media || [])[0]; if (!src || !isVideo(src)) throw new Error("YouTube needs one video file."); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 5c79642..083aef1 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -8,6 +8,7 @@ import { login, logout, currentUser, requireAuth, requireAdmin, setupFirstAdmin, import * as users from "./users.js"; import * as clients from "./clients.js"; import * as metrics from "./metrics.js"; +import * as published from "./published.js"; import * as odoo from "./odoo.js"; import * as jobs from "./jobs.js"; import * as docs from "./docs.js"; @@ -288,6 +289,10 @@ app.get("/api/schedule/media", h(async (req, res) => { })); +// ---------- published posts and their numbers ---------- +app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 }))); +app.post("/api/published/refresh", h(async () => published.refreshStats())); + // ---------- content library: team uploads finished work per client ---------- const who = (req) => req.user.name || req.user.email; const esc = (s) => String(s || "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); @@ -369,6 +374,7 @@ function scheduleNightly() { if (next <= new Date()) next.setDate(next.getDate() + 1); setTimeout(async () => { try { console.log("Nightly refresh:", await metrics.refreshAll()); } catch (e) { console.error("Nightly refresh failed", e); } + try { console.log("Post stats:", await published.refreshStats()); } catch (e) { console.error("Post stats failed", e); } scheduleNightly(); }, next - new Date()); } diff --git a/dashboard/server/published.js b/dashboard/server/published.js new file mode 100644 index 0000000..2067916 --- /dev/null +++ b/dashboard/server/published.js @@ -0,0 +1,124 @@ +// "Published" feed: every post that went out on a client's accounts, with its numbers. +// Instagram and Facebook come from the nightly snapshots (so posts made outside Quorla show too); +// other platforms come from what Quorla published, with YouTube counts fetched on refresh. +import fs from "node:fs"; +import path from "node:path"; +import { DATA_DIR } from "./config.js"; +import * as clients from "./clients.js"; +import * as metrics from "./metrics.js"; +import * as sched from "../../connector/src/schedule.js"; +import * as extra from "../../connector/src/platforms/index.js"; + +const STATS_FILE = path.join(DATA_DIR, "post-stats.json"); +const readStats = () => { try { return JSON.parse(fs.readFileSync(STATS_FILE, "utf8")); } catch { return { fetchedAt: null, posts: {} }; } }; + +const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null); +const sum = (...vs) => { const xs = vs.map(num).filter((x) => x !== null); return xs.length ? xs.reduce((a, b) => a + b, 0) : null; }; + +function igItem(client, p, viaQuorla) { + const i = p.insights || {}; + const likes = num(p.like_count), comments = num(p.comments_count), saves = num(i.saved), shares = num(i.shares); + return { + key: `instagram:${p.id}`, client: client.slug, clientName: client.name, platform: "instagram", + id: p.id, at: p.timestamp, caption: p.caption || "", permalink: p.permalink || null, + format: p.media_product_type === "REELS" ? "Reel" : p.media_type === "CAROUSEL_ALBUM" ? "Carousel" : p.media_type === "VIDEO" ? "Video" : "Image", + thumb: p.thumbnail_url || p.media_url || null, + metrics: { reach: num(i.reach), views: num(i.views), likes, comments, saves, shares }, + interactions: num(i.total_interactions) ?? sum(likes, comments, saves, shares), + viaQuorla, + }; +} + +function fbItem(client, p, viaQuorla) { + return { + key: `facebook:${p.id}`, client: client.slug, clientName: client.name, platform: "facebook", + id: p.id, at: p.created_time, caption: p.message || "", permalink: p.permalink_url || null, format: "Post", + thumb: p.picture || null, + metrics: { reach: null, views: null, likes: num(p.reactions), comments: num(p.comments), saves: null, shares: num(p.shares) }, + interactions: sum(p.reactions, p.comments, p.shares), + viaQuorla, + }; +} + +/** All published posts, newest first. Filters: client slug, platform id, days back. */ +export function feed({ client, platform, days = 30 } = {}) { + const since = Date.now() - Number(days || 30) * 86400000; + const all = clients.listClients().filter((c) => !client || c.slug === client); + const quorla = sched.listPosts({}).filter((p) => ["published", "partial"].includes(p.status)); + const quorlaIds = new Set(quorla.flatMap((p) => Object.values(p.results || {}).map((r) => r?.id).filter(Boolean).map(String))); + const stats = readStats().posts; + const items = []; + + for (const c of all) { + const snap = metrics.readSnapshot(c.slug); + for (const p of snap?.ig?.posts || []) items.push(igItem(c, p, quorlaIds.has(String(p.id)))); + for (const p of snap?.fb?.posts || []) items.push(fbItem(c, p, quorlaIds.has(String(p.id)))); + } + const seen = new Set(items.map((i) => i.key)); + const byClient = Object.fromEntries(all.map((c) => [c.slug, c])); + + // Platforms the snapshots don't cover, plus Quorla posts newer than the last snapshot. + for (const p of quorla) { + const c = byClient[p.client]; + if (!c) continue; + for (const [pl, r] of Object.entries(p.results || {})) { + if (!r?.id || r.cancelled) continue; + const key = `${pl}:${r.id}`; + if (seen.has(key)) continue; + const s = stats[key] || {}; + items.push({ + key, client: c.slug, clientName: c.name, platform: pl, id: String(r.id), + at: p.publishedAt || p.publishAt, caption: p.options?.[pl]?.caption ?? p.caption ?? "", permalink: r.permalink || null, + format: p.type === "REELS" ? "Video" : p.type === "CAROUSEL" ? "Carousel" : p.type === "TEXT" ? "Text" : "Image", + thumb: s.thumb || null, media: p.media?.[0] || null, + metrics: { reach: null, views: num(s.views), likes: num(s.likes), comments: num(s.comments), saves: null, shares: null }, + interactions: sum(s.likes, s.comments), + viaQuorla: true, note: r.inbox ? "Sent to the TikTok app to finish" : null, + }); + seen.add(key); + } + } + + const list = items + .filter((i) => (!platform || i.platform === platform) && i.at && new Date(i.at).getTime() >= since) + .sort((a, b) => new Date(b.at) - new Date(a.at)); + const reachKnown = list.filter((i) => i.metrics.reach !== null); + return { + items: list, + totals: { + posts: list.length, + viaQuorla: list.filter((i) => i.viaQuorla).length, + reach: reachKnown.reduce((s, i) => s + i.metrics.reach, 0), + interactions: list.reduce((s, i) => s + (i.interactions || 0), 0), + avgInteractions: list.length ? Math.round(list.reduce((s, i) => s + (i.interactions || 0), 0) / list.length) : 0, + }, + snapshotsAt: Object.fromEntries(all.map((c) => [c.slug, metrics.readSnapshot(c.slug)?.fetchedAt || null])), + statsAt: readStats().fetchedAt, + }; +} + +/** Fetch counts for Quorla-published posts on platforms with a stats reader (YouTube), last 90 days. */ +export async function refreshStats() { + const since = Date.now() - 90 * 86400000; + const want = {}; + for (const p of sched.listPosts({})) { + if (!["published", "partial"].includes(p.status) || new Date(p.publishedAt || p.publishAt).getTime() < since) continue; + for (const [pl, r] of Object.entries(p.results || {})) { + if (pl !== "youtube" || !r?.id) continue; + ((want[p.client] ||= {})[pl] ||= []).push(String(r.id)); + } + } + const out = readStats(); + const errors = []; + for (const [client, byPl] of Object.entries(want)) { + for (const [pl, ids] of Object.entries(byPl)) { + try { + const s = await extra.postStats(pl, client, ids); + for (const [id, v] of Object.entries(s)) out.posts[`${pl}:${id}`] = v; + } catch (e) { errors.push(`${client} ${pl}: ${e.message}`); } + } + } + out.fetchedAt = new Date().toISOString(); + fs.writeFileSync(STATS_FILE, JSON.stringify(out, null, 1)); + return { fetchedAt: out.fetchedAt, errors }; +} diff --git a/dashboard/web/src/App.jsx b/dashboard/web/src/App.jsx index 0359efb..05d6d0f 100644 --- a/dashboard/web/src/App.jsx +++ b/dashboard/web/src/App.jsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom"; -import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug } from "lucide-react"; +import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3 } from "lucide-react"; import { api } from "./api.js"; import { cx, Spinner } from "./ui.jsx"; import Login from "./pages/Login.jsx"; @@ -10,6 +10,7 @@ import Team from "./pages/Team.jsx"; import Runs from "./pages/Runs.jsx"; import Settings from "./pages/Settings.jsx"; import Publishing from "./pages/Publishing.jsx"; +import Published from "./pages/Published.jsx"; import Inbox from "./pages/Inbox.jsx"; import Library from "./pages/Library.jsx"; import Connections from "./pages/Connections.jsx"; @@ -21,6 +22,7 @@ const NAV = [ { to: "/", label: "Clients", icon: LayoutGrid, end: true }, { to: "/library", label: "Content library", icon: FolderUp }, { to: "/publishing", label: "Publishing", icon: CalendarClock }, + { to: "/published", label: "Published", icon: BarChart3 }, { to: "/inbox", label: "Inbox", icon: MessageCircle }, { to: "/connections", label: "Connections", icon: Plug }, { to: "/team", label: "Team", icon: Users2 }, @@ -116,6 +118,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/web/src/pages/Published.jsx b/dashboard/web/src/pages/Published.jsx new file mode 100644 index 0000000..fb772c2 --- /dev/null +++ b/dashboard/web/src/pages/Published.jsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ExternalLink, ImageOff, RefreshCw, Send } from "lucide-react"; +import { api } from "../api.js"; +import { useAuth } from "../App.jsx"; +import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx"; + +const PLATFORMS = { + instagram: { name: "Instagram", color: "#d62976" }, + facebook: { name: "Facebook", color: "#1877f2" }, + youtube: { name: "YouTube", color: "#ff0000" }, + tiktok: { name: "TikTok", color: "#010101" }, + gbp: { name: "Google Business", color: "#4285f4" }, + linkedin: { name: "LinkedIn", color: "#0a66c2" }, + pinterest: { name: "Pinterest", color: "#e60023" }, + threads: { name: "Threads", color: "#000000" }, + reddit: { name: "Reddit", color: "#ff4500" }, +}; +const COLS = [["reach", "Reach"], ["views", "Views"], ["likes", "Likes"], ["comments", "Comments"], ["saves", "Saves"], ["shares", "Shares"]]; +const SORTS = { newest: "Newest first", interactions: "Most interactions", reach: "Most reach", views: "Most views" }; +const PAGE = 50; + +const isVideo = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m || "")); + +function Thumb({ item }) { + const [broken, setBroken] = useState(false); + const local = item.media ? `/api/schedule/media?path=${encodeURIComponent(item.media)}` : null; + const p = PLATFORMS[item.platform] || { name: item.platform, color: "#6b6e78" }; + const box = "size-14 rounded-md shrink-0 overflow-hidden bg-canvas border border-line"; + if (!broken && item.thumb) return setBroken(true)} />; + if (local && isVideo(item.media)) return