Retry failed platforms on a post; clearer platform error codes
- Publishing: admins can retry a failed or partly published post; parts that already posted are skipped - API errors now include the platform's error code, with plain fixes for common TikTok ones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4198ade454
commit
fd87ba08aa
@ -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]);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 || ""));
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{p.status === "draft" && isAdmin && <Button size="sm" variant="primary" icon={Check} loading={busy === p.id + "approve"} disabled={!data.publishing} onClick={() => act(p, "approve")}>Approve</Button>}
|
||||
{["failed", "partial"].includes(p.status) && isAdmin && <Button size="sm" icon={RotateCcw} loading={busy === p.id + "retry"} disabled={!data.publishing} onClick={() => act(p, "retry")}>Retry failed</Button>}
|
||||
{!["published", "cancelled", "publishing"].includes(p.status) && <Button size="sm" variant="danger" icon={X} loading={busy === p.id + "cancel"} onClick={() => act(p, "cancel")}>Cancel</Button>}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user