diff --git a/connector/src/platforms/http.js b/connector/src/platforms/http.js index c90b7f0..94d9728 100644 --- a/connector/src/platforms/http.js +++ b/connector/src/platforms/http.js @@ -24,11 +24,21 @@ export async function request(platform, url, { method = "GET", headers = {}, jso try { data = text ? JSON.parse(text) : null; } catch { data = text; } const apiError = data && typeof data === "object" && (data.error?.message || data.error_description || (typeof data.error === "string" && data.error) || data.message && !res.ok && data.message); if (!res.ok || (data && typeof data === "object" && data.error && data.error.code && data.error.code !== "ok")) { - throw new PlatformError(platform, apiError || `${res.status} ${res.statusText}`, { status: res.status, body: data }); + const code = data && typeof data === "object" && typeof data.error?.code === "string" ? data.error.code : null; + const msg = HINTS[code] || [apiError || `${res.status} ${res.statusText}`, code && `(${code})`].filter(Boolean).join(" "); + throw new PlatformError(platform, msg, { status: res.status, body: data }); } return { data, headers: res.headers, status: res.status }; } +// Plain-language fixes for error codes whose API message doesn't say what to do. +const HINTS = { + unaudited_client_can_only_post_to_private_accounts: "until TikTok approves the app, it can only post to private accounts. Set the TikTok account to Private (Settings and privacy → Privacy → Private account) and try again. (unaudited_client_can_only_post_to_private_accounts)", + spam_risk_too_many_posts: "TikTok's daily post limit for this account was reached. Try again tomorrow. (spam_risk_too_many_posts)", + spam_risk_user_banned_from_posting: "TikTok has blocked this account from posting. (spam_risk_user_banned_from_posting)", + privacy_level_option_mismatch: "the chosen audience isn't allowed for this account. Pick one of the options shown. (privacy_level_option_mismatch)", +}; + export const fileName = (p) => path.basename(String(p)).replace(/^\d+-[a-z0-9]{4}-/i, ""); export const isUrl = (s) => /^https?:\/\//i.test(String(s || "")); export const isVideo = (s) => /\.(mp4|mov|m4v|webm)$/i.test(String(s || "").split("?")[0]); diff --git a/connector/src/schedule.js b/connector/src/schedule.js index 4d0b2b8..bf88131 100644 --- a/connector/src/schedule.js +++ b/connector/src/schedule.js @@ -168,6 +168,18 @@ export async function approvePost(id, by) { write(items); return p; } +/** An admin retries the platforms that failed. Parts that already posted are skipped, so nothing posts twice. */ +export function retryPost(id, by) { + if (!by) throw err("Retrying needs the name of the person approving."); + const items = read(); const p = items.find((x) => x.id === id); + if (!p) throw err("Post not found.", 404); + if (!["failed", "partial"].includes(p.status)) throw err(`Only failed posts can be retried; this one is ${p.status}.`); + p.status = "approved"; p.retriedBy = by; p.retriedAt = new Date().toISOString(); + if (new Date(p.publishAt) < new Date()) p.publishAt = new Date().toISOString(); + delete p.retryAt; + 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); diff --git a/dashboard/server/index.js b/dashboard/server/index.js index 265c988..5c79642 100644 --- a/dashboard/server/index.js +++ b/dashboard/server/index.js @@ -279,6 +279,7 @@ app.post("/api/schedule/upload", express.raw({ type: "*/*", limit: "500mb" }), h app.post("/api/schedule", h(async (req) => ({ post: sched.createPost({ ...req.body, createdBy: req.user.name || req.user.email }) }))); app.patch("/api/schedule/:id", h(async (req) => ({ post: sched.updatePost(req.params.id, req.body || {}, req.user.name || req.user.email) }))); app.post("/api/schedule/:id/approve", requireAdmin, h(async (req) => { needPublishing(); return { post: await sched.approvePost(req.params.id, req.user.name || req.user.email) }; })); +app.post("/api/schedule/:id/retry", requireAdmin, h(async (req) => { needPublishing(); return { post: sched.retryPost(req.params.id, req.user.name || req.user.email) }; })); app.post("/api/schedule/:id/cancel", h(async (req) => ({ post: await sched.cancelPost(req.params.id, req.user.name || req.user.email) }))); app.get("/api/schedule/media", h(async (req, res) => { const f = path.resolve(String(req.query.path || "")); diff --git a/dashboard/web/src/pages/Publishing.jsx b/dashboard/web/src/pages/Publishing.jsx index 567e7be..34dbc57 100644 --- a/dashboard/web/src/pages/Publishing.jsx +++ b/dashboard/web/src/pages/Publishing.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { CalendarClock, Check, Image as ImageIcon, Plus, Upload, X } from "lucide-react"; +import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, Upload, X } from "lucide-react"; import { api } from "../api.js"; import { useAuth } from "../App.jsx"; import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx"; @@ -83,6 +83,7 @@ export default function Publishing() {