fix(rsocials): Postiz createPost/createThread use nested posts[] shape

Postiz's /public/v1/posts expects a nested envelope:
  { type, date, shortLink, tags, posts: [{ integration:{id}, value:[{content}], settings:{__type} }] }

The earlier shim's flat {content, integrationIds, type, scheduledAt} was
rejected with "All posts must have an integration id" (the field is named
integration, not integrationIds; it's a nested object, not an array of strings).

Also: Postiz treats PostItem.value[] as thread segments, so createThread now
sends ONE request with multiple value entries rather than N grouped posts.

Callers now pass full {id, identifier} tuples so settings.__type is set
correctly (otherwise Postiz can't route to the provider handler).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-17 10:49:18 -04:00
parent 878646a399
commit 1cde95f3fc
2 changed files with 118 additions and 63 deletions

View File

@ -57,20 +57,44 @@ export async function getIntegrations(config: PostizConfig) {
return res.json();
}
/** POST /public/v1/posts — create a single post (draft, schedule, or now). */
/** POST /public/v1/posts create a single post (draft, schedule, or now).
*
* Postiz expects a nested shape:
* { type, date, shortLink, tags, posts: [{ integration: {id}, value: [{content}], settings: {__type} }] }
*
* Callers pass integrations as {id, identifier} tuples; identifier populates
* settings.__type which Postiz uses to route to the correct provider handler.
*/
export interface PostizIntegrationRef {
id: string;
identifier: string; // e.g. 'x', 'linkedin', 'bluesky'
}
export async function createPost(
config: PostizConfig,
payload: {
content: string;
integrationIds: string[];
integrations: PostizIntegrationRef[];
type: 'draft' | 'schedule' | 'now';
scheduledAt?: string;
group?: string;
},
) {
const body = {
type: payload.type,
date: payload.scheduledAt || new Date().toISOString(),
shortLink: false,
tags: [] as unknown[],
posts: payload.integrations.map(integ => ({
integration: { id: integ.id },
value: [{ content: payload.content, image: [] as unknown[] }],
...(payload.group ? { group: payload.group } : {}),
settings: { __type: integ.identifier },
})),
};
const res = await postizFetch(config, "/public/v1/posts", {
method: "POST",
body: JSON.stringify(payload),
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
@ -107,27 +131,38 @@ export async function listPosts(
return [];
}
/** Create a thread — sends multiple grouped posts sharing a group ID. */
/** Create a thread single Postiz post whose `value` array carries each tweet.
*
* Postiz treats the `value` array on a PostItem as the thread segments, so we
* send ONE request with multiple value entries, not N grouped requests.
*/
export async function createThread(
config: PostizConfig,
tweets: string[],
opts: {
integrationIds: string[];
integrations: PostizIntegrationRef[];
type: 'draft' | 'schedule' | 'now';
scheduledAt?: string;
},
) {
const group = `thread-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
const results = [];
for (const content of tweets) {
const result = await createPost(config, {
content,
integrationIds: opts.integrationIds,
type: opts.type,
scheduledAt: opts.scheduledAt,
group,
});
results.push(result);
const body = {
type: opts.type,
date: opts.scheduledAt || new Date().toISOString(),
shortLink: false,
tags: [] as unknown[],
posts: opts.integrations.map(integ => ({
integration: { id: integ.id },
value: tweets.map(t => ({ content: t, image: [] as unknown[] })),
settings: { __type: integ.identifier },
})),
};
const res = await postizFetch(config, "/public/v1/posts", {
method: "POST",
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Postiz createThread error: ${res.status} ${text}`);
}
return { group, posts: results };
return res.json();
}

View File

@ -31,7 +31,7 @@ import {
} from "./lib/image-gen";
import { DEMO_FEED } from "./lib/types";
import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy";
import { getPostizConfig, getIntegrations, createPost, createThread, listPosts } from "./lib/postiz-client";
import { getPostizConfig, getIntegrations, createPost, createThread, listPosts, type PostizIntegrationRef } from "./lib/postiz-client";
import { verifyToken, extractToken } from "../../server/auth";
import type { EncryptIDClaims } from "../../server/auth";
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
@ -790,9 +790,16 @@ routes.post("/api/postiz/posts", async (c) => {
}
try {
const fetched = await getIntegrations(config);
const byId = new Map<string, any>((fetched || []).map((i: any) => [i.id, i]));
const integrations: PostizIntegrationRef[] = integrationIds
.map((id: string) => byId.get(id))
.filter(Boolean)
.map((i: any) => ({ id: i.id, identifier: i.identifier || i.providerIdentifier }));
if (!integrations.length) return c.json({ error: "No integration matched the requested IDs" }, 400);
const result = await createPost(config, {
content,
integrationIds,
integrations,
type: type || 'draft',
scheduledAt,
});
@ -803,14 +810,25 @@ routes.post("/api/postiz/posts", async (c) => {
});
// Resolve an integration id from a platform name against a live Postiz integrations list.
function resolveIntegrationId(integrations: any[], platform: string): string | null {
if (!platform) return integrations[0]?.id ?? null;
// Resolve a full integration tuple {id, identifier} from a platform name.
// Postiz's public API returns integrations with `identifier` (e.g. 'x'), while
// the internal/admin shape uses `providerIdentifier`. Exact match on either
// beats a loose name includes().
function resolveIntegration(integrations: any[], platform: string): PostizIntegrationRef | null {
const toRef = (i: any): PostizIntegrationRef | null => {
const id = i?.id;
const identifier = i?.identifier || i?.providerIdentifier;
return id && identifier ? { id, identifier } : null;
};
if (!platform) return toRef(integrations[0]);
const needle = platform.toLowerCase();
const match = integrations.find((i: any) =>
i.name?.toLowerCase().includes(needle) ||
i.providerIdentifier?.toLowerCase().includes(needle)
const exact = integrations.find((i: any) =>
i.identifier?.toLowerCase() === needle ||
i.providerIdentifier?.toLowerCase() === needle
);
return match?.id ?? integrations[0]?.id ?? null;
if (exact) return toRef(exact);
const loose = integrations.find((i: any) => i.name?.toLowerCase().includes(needle));
return toRef(loose) || toRef(integrations[0]);
}
// Push a single campaign-flow Post node to Postiz. Writes postizPostId + status
@ -836,15 +854,20 @@ async function sendCampaignNodeToPostiz(
const content = (data.content || '').trim();
if (!content) return { ok: false, error: "Post has no content", code: 400 };
let integrationIds: string[];
let integrations: PostizIntegrationRef[];
try {
const integrations = await getIntegrations(config);
if (!Array.isArray(integrations) || integrations.length === 0) {
const fetched = await getIntegrations(config);
if (!Array.isArray(fetched) || fetched.length === 0) {
return { ok: false, error: "No Postiz integrations configured", code: 400 };
}
const id = data.postizIntegrationId || resolveIntegrationId(integrations, data.platform);
if (!id) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 };
integrationIds = [id];
let ref: PostizIntegrationRef | null = null;
if (data.postizIntegrationId) {
const matched = fetched.find((i: any) => i.id === data.postizIntegrationId);
if (matched) ref = { id: matched.id, identifier: matched.identifier || matched.providerIdentifier };
}
ref = ref || resolveIntegration(fetched, data.platform);
if (!ref) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 };
integrations = [ref];
} catch (err: any) {
return { ok: false, error: `Postiz integrations fetch failed: ${err.message}`, code: 502 };
}
@ -855,7 +878,7 @@ async function sendCampaignNodeToPostiz(
const hashtagLine = data.hashtags?.length ? '\n\n' + data.hashtags.map(h => h.startsWith('#') ? h : `#${h}`).join(' ') : '';
const payload = {
content: content + hashtagLine,
integrationIds,
integrations,
type,
scheduledAt: type === 'schedule' ? scheduledAt!.toISOString() : undefined,
};
@ -875,7 +898,7 @@ async function sendCampaignNodeToPostiz(
if (!n || n.type !== 'post') return;
const nd = n.data as PostNodeData;
nd.postizPostId = postizPostId || `postiz-${Date.now()}`;
nd.postizIntegrationId = integrationIds[0];
nd.postizIntegrationId = integrations[0].id;
nd.postizStatus = 'queued';
nd.postizSentAt = Date.now();
nd.postizError = '';
@ -1054,20 +1077,25 @@ routes.post("/api/postiz/threads", async (c) => {
}
// If no integrationIds provided, try to auto-detect from configured integrations
let ids = integrationIds;
if (!ids?.length) {
try {
const integrations = await getIntegrations(config);
ids = (integrations || []).map((i: any) => i.id).slice(0, 1);
} catch { /* fall through */ }
}
if (!ids?.length) {
return c.json({ error: "No integrationIds provided and no integrations found" }, 400);
let integrations: PostizIntegrationRef[] = [];
try {
const fetched = await getIntegrations(config);
const byId = new Map<string, any>((fetched || []).map((i: any) => [i.id, i]));
const ids: string[] = Array.isArray(integrationIds) && integrationIds.length
? integrationIds
: (fetched || []).slice(0, 1).map((i: any) => i.id);
integrations = ids
.map((id: string) => byId.get(id))
.filter(Boolean)
.map((i: any) => ({ id: i.id, identifier: i.identifier || i.providerIdentifier }));
} catch { /* fall through */ }
if (!integrations.length) {
return c.json({ error: "No integrations provided and no integrations found" }, 400);
}
try {
const result = await createThread(config, tweets, {
integrationIds: ids,
integrations,
type: type || 'draft',
scheduledAt,
});
@ -1421,29 +1449,24 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
switch (node.type) {
case 'post-to-platform': {
if (!postizConfig) throw new Error('Postiz not configured');
const integrations = await getIntegrations(postizConfig);
const platformName = (cfg.platform as string || '').toLowerCase();
const match = (integrations || []).find((i: any) =>
i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName)
);
const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id);
const fetched = await getIntegrations(postizConfig);
const ref = resolveIntegration(fetched || [], (cfg.platform as string) || '');
if (!ref) throw new Error(`No integration matches platform "${cfg.platform}"`);
const content = (cfg.content as string || '') + (cfg.hashtags ? '\n' + cfg.hashtags : '');
await createPost(postizConfig, { content, integrationIds, type: 'draft' });
await createPost(postizConfig, { content, integrations: [ref], type: 'draft' });
results.push({ nodeId: node.id, status: 'success', message: `Draft created on ${cfg.platform || 'default'}`, durationMs: Date.now() - start });
break;
}
case 'cross-post': {
if (!postizConfig) throw new Error('Postiz not configured');
const platforms = (cfg.platforms as string || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const integrations = await getIntegrations(postizConfig);
const fetched = await getIntegrations(postizConfig);
const content = (cfg.content as string || '');
const posted: string[] = [];
for (const plat of platforms) {
const match = (integrations || []).find((i: any) =>
i.name?.toLowerCase().includes(plat) || i.providerIdentifier?.toLowerCase().includes(plat)
);
if (match) {
await createPost(postizConfig, { content, integrationIds: [match.id], type: 'draft' });
const ref = resolveIntegration(fetched || [], plat);
if (ref) {
await createPost(postizConfig, { content, integrations: [ref], type: 'draft' });
posted.push(plat);
}
}
@ -1461,13 +1484,10 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
}
const tweets = threadContent.split(/\n---\n/).map(s => s.trim()).filter(Boolean);
if (tweets.length === 0) throw new Error('No thread content or thread ID provided');
const integrations = await getIntegrations(postizConfig);
const platformName = (cfg.platform as string || '').toLowerCase();
const match = (integrations || []).find((i: any) =>
i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName)
);
const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id);
await createThread(postizConfig, tweets, { integrationIds, type: 'draft' });
const fetched = await getIntegrations(postizConfig);
const ref = resolveIntegration(fetched || [], (cfg.platform as string) || '');
if (!ref) throw new Error(`No integration matches platform "${cfg.platform}"`);
await createThread(postizConfig, tweets, { integrations: [ref], type: 'draft' });
results.push({ nodeId: node.id, status: 'success', message: `Thread draft created (${tweets.length} tweets)`, durationMs: Date.now() - start });
break;
}