Track platform quotas; wait and retry instead of failing
- quota.js: per-platform limits (YouTube 10,000 units/day Pacific per project, Instagram 100 / Threads 250 / TikTok 15 posts per 24 h per account, ...), usage log, .env overrides (QUOTA_<PLATFORM>) - Publishing checks the budget first and records usage; when a platform is out of quota (or answers quota exceeded / 429) the post stays approved and the queue retries after the reset, without re-posting finished platforms - Drafts that exceed a day's limit get a warning - Connections page shows used/limit and when it frees up - Tests for windows (incl. DST), shared vs per-account limits and the wait-and-retry queue flow; docs for quotas and the Google app setup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
006a27a203
commit
fc2179b343
@ -9,10 +9,11 @@ import threads from "./threads.js";
|
|||||||
import reddit from "./reddit.js";
|
import reddit from "./reddit.js";
|
||||||
import * as vault from "./vault.js";
|
import * as vault from "./vault.js";
|
||||||
import * as oauth from "./oauth.js";
|
import * as oauth from "./oauth.js";
|
||||||
|
import * as quota from "./quota.js";
|
||||||
|
|
||||||
export const ADAPTERS = { linkedin, gbp, youtube, tiktok, pinterest, threads, reddit };
|
export const ADAPTERS = { linkedin, gbp, youtube, tiktok, pinterest, threads, reddit };
|
||||||
export const PLATFORM_IDS = Object.keys(ADAPTERS);
|
export const PLATFORM_IDS = Object.keys(ADAPTERS);
|
||||||
export { vault };
|
export { vault, quota };
|
||||||
|
|
||||||
const err = (m, status = 400) => Object.assign(new Error(m), { status });
|
const err = (m, status = 400) => Object.assign(new Error(m), { status });
|
||||||
const FORMAT_WORD = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
|
const FORMAT_WORD = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
|
||||||
@ -55,6 +56,7 @@ export async function finishConnect({ platform, code, state, user }) {
|
|||||||
const app = vault.appCredentials(platform);
|
const app = vault.appCredentials(platform);
|
||||||
const tokens = await oauth.exchangeCode(a, app, { code, redirectUri: s.redirectUri, verifier: s.verifier });
|
const tokens = await oauth.exchangeCode(a, app, { code, redirectUri: s.redirectUri, verifier: s.verifier });
|
||||||
const account = await a.identify(tokens);
|
const account = await a.identify(tokens);
|
||||||
|
if (platform === "youtube") quota.record("youtube", null, "read");
|
||||||
const conn = vault.saveConnection({ client: s.client, platform, account, tokens, scopes: (tokens.scope || "").split(/[\s,]+/).filter(Boolean), by: s.by });
|
const conn = vault.saveConnection({ client: s.client, platform, account, tokens, scopes: (tokens.scope || "").split(/[\s,]+/).filter(Boolean), by: s.by });
|
||||||
// Pick the only target automatically (e.g. one business location).
|
// Pick the only target automatically (e.g. one business location).
|
||||||
if (a.targets) {
|
if (a.targets) {
|
||||||
@ -99,7 +101,9 @@ export async function setTarget(connId, targetId) {
|
|||||||
export async function checkConnection(connId) {
|
export async function checkConnection(connId) {
|
||||||
const { conn, tokens } = await tokensFor(connId);
|
const { conn, tokens } = await tokensFor(connId);
|
||||||
try {
|
try {
|
||||||
|
if (conn.platform === "youtube") quota.ensure("youtube", null, "read");
|
||||||
const account = await adapter(conn.platform).identify(tokens);
|
const account = await adapter(conn.platform).identify(tokens);
|
||||||
|
if (conn.platform === "youtube") quota.record("youtube", null, "read");
|
||||||
return vault.updateConnection(connId, { account, status: "connected", lastError: null });
|
return vault.updateConnection(connId, { account, status: "connected", lastError: null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
vault.updateConnection(connId, { status: "error", lastError: e.message });
|
vault.updateConnection(connId, { status: "error", lastError: e.message });
|
||||||
@ -141,5 +145,12 @@ export async function publish(platform, client, post) {
|
|||||||
if (!c) throw err(`${a.name} isn't connected for this client.`);
|
if (!c) throw err(`${a.name} isn't connected for this client.`);
|
||||||
const { conn, tokens } = await tokensFor(c.id);
|
const { conn, tokens } = await tokensFor(c.id);
|
||||||
const opts = { ...Object.fromEntries((a.fields || []).filter((f) => f.default !== undefined).map((f) => [f.key, f.default])), ...(post.options?.[platform] || {}) };
|
const opts = { ...Object.fromEntries((a.fields || []).filter((f) => f.default !== undefined).map((f) => [f.key, f.default])), ...(post.options?.[platform] || {}) };
|
||||||
return a.publish({ tokens, conn, post, opts, caption: opts.caption ?? post.caption ?? "" });
|
quota.ensure(platform, conn.account?.id);
|
||||||
|
try {
|
||||||
|
const result = await a.publish({ tokens, conn, post, opts, caption: opts.caption ?? post.caption ?? "" });
|
||||||
|
quota.record(platform, conn.account?.id);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
throw quota.fromPlatformError(platform, e) || e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
connector/src/platforms/quota.js
Normal file
135
connector/src/platforms/quota.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// Platform quotas: how many posts / API units each platform allows, how much Quorla has used, and when it resets.
|
||||||
|
// Publishing checks the budget first; when it is used up (or the platform itself says so) the post waits and is
|
||||||
|
// retried automatically after the reset instead of failing. Limits can be raised in .env (QUOTA_<PLATFORM>=n).
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { PROJECT_ROOT, loadEnv } from "../env.js";
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
const FILE = process.env.QUORLA_QUOTA_FILE || path.join(PROJECT_ROOT, "data", "quota.json");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scope "app" = shared by everything using our developer app (YouTube's daily units are per Google project)
|
||||||
|
* scope "account" = per connected account (Instagram, Threads, TikTok creator, …)
|
||||||
|
* window "pt-day" = calendar day in Pacific time (YouTube); "24h" = rolling 24 hours; "minute" = rolling 60 s
|
||||||
|
*/
|
||||||
|
export const LIMITS = {
|
||||||
|
youtube: { scope: "app", window: "pt-day", limit: 10000, unit: "units", cost: { publish: 1600, read: 1 }, note: "Google raises it free after the YouTube API audit." },
|
||||||
|
instagram: { scope: "account", window: "24h", limit: 100, unit: "posts", cost: { publish: 1 } },
|
||||||
|
facebook: { scope: "account", window: "24h", limit: 200, unit: "posts", cost: { publish: 1 } },
|
||||||
|
threads: { scope: "account", window: "24h", limit: 250, unit: "posts", cost: { publish: 1 } },
|
||||||
|
tiktok: { scope: "account", window: "24h", limit: 15, unit: "posts", cost: { publish: 1 } },
|
||||||
|
linkedin: { scope: "account", window: "24h", limit: 100, unit: "posts", cost: { publish: 1 } },
|
||||||
|
pinterest: { scope: "account", window: "24h", limit: 100, unit: "Pins", cost: { publish: 1 } },
|
||||||
|
gbp: { scope: "account", window: "24h", limit: 50, unit: "updates", cost: { publish: 1 } },
|
||||||
|
reddit: { scope: "account", window: "24h", limit: 10, unit: "posts", cost: { publish: 1 }, note: "Kept low on purpose: subreddits ban accounts that post too often." },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function limitFor(platform) {
|
||||||
|
const base = LIMITS[platform];
|
||||||
|
if (!base) return null;
|
||||||
|
const override = Number(process.env[`QUOTA_${platform.toUpperCase()}`]);
|
||||||
|
return { ...base, limit: Number.isFinite(override) && override > 0 ? override : base.limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QuotaError extends Error {
|
||||||
|
constructor(platform, resetAt, message) {
|
||||||
|
super(message || `${platform}: quota used up until ${new Date(resetAt).toISOString()}.`);
|
||||||
|
this.platform = platform; this.resetAt = new Date(resetAt).toISOString(); this.quota = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- time windows ----------
|
||||||
|
function ptOffsetMs(at) {
|
||||||
|
// Offset of America/Los_Angeles from UTC at that instant (handles DST).
|
||||||
|
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-US", { timeZone: "America/Los_Angeles", hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(at).map((p) => [p.type, p.value]));
|
||||||
|
const asUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second);
|
||||||
|
return asUtc - Math.floor(at / 1000) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [start, end) of the quota window that contains `at`. */
|
||||||
|
export function windowAt(lim, at = Date.now()) {
|
||||||
|
if (lim.window === "minute") return { start: at - 60000, end: at, rolling: true, length: 60000 };
|
||||||
|
if (lim.window === "24h") return { start: at - 86400000, end: at, rolling: true, length: 86400000 };
|
||||||
|
const local = new Date(at + ptOffsetMs(at)); // wall-clock date in Pacific time, read with UTC getters
|
||||||
|
const y = local.getUTCFullYear(), m = local.getUTCMonth(), d = local.getUTCDate();
|
||||||
|
return { start: ptMidnight(y, m, d), end: ptMidnight(y, m, d + 1), rolling: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC instant of 00:00 Pacific time on that date (tries PDT and PST; the one that round-trips is right). */
|
||||||
|
function ptMidnight(y, m, d) {
|
||||||
|
for (const hours of [7, 8]) {
|
||||||
|
const utc = Date.UTC(y, m, d, hours);
|
||||||
|
if (ptOffsetMs(utc) === -hours * 3600000) return utc;
|
||||||
|
}
|
||||||
|
return Date.UTC(y, m, d, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- usage log ----------
|
||||||
|
function read() { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return { events: [] }; } }
|
||||||
|
function write(db) {
|
||||||
|
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||||
|
const cutoff = Date.now() - 3 * 86400000; // keep 3 days, plenty for every window
|
||||||
|
db.events = db.events.filter((e) => new Date(e.at).getTime() > cutoff);
|
||||||
|
const tmp = `${FILE}.tmp`; fs.writeFileSync(tmp, JSON.stringify(db)); fs.renameSync(tmp, FILE);
|
||||||
|
}
|
||||||
|
const scopeKey = (lim, account) => (lim.scope === "app" ? "app" : String(account || "unknown"));
|
||||||
|
|
||||||
|
/** Units used in the current window, and when room next frees up. */
|
||||||
|
export function usage(platform, account, at = Date.now()) {
|
||||||
|
const lim = limitFor(platform);
|
||||||
|
if (!lim) return null;
|
||||||
|
const key = scopeKey(lim, account);
|
||||||
|
const w = windowAt(lim, at);
|
||||||
|
const events = read().events.filter((e) => e.platform === platform && e.key === key && new Date(e.at).getTime() >= w.start && new Date(e.at).getTime() <= at);
|
||||||
|
const used = events.reduce((s, e) => s + e.units, 0);
|
||||||
|
// Rolling windows free up as old events age out; calendar windows reset at the end of the day.
|
||||||
|
const resetAt = w.rolling ? (events.length ? new Date(events[0].at).getTime() + w.length : at) : w.end;
|
||||||
|
return { platform, key, used, limit: lim.limit, remaining: Math.max(0, lim.limit - used), unit: lim.unit, window: lim.window, resetAt: new Date(resetAt).toISOString(), note: lim.note || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws QuotaError if `kind` (publish/read) doesn't fit in what's left. */
|
||||||
|
export function ensure(platform, account, kind = "publish", at = Date.now()) {
|
||||||
|
const lim = limitFor(platform);
|
||||||
|
if (!lim) return;
|
||||||
|
const cost = lim.cost[kind] ?? 1;
|
||||||
|
const u = usage(platform, account, at);
|
||||||
|
if (u.used + cost > lim.limit) {
|
||||||
|
throw new QuotaError(platform, u.resetAt, `${platform}: daily limit reached (${u.used}/${lim.limit} ${lim.unit}). The post will go out automatically after ${u.resetAt}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function record(platform, account, kind = "publish", at = Date.now()) {
|
||||||
|
const lim = limitFor(platform);
|
||||||
|
if (!lim) return;
|
||||||
|
const db = read();
|
||||||
|
db.events.push({ platform, key: scopeKey(lim, account), units: lim.cost[kind] ?? 1, kind, at: new Date(at).toISOString() });
|
||||||
|
write(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Does this error from a platform mean "slow down / quota used up"? Returns a QuotaError or null. */
|
||||||
|
export function fromPlatformError(platform, e, at = Date.now()) {
|
||||||
|
const msg = String(e?.message || "");
|
||||||
|
const body = JSON.stringify(e?.body || "");
|
||||||
|
const status = e?.status;
|
||||||
|
const quotaLike = status === 429
|
||||||
|
|| /quotaExceeded|rateLimitExceeded|userRateLimitExceeded|dailyLimitExceeded/i.test(body + msg)
|
||||||
|
|| /rate_limit_exceeded|spam_risk_too_many_posts|spam_risk_user_banned_from_posting/i.test(body + msg)
|
||||||
|
|| /too many requests|request limit reached|application request limit|posting limit|maximum number of posts/i.test(msg)
|
||||||
|
|| /"code":\s*(4|17|32|613)\b/.test(body);
|
||||||
|
if (!quotaLike) return null;
|
||||||
|
const lim = limitFor(platform);
|
||||||
|
const resetAt = lim?.window === "pt-day" ? windowAt(lim, at).end : at + (status === 429 ? 15 * 60000 : 60 * 60000);
|
||||||
|
return new QuotaError(platform, resetAt, `${platform}: the platform's limit was reached (${msg.slice(0, 160)}). Retrying automatically after ${new Date(resetAt).toISOString()}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many posts are already planned for this platform/account in the window of `when` (for draft warnings). */
|
||||||
|
export function plannedWarning(platform, account, plannedCount, when) {
|
||||||
|
const lim = limitFor(platform);
|
||||||
|
if (!lim) return null;
|
||||||
|
const cost = lim.cost.publish ?? 1;
|
||||||
|
const needed = (plannedCount + 1) * cost;
|
||||||
|
if (needed <= lim.limit) return null;
|
||||||
|
const perWindow = Math.floor(lim.limit / cost);
|
||||||
|
return `${platform}: ${plannedCount + 1} posts are planned in the same ${lim.window === "pt-day" ? "day (Pacific time)" : "24 hours"}, more than the ${perWindow} allowed. Extra posts will wait and go out after the limit resets.`;
|
||||||
|
}
|
||||||
@ -7,14 +7,15 @@ import crypto from "node:crypto";
|
|||||||
import { PROJECT_ROOT } from "./env.js";
|
import { PROJECT_ROOT } from "./env.js";
|
||||||
import * as meta from "./meta.js";
|
import * as meta from "./meta.js";
|
||||||
import * as extra from "./platforms/index.js";
|
import * as extra from "./platforms/index.js";
|
||||||
|
import * as quota from "./platforms/quota.js";
|
||||||
|
|
||||||
const META_PLATFORMS = ["instagram", "facebook"];
|
const META_PLATFORMS = ["instagram", "facebook"];
|
||||||
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
|
export const ALL_PLATFORMS = [...META_PLATFORMS, ...extra.PLATFORM_IDS];
|
||||||
|
|
||||||
const DIR = path.join(PROJECT_ROOT, "data", "schedule");
|
const DIR = process.env.QUORLA_SCHEDULE_DIR || path.join(PROJECT_ROOT, "data", "schedule"); // override for tests
|
||||||
const FILE = path.join(DIR, "queue.json");
|
const FILE = path.join(DIR, "queue.json");
|
||||||
const LOCK = path.join(DIR, ".runner.lock");
|
const LOCK = path.join(DIR, ".runner.lock");
|
||||||
const LOG = path.join(PROJECT_ROOT, "data", "publish-log.jsonl");
|
const LOG = process.env.QUORLA_SCHEDULE_DIR ? path.join(DIR, "publish-log.jsonl") : path.join(PROJECT_ROOT, "data", "publish-log.jsonl");
|
||||||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||||
fs.mkdirSync(DIR, { recursive: true });
|
fs.mkdirSync(DIR, { recursive: true });
|
||||||
|
|
||||||
@ -77,6 +78,32 @@ export function getPost(id) {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The account a quota is counted against for this client/platform (Page, Instagram or connected account id). */
|
||||||
|
function quotaAccount(client, platform) {
|
||||||
|
if (platform === "instagram") return clientInfo(client).igId || null;
|
||||||
|
if (platform === "facebook") return clientInfo(client).pageId || null;
|
||||||
|
return extra.vault.connectionFor(client, platform)?.account?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warnings (not errors) when a day's planned posts exceed a platform's limit; extra posts then wait for the reset. */
|
||||||
|
function quotaWarnings(post, items) {
|
||||||
|
const at = new Date(post.publishAt).getTime();
|
||||||
|
const out = [];
|
||||||
|
for (const platform of post.platforms) {
|
||||||
|
const lim = quota.limitFor(platform);
|
||||||
|
if (!lim) continue;
|
||||||
|
const acct = quotaAccount(post.client, platform);
|
||||||
|
const w = lim.window === "pt-day" ? quota.windowAt(lim, at) : { start: at - 86400000, end: at + 86400000 };
|
||||||
|
const planned = items.filter((x) => x.id !== post.id && ["draft", "approved", "publishing"].includes(x.status) && x.platforms.includes(platform)
|
||||||
|
&& (lim.scope === "app" || (x.client === post.client))
|
||||||
|
&& new Date(x.publishAt).getTime() >= w.start && new Date(x.publishAt).getTime() < w.end).length;
|
||||||
|
const used = at - Date.now() < 86400000 ? quota.usage(platform, acct)?.used / (lim.cost.publish || 1) || 0 : 0;
|
||||||
|
const msg = quota.plannedWarning(platform, acct, planned + Math.floor(used), at);
|
||||||
|
if (msg) out.push(msg);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a post to the queue as a DRAFT. Nothing is published until a person approves it.
|
* Add a post to the queue as a DRAFT. Nothing is published until a person approves it.
|
||||||
* localTime is in the client's time zone, e.g. "2026-10-20 19:00".
|
* localTime is in the client's time zone, e.g. "2026-10-20 19:00".
|
||||||
@ -105,7 +132,9 @@ export function createPost({ client, platforms = ["instagram"], type = "IMAGE",
|
|||||||
publishAt: publishAt.toISOString(), status: "draft", createdBy, createdAt: new Date().toISOString(), notes: notes || null,
|
publishAt: publishAt.toISOString(), status: "draft", createdBy, createdAt: new Date().toISOString(), notes: notes || null,
|
||||||
results: {}, errors: {},
|
results: {}, errors: {},
|
||||||
};
|
};
|
||||||
const items = read(); items.push(post); write(items);
|
const items = read();
|
||||||
|
post.warnings = quotaWarnings(post, items);
|
||||||
|
items.push(post); write(items);
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,6 +146,7 @@ export function updatePost(id, patch, by = "team") {
|
|||||||
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
|
for (const pl of p.platforms) if (!META_PLATFORMS.includes(pl)) extra.validate(pl, p.client, p);
|
||||||
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
if (patch.localTime) { const t = zonedToUtc(patch.localTime, p.timezone); p.publishAt = t.toISOString(); p.localTime = formatInTz(t, p.timezone); }
|
||||||
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
p.status = "draft"; p.updatedBy = by; p.updatedAt = new Date().toISOString();
|
||||||
|
p.warnings = quotaWarnings(p, items);
|
||||||
write(items); return p;
|
write(items); return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +168,13 @@ export async function approvePost(id, by) {
|
|||||||
write(items); return p;
|
write(items); return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Quota use per platform for a client (Connections page): used / limit, and when it resets. */
|
||||||
|
export function quotaSummary(client) {
|
||||||
|
const c = clientInfo(client);
|
||||||
|
const platforms = [...(c.igId ? ["instagram"] : []), ...(c.pageId ? ["facebook"] : []), ...extra.vault.listConnections({ client }).map((x) => x.platform)];
|
||||||
|
return Object.fromEntries(platforms.map((pl) => [pl, quota.usage(pl, quotaAccount(client, pl))]).filter(([, u]) => u));
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelPost(id, by = "team") {
|
export async function cancelPost(id, by = "team") {
|
||||||
const items = read(); const p = items.find((x) => x.id === id);
|
const items = read(); const p = items.find((x) => x.id === id);
|
||||||
if (!p) throw err("Post not found.", 404);
|
if (!p) throw err("Post not found.", 404);
|
||||||
@ -150,21 +187,43 @@ export async function cancelPost(id, by = "team") {
|
|||||||
write(items); return p;
|
write(items); return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Meta posts go through the Meta connector; quotas are tracked here the same way as for the other platforms. */
|
||||||
|
async function publishMeta(platform, a, p) {
|
||||||
|
const acct = platform === "instagram" ? a.instagram?.id : a.page_id;
|
||||||
|
quota.ensure(platform, acct);
|
||||||
|
try {
|
||||||
|
const r = platform === "instagram"
|
||||||
|
? await meta.igPublishAny(a, { type: p.type, media: p.media, caption: p.caption, collaborators: p.collaborators })
|
||||||
|
: await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media });
|
||||||
|
quota.record(platform, acct);
|
||||||
|
return r;
|
||||||
|
} catch (e) { throw quota.fromPlatformError(platform, e) || e; }
|
||||||
|
}
|
||||||
|
|
||||||
async function publishNow(p) {
|
async function publishNow(p) {
|
||||||
let a = null;
|
let a = null;
|
||||||
|
p.waiting = p.waiting || {};
|
||||||
for (const platform of p.platforms) {
|
for (const platform of p.platforms) {
|
||||||
if (p.results[platform]?.id && !p.results[platform]?.cancelled) continue; // already done (e.g. FB scheduled natively)
|
if (p.results[platform]?.id && !p.results[platform]?.cancelled) continue; // already done (e.g. FB scheduled natively)
|
||||||
try {
|
try {
|
||||||
if (META_PLATFORMS.includes(platform)) a ||= await accountFor(p.client);
|
if (META_PLATFORMS.includes(platform)) a ||= await accountFor(p.client);
|
||||||
p.results[platform] = platform === "instagram"
|
p.results[platform] = META_PLATFORMS.includes(platform) ? await publishMeta(platform, a, p) : await extra.publish(platform, p.client, p);
|
||||||
? await meta.igPublishAny(a, { type: p.type, media: p.media, caption: p.caption, collaborators: p.collaborators })
|
delete p.errors[platform]; delete p.waiting[platform];
|
||||||
: platform === "facebook"
|
|
||||||
? await meta.fbPublishAny(a, { message: p.fbCaption || p.caption, media: p.media })
|
|
||||||
: await extra.publish(platform, p.client, p);
|
|
||||||
delete p.errors[platform];
|
|
||||||
log({ action: "published", platform, post: p.id, client: p.client, result: p.results[platform] });
|
log({ action: "published", platform, post: p.id, client: p.client, result: p.results[platform] });
|
||||||
} catch (e) { p.errors[platform] = e.message; log({ action: "failed", platform, post: p.id, client: p.client, error: e.message }); }
|
} catch (e) {
|
||||||
|
p.errors[platform] = e.message;
|
||||||
|
if (e.quota) { p.waiting[platform] = e.resetAt; log({ action: "waiting_for_quota", platform, post: p.id, client: p.client, until: e.resetAt }); }
|
||||||
|
else { delete p.waiting[platform]; log({ action: "failed", platform, post: p.id, client: p.client, error: e.message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const waits = Object.values(p.waiting);
|
||||||
|
if (waits.length) {
|
||||||
|
// Some platforms are out of quota: keep the post approved and try those again after the earliest reset.
|
||||||
|
p.status = "approved";
|
||||||
|
p.retryAt = waits.sort()[0];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete p.retryAt;
|
||||||
const ok = p.platforms.filter((x) => p.results[x]?.id).length;
|
const ok = p.platforms.filter((x) => p.results[x]?.id).length;
|
||||||
p.status = ok === p.platforms.length ? "published" : ok ? "partial" : "failed";
|
p.status = ok === p.platforms.length ? "published" : ok ? "partial" : "failed";
|
||||||
p.publishedAt = new Date().toISOString();
|
p.publishedAt = new Date().toISOString();
|
||||||
@ -180,6 +239,7 @@ export async function runDue({ now = Date.now() } = {}) {
|
|||||||
const items = read();
|
const items = read();
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
if (p.status !== "approved" || new Date(p.publishAt) > now) continue;
|
if (p.status !== "approved" || new Date(p.publishAt) > now) continue;
|
||||||
|
if (p.retryAt && new Date(p.retryAt) > now) continue; // waiting for a platform quota to reset
|
||||||
p.status = "publishing"; write(items);
|
p.status = "publishing"; write(items);
|
||||||
await publishNow(p); write(items);
|
await publishNow(p); write(items);
|
||||||
done.push({ id: p.id, client: p.client, status: p.status, errors: p.errors });
|
done.push({ id: p.id, client: p.client, status: p.status, errors: p.errors });
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-"));
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-test-"));
|
||||||
process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json");
|
process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json");
|
||||||
|
process.env.QUORLA_QUOTA_FILE = path.join(TMP, "quota.json");
|
||||||
process.env.DASHBOARD_SECRET = "test-secret-not-real";
|
process.env.DASHBOARD_SECRET = "test-secret-not-real";
|
||||||
process.env.PUBLIC_URL = "https://app.example.test";
|
process.env.PUBLIC_URL = "https://app.example.test";
|
||||||
|
|
||||||
|
|||||||
133
connector/test/quota.test.mjs
Normal file
133
connector/test/quota.test.mjs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
// Offline tests for platform quotas and the queue's wait-and-retry behaviour.
|
||||||
|
// Run: node --test connector/test/quota.test.mjs (temporary files only; no network)
|
||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-quota-"));
|
||||||
|
process.env.QUORLA_QUOTA_FILE = path.join(TMP, "quota.json");
|
||||||
|
process.env.QUORLA_CONNECTIONS_FILE = path.join(TMP, "connections.json");
|
||||||
|
process.env.QUORLA_SCHEDULE_DIR = path.join(TMP, "schedule");
|
||||||
|
process.env.DASHBOARD_SECRET = "test-secret-not-real";
|
||||||
|
process.env.ALLOW_PUBLISH = "true";
|
||||||
|
|
||||||
|
const quota = await import("../src/platforms/quota.js");
|
||||||
|
const P = await import("../src/platforms/index.js");
|
||||||
|
const sched = await import("../src/schedule.js");
|
||||||
|
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
let calls = [];
|
||||||
|
const json = (b, status = 200, headers = {}) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json", ...headers } });
|
||||||
|
before(() => {
|
||||||
|
globalThis.fetch = async (u, init = {}) => {
|
||||||
|
const url = String(u);
|
||||||
|
calls.push({ url, method: init.method || "GET" });
|
||||||
|
if (url.startsWith("https://api.linkedin.com/rest/posts")) return new Response("", { status: 201, headers: { "x-restli-id": `urn:li:share:${calls.length}` } });
|
||||||
|
return json({ error: { message: `no fake route ${url}` } }, 599);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
after(() => { globalThis.fetch = realFetch; fs.rmSync(TMP, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
const H = 3600000;
|
||||||
|
const reset = () => { try { fs.rmSync(process.env.QUORLA_QUOTA_FILE); } catch { /* none yet */ } };
|
||||||
|
|
||||||
|
test("YouTube's day runs midnight to midnight Pacific time, including the DST change", () => {
|
||||||
|
const lim = quota.limitFor("youtube");
|
||||||
|
const noonPT = Date.parse("2026-10-21T19:00:00Z"); // 12:00 PDT (UTC-7)
|
||||||
|
const w = quota.windowAt(lim, noonPT);
|
||||||
|
assert.equal(new Date(w.start).toISOString(), "2026-10-21T07:00:00.000Z");
|
||||||
|
assert.equal(new Date(w.end).toISOString(), "2026-10-22T07:00:00.000Z");
|
||||||
|
const dst = quota.windowAt(lim, Date.parse("2026-11-01T20:00:00Z")); // Nov 1: clocks go back, 25-hour day
|
||||||
|
assert.equal(new Date(dst.start).toISOString(), "2026-11-01T07:00:00.000Z");
|
||||||
|
assert.equal(new Date(dst.end).toISOString(), "2026-11-02T08:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YouTube allows 6 uploads (6 x 1,600 of 10,000 units), then waits until midnight Pacific", () => {
|
||||||
|
reset();
|
||||||
|
const at = Date.parse("2026-10-21T19:00:00Z");
|
||||||
|
for (let i = 0; i < 6; i++) { quota.ensure("youtube", null, "publish", at); quota.record("youtube", null, "publish", at); }
|
||||||
|
const u = quota.usage("youtube", null, at);
|
||||||
|
assert.deepEqual([u.used, u.remaining], [9600, 400]);
|
||||||
|
assert.throws(() => quota.ensure("youtube", null, "publish", at), (e) => e.quota && e.resetAt === "2026-10-22T07:00:00.000Z");
|
||||||
|
assert.doesNotThrow(() => quota.ensure("youtube", null, "publish", Date.parse("2026-10-22T07:00:01Z")), "new day, new budget");
|
||||||
|
assert.doesNotThrow(() => quota.ensure("youtube", "someone-else", "read", at), "reads (1 unit) still fit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("YouTube's budget is shared by all clients; per-account platforms are separate", () => {
|
||||||
|
reset();
|
||||||
|
const at = Date.now();
|
||||||
|
for (let i = 0; i < 6; i++) quota.record("youtube", `client-${i}`, "publish", at);
|
||||||
|
assert.throws(() => quota.ensure("youtube", "client-x", "publish", at), /daily limit reached/);
|
||||||
|
for (let i = 0; i < 15; i++) quota.record("tiktok", "acct-a", "publish", at - (15 - i) * 60000);
|
||||||
|
assert.throws(() => quota.ensure("tiktok", "acct-a", "publish", at), (e) => e.quota);
|
||||||
|
assert.doesNotThrow(() => quota.ensure("tiktok", "acct-b", "publish", at), "another TikTok account has its own limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rolling 24 h windows free up when the oldest post ages out", () => {
|
||||||
|
reset();
|
||||||
|
const at = Date.parse("2026-10-21T12:00:00Z");
|
||||||
|
const first = at - 20 * H;
|
||||||
|
quota.record("tiktok", "acct", "publish", first);
|
||||||
|
for (let i = 1; i < 15; i++) quota.record("tiktok", "acct", "publish", at - H);
|
||||||
|
let err;
|
||||||
|
try { quota.ensure("tiktok", "acct", "publish", at); } catch (e) { err = e; }
|
||||||
|
assert.equal(err.resetAt, new Date(first + 24 * H).toISOString());
|
||||||
|
assert.doesNotThrow(() => quota.ensure("tiktok", "acct", "publish", first + 24 * H + 1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits can be changed in .env", () => {
|
||||||
|
reset();
|
||||||
|
process.env.QUOTA_REDDIT = "2";
|
||||||
|
quota.record("reddit", "u", "publish"); quota.record("reddit", "u", "publish");
|
||||||
|
assert.throws(() => quota.ensure("reddit", "u", "publish"), /2\/2 posts/);
|
||||||
|
delete process.env.QUOTA_REDDIT;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("platform 'slow down' errors become a retry, other errors don't", () => {
|
||||||
|
const at = Date.parse("2026-10-21T19:00:00Z");
|
||||||
|
const yt = quota.fromPlatformError("youtube", { message: "YouTube: upload failed", status: 403, body: { error: { errors: [{ reason: "quotaExceeded" }] } } }, at);
|
||||||
|
assert.equal(yt.resetAt, "2026-10-22T07:00:00.000Z");
|
||||||
|
const tt = quota.fromPlatformError("tiktok", { message: "TikTok: spam_risk_too_many_posts", status: 403 }, at);
|
||||||
|
assert.ok(tt.quota);
|
||||||
|
assert.equal(quota.fromPlatformError("reddit", { message: "Reddit: Too Many Requests", status: 429 }, at).resetAt, new Date(at + 15 * 60000).toISOString());
|
||||||
|
assert.equal(quota.fromPlatformError("linkedin", { message: "LinkedIn: invalid image", status: 400 }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drafts beyond the daily limit get a warning, not an error", () => {
|
||||||
|
reset();
|
||||||
|
process.env.QUOTA_LINKEDIN = "1";
|
||||||
|
P.vault.saveConnection({ client: "falcon-fashions", platform: "linkedin", account: { id: "abc", name: "Pat" }, tokens: { accessToken: "t", expiresAt: new Date(Date.now() + 864e5).toISOString() }, by: "t" });
|
||||||
|
const when = new Date(Date.now() + 3 * 864e5).toISOString().slice(0, 10) + " 10:00";
|
||||||
|
const first = sched.createPost({ client: "falcon-fashions", platforms: ["linkedin"], type: "TEXT", caption: "one", localTime: when });
|
||||||
|
assert.deepEqual(first.warnings, []);
|
||||||
|
const second = sched.createPost({ client: "falcon-fashions", platforms: ["linkedin"], type: "TEXT", caption: "two", localTime: when.replace("10:00", "11:00") });
|
||||||
|
assert.equal(second.warnings.length, 1);
|
||||||
|
assert.match(second.warnings[0], /more than the 1 allowed/);
|
||||||
|
delete process.env.QUOTA_LINKEDIN;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the queue waits for the reset and then publishes, without failing the post", async () => {
|
||||||
|
reset();
|
||||||
|
process.env.QUOTA_LINKEDIN = "1";
|
||||||
|
const post = sched.createPost({ client: "falcon-fashions", platforms: ["linkedin"], type: "TEXT", caption: "Hello", localTime: new Date(Date.now() + 2 * 60000).toLocaleString("sv-SE", { timeZone: "America/Toronto" }).slice(0, 16) });
|
||||||
|
await sched.approvePost(post.id, "Tester");
|
||||||
|
quota.record("linkedin", "abc", "publish"); // the day's single post is already used
|
||||||
|
calls = [];
|
||||||
|
const t1 = Date.now() + 3 * 60000;
|
||||||
|
await sched.runDue({ now: t1 });
|
||||||
|
let p = sched.getPost(post.id);
|
||||||
|
assert.equal(p.status, "approved", "still approved, not failed");
|
||||||
|
assert.ok(p.retryAt && p.waiting.linkedin, "remembers when to retry");
|
||||||
|
assert.equal(calls.length, 0, "nothing was sent to LinkedIn");
|
||||||
|
await sched.runDue({ now: t1 + 60000 });
|
||||||
|
assert.equal(calls.length, 0, "not retried before the reset");
|
||||||
|
reset(); // the 24 h window has passed
|
||||||
|
await sched.runDue({ now: new Date(p.retryAt).getTime() + 1000 });
|
||||||
|
p = sched.getPost(post.id);
|
||||||
|
assert.equal(p.status, "published");
|
||||||
|
assert.ok(p.results.linkedin.id);
|
||||||
|
assert.ok(!p.retryAt && !p.waiting.linkedin);
|
||||||
|
delete process.env.QUOTA_LINKEDIN;
|
||||||
|
});
|
||||||
@ -100,6 +100,7 @@ app.get("/api/platforms", h(async (req) => ({
|
|||||||
app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; }));
|
app.put("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.saveApp(req.params.id, req.body || {}, req.user.name || req.user.email); return { ok: true }; }));
|
||||||
app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; }));
|
app.delete("/api/platforms/:id/app", requireAdmin, h(async (req) => { extra.adapter(req.params.id); extra.vault.removeApp(req.params.id); return { ok: true }; }));
|
||||||
app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) })));
|
app.get("/api/connections", h(async (req) => ({ connections: extra.vault.listConnections({ client: req.query.client || undefined }) })));
|
||||||
|
app.get("/api/quota", h(async (req) => ({ quota: sched.quotaSummary(clients.safeSlug(req.query.client) && String(req.query.client)) })));
|
||||||
app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
|
app.get("/api/oauth/:platform/start", requireAdmin, h(async (req) => {
|
||||||
const client = String(req.query.client || "");
|
const client = String(req.query.client || "");
|
||||||
if (!clients.getClient(client)) throw Object.assign(new Error("Pick a client first."), { status: 400 });
|
if (!clients.getClient(client)) throw Object.assign(new Error("Pick a client first."), { status: 400 });
|
||||||
|
|||||||
@ -7,6 +7,19 @@ import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeA
|
|||||||
|
|
||||||
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
|
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
|
||||||
|
|
||||||
|
/** "3 of 15 posts used · resets 9:12 PM" (YouTube: units per day, shared by all clients). */
|
||||||
|
function QuotaLine({ q }) {
|
||||||
|
if (!q) return null;
|
||||||
|
const reset = new Date(q.resetAt).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" });
|
||||||
|
const full = q.remaining <= 0;
|
||||||
|
return (
|
||||||
|
<div className={cx("text-[12px]", full ? "text-warn" : "text-muted")}>
|
||||||
|
{fmt(q.used)} of {fmt(q.limit)} {q.unit} used {q.window === "pt-day" ? "today (all clients)" : "in the last 24 h"}
|
||||||
|
{(q.used > 0 || full) && <> · {full ? "full, posts wait until" : "frees up"} {reset}</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CopyText({ text }) {
|
function CopyText({ text }) {
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
|
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
|
||||||
@ -64,7 +77,7 @@ function AppDialog({ platform, onClose, onSaved }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError }) {
|
function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, reload, setError, quota }) {
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState(null);
|
||||||
const [targets, setTargets] = useState(null);
|
const [targets, setTargets] = useState(null);
|
||||||
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
|
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
|
||||||
@ -108,6 +121,7 @@ function PlatformCard({ platform, conn, client, isAdmin, mediaLinks, onSetup, re
|
|||||||
Connected {timeAgo(conn.connectedAt)} by {conn.connectedBy}
|
Connected {timeAgo(conn.connectedAt)} by {conn.connectedBy}
|
||||||
{conn.account?.url && <> · <a className="text-brand-deep" href={conn.account.url} target="_blank" rel="noreferrer">View profile</a></>}
|
{conn.account?.url && <> · <a className="text-brand-deep" href={conn.account.url} target="_blank" rel="noreferrer">View profile</a></>}
|
||||||
</div>
|
</div>
|
||||||
|
<QuotaLine q={quota} />
|
||||||
{conn.lastError && <div className="text-[12px] text-bad">{conn.lastError}</div>}
|
{conn.lastError && <div className="text-[12px] text-bad">{conn.lastError}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -147,12 +161,16 @@ export default function Connections() {
|
|||||||
const [conns, setConns] = useState([]);
|
const [conns, setConns] = useState([]);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [setup, setSetup] = useState(null);
|
const [setup, setSetup] = useState(null);
|
||||||
|
const [quota, setQuota] = useState({});
|
||||||
const client = params.get("client") || "";
|
const client = params.get("client") || "";
|
||||||
const notice = params.get("connected");
|
const notice = params.get("connected");
|
||||||
const oauthError = params.get("error");
|
const oauthError = params.get("error");
|
||||||
|
|
||||||
const loadPlatforms = useCallback(() => api("/api/platforms").then(setPlatforms).catch(setError), []);
|
const loadPlatforms = useCallback(() => api("/api/platforms").then(setPlatforms).catch(setError), []);
|
||||||
const loadConns = useCallback(() => (client ? api(`/api/connections?client=${encodeURIComponent(client)}`).then((r) => setConns(r.connections)).catch(setError) : Promise.resolve()), [client]);
|
const loadConns = useCallback(() => (client ? Promise.all([
|
||||||
|
api(`/api/connections?client=${encodeURIComponent(client)}`).then((r) => setConns(r.connections)),
|
||||||
|
api(`/api/quota?client=${encodeURIComponent(client)}`).then((r) => setQuota(r.quota)).catch(() => setQuota({})),
|
||||||
|
]).catch(setError) : Promise.resolve()), [client]);
|
||||||
|
|
||||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); loadPlatforms(); }, [loadPlatforms]);
|
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); loadPlatforms(); }, [loadPlatforms]);
|
||||||
useEffect(() => { loadConns(); }, [loadConns]);
|
useEffect(() => { loadConns(); }, [loadConns]);
|
||||||
@ -196,12 +214,18 @@ export default function Connections() {
|
|||||||
<span><b>Facebook:</b> {current?.pageId ? "Page linked" : current?.igOnly ? <span className="text-muted">none (Instagram only)</span> : <span className="text-muted">not linked</span>}</span>
|
<span><b>Facebook:</b> {current?.pageId ? "Page linked" : current?.igOnly ? <span className="text-muted">none (Instagram only)</span> : <span className="text-muted">not linked</span>}</span>
|
||||||
{!current?.linked && <span className="text-muted">Link it from the client's page.</span>}
|
{!current?.linked && <span className="text-muted">Link it from the client's page.</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{(quota.instagram || quota.facebook) && (
|
||||||
|
<div className="px-4 pb-3 grid gap-0.5">
|
||||||
|
{quota.instagram && <div className="flex gap-2 items-baseline"><span className="text-[12px] font-medium w-[70px]">Instagram</span><QuotaLine q={quota.instagram} /></div>}
|
||||||
|
{quota.facebook && <div className="flex gap-2 items-baseline"><span className="text-[12px] font-medium w-[70px]">Facebook</span><QuotaLine q={quota.facebook} /></div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
|
||||||
{platforms.platforms.map((p) => (
|
{platforms.platforms.map((p) => (
|
||||||
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} isAdmin={isAdmin}
|
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => c.platform === p.id)} client={client} isAdmin={isAdmin}
|
||||||
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} />
|
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} reload={loadConns} setError={setError} quota={quota[p.id]} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,9 @@ export default function Publishing() {
|
|||||||
<div className="text-[12.5px] text-ink-2 mt-0.5"><b>{p.localTime}</b> <span className="text-muted">({p.timezone.split("/").pop().replace("_", " ")} time)</span></div>
|
<div className="text-[12.5px] text-ink-2 mt-0.5"><b>{p.localTime}</b> <span className="text-muted">({p.timezone.split("/").pop().replace("_", " ")} time)</span></div>
|
||||||
<p className="text-[13px] text-ink-2 mt-1.5 line-clamp-2 whitespace-pre-line">{p.caption || <i className="text-muted">No caption</i>}</p>
|
<p className="text-[13px] text-ink-2 mt-1.5 line-clamp-2 whitespace-pre-line">{p.caption || <i className="text-muted">No caption</i>}</p>
|
||||||
{p.collaborators?.length > 0 && <div className="text-[12px] text-muted mt-1">Collab: {p.collaborators.map((c) => "@" + c).join(", ")}</div>}
|
{p.collaborators?.length > 0 && <div className="text-[12px] text-muted mt-1">Collab: {p.collaborators.map((c) => "@" + c).join(", ")}</div>}
|
||||||
{Object.entries(p.errors || {}).map(([k, v]) => <div key={k} className="text-[12px] text-bad mt-1">{k}: {v}</div>)}
|
{Object.entries(p.errors || {}).filter(([k]) => !p.waiting?.[k]).map(([k, v]) => <div key={k} className="text-[12px] text-bad mt-1">{k}: {v}</div>)}
|
||||||
|
{Object.entries(p.waiting || {}).map(([k, until]) => <div key={k} className="text-[12px] text-warn mt-1">{k}: daily limit reached. Goes out automatically after {new Date(until).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" })}.</div>)}
|
||||||
|
{p.status === "draft" && (p.warnings || []).map((w) => <div key={w} className="text-[12px] text-warn mt-1">{w}</div>)}
|
||||||
{Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => <a key={k} href={v.permalink} target="_blank" rel="noreferrer" className="text-[12px] text-brand mr-3">View on {k}</a>)}
|
{Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => <a key={k} href={v.permalink} target="_blank" rel="noreferrer" className="text-[12px] text-brand mr-3">View on {k}</a>)}
|
||||||
{p.results?.facebook?.scheduledOnFacebook && !p.results.facebook.cancelled && <div className="text-[12px] text-muted mt-1">Facebook part is scheduled in Meta Planner.</div>}
|
{p.results?.facebook?.scheduledOnFacebook && !p.results.facebook.cancelled && <div className="text-[12px] text-muted mt-1">Facebook part is scheduled in Meta Planner.</div>}
|
||||||
<div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div>
|
<div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div>
|
||||||
|
|||||||
26
docs/google-app.md
Normal file
26
docs/google-app.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Google app: Quorla (YouTube + Google Business Profile)
|
||||||
|
|
||||||
|
- Google Cloud project **Quorla** (ID `quorla`) in the **metatroncubesolutions.com** organization, created with
|
||||||
|
dev@metatroncubesolutions.com.
|
||||||
|
- APIs enabled: YouTube Data API v3, My Business Account Management API, My Business Business Information API,
|
||||||
|
Google My Business API. No billing account needed; all are free within quota.
|
||||||
|
- OAuth consent screen (Google Auth Platform): app name Quorla, logo `branding/quorla-icon-1024.png`, support and
|
||||||
|
developer contact dev@metatroncubesolutions.com, home https://app.quorlasocial.com, privacy /privacy, terms /terms,
|
||||||
|
authorized domain quorlasocial.com. Audience **External**, publishing status **Testing**.
|
||||||
|
- Scopes: openid, userinfo.email, business.manage (non-sensitive); youtube.upload, youtube.readonly (sensitive).
|
||||||
|
No restricted scopes, so no paid security assessment is needed.
|
||||||
|
- Test users (only these can connect while in Testing; max 100): dev@metatroncubesolutions.com,
|
||||||
|
metatroncubesoftwaresolutions@gmail.com. Add each client's Google account here until the app is verified.
|
||||||
|
- OAuth web client "Quorla web (app.quorlasocial.com)" with redirect URIs
|
||||||
|
https://app.quorlasocial.com/oauth/youtube/callback and https://app.quorlasocial.com/oauth/gbp/callback.
|
||||||
|
Its ID and secret are in the PC's `.env` as YOUTUBE_CLIENT_ID/SECRET and GBP_CLIENT_ID/SECRET; copy those four lines
|
||||||
|
to the server's `.env`, or enter them in Quorla → Connections → Set up. Google shows the secret only once; if it is
|
||||||
|
lost, add a new secret on the client in Google Cloud.
|
||||||
|
|
||||||
|
## Still to do
|
||||||
|
1. **Business Profile API access**: request it (Google form) for project number of `quorla`; until approved the
|
||||||
|
Business Profile APIs have a quota of 0.
|
||||||
|
2. **Verify quorlasocial.com in Google Search Console** (DNS TXT record) — required before Google verifies the app.
|
||||||
|
3. After deploy: **publish the app** (Audience → Publish app) and submit **verification** (sensitive YouTube scopes):
|
||||||
|
Google asks for a demo video of the sign-in and how each scope is used.
|
||||||
|
4. **YouTube API audit** (free form) to lift the 10,000 units/day quota and the private-only upload limit.
|
||||||
@ -56,6 +56,30 @@ TikTok also needs the domain verified in its developer portal.
|
|||||||
- **Not built yet:** comments/inbox and analytics for these platforms (only follower counts are shown),
|
- **Not built yet:** comments/inbox and analytics for these platforms (only follower counts are shown),
|
||||||
Reddit image posts, Pinterest video cover images, LinkedIn document (PDF) posts, X (paid API).
|
Reddit image posts, Pinterest video cover images, LinkedIn document (PDF) posts, X (paid API).
|
||||||
|
|
||||||
|
## Quotas and limits
|
||||||
|
|
||||||
|
Quorla tracks each platform's limit (`connector/src/platforms/quota.js`, usage in `data/quota.json`):
|
||||||
|
|
||||||
|
| Platform | Limit tracked | Window |
|
||||||
|
|---|---|---|
|
||||||
|
| YouTube | 10,000 units per Google project, shared by all clients (upload = 1,600, read = 1) → about 6 uploads/day until the YouTube audit raises it | calendar day, Pacific time |
|
||||||
|
| Instagram | 100 API posts per account | rolling 24 h |
|
||||||
|
| Facebook | 200 posts per Page | rolling 24 h |
|
||||||
|
| Threads | 250 posts per profile | rolling 24 h |
|
||||||
|
| TikTok | 15 posts per creator | rolling 24 h |
|
||||||
|
| LinkedIn | 100 posts per member/Page | rolling 24 h |
|
||||||
|
| Pinterest | 100 Pins per account | rolling 24 h |
|
||||||
|
| Google Business Profile | 50 updates per account | rolling 24 h |
|
||||||
|
| Reddit | 10 posts per account (kept low: subreddits ban frequent posters) | rolling 24 h |
|
||||||
|
|
||||||
|
- Before publishing, Quorla checks the budget. If it's used up, or the platform answers "quota exceeded" / "too many
|
||||||
|
requests", the post **stays approved and waits**; the queue retries it automatically after the reset. Platforms that
|
||||||
|
already went out are not posted twice.
|
||||||
|
- Saving a draft that would exceed a day's limit shows a warning on the draft (not an error).
|
||||||
|
- The Connections page shows used / limit and when it frees up.
|
||||||
|
- Raise a limit after a platform approves more with `QUOTA_<PLATFORM>=n` in `.env` (e.g. `QUOTA_YOUTUBE=1000000`).
|
||||||
|
- Tests: `node --test connector/test/quota.test.mjs`.
|
||||||
|
|
||||||
## Suggested order after merging
|
## Suggested order after merging
|
||||||
|
|
||||||
1. Deploy (`git pull && docker compose up -d --build`), then Connections → Set up for one platform at a time.
|
1. Deploy (`git pull && docker compose up -d --build`), then Connections → Set up for one platform at a time.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user