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() {
setBroken(true)} />;
+ if (local && isVideo(item.media)) return ;
+ if (local) return
;
+ return (
+
+ Everything that went out on your clients' accounts, with its numbers + {oldestSnap ? ` ยท Instagram and Facebook as of ${timeAgo(oldestSnap)}` : ""} +
++ Instagram and Facebook numbers refresh nightly with each client's snapshot. Reach and saves aren't available for Facebook posts. + YouTube counts refresh nightly{data?.statsAt ? ` (last ${timeAgo(data.statsAt)})` : ""}. TikTok and Google Business Profile don't share per-post numbers with Quorla yet. +
+