/** * Postiz API client — reads per-space credentials from module settings * and forwards authenticated requests to the Postiz public API. * * Follows the same pattern as listmonk-proxy.ts. */ import { loadCommunity, getDocumentData } from "../../../server/community-store"; export interface PostizConfig { url: string; apiKey: string; } /** Read Postiz credentials from the space's module settings. */ export async function getPostizConfig(spaceSlug: string): Promise { await loadCommunity(spaceSlug); const data = getDocumentData(spaceSlug); if (!data) return null; const settings = data.meta.moduleSettings?.rsocials; if (!settings) return null; const url = settings.postizUrl as string | undefined; const apiKey = settings.postizApiKey as string | undefined; if (!url || !apiKey) return null; return { url: url.replace(/\/+$/, ''), apiKey }; } /** Make an authenticated request to the Postiz API. * * Postiz's public API expects the raw API key in the Authorization header * (NOT `Bearer `). The API lives at `/api/public/v1/*`; we * tolerate either `config.url = 'https://host'` or `config.url = 'https://host/api'` * by inserting `/api` when missing. */ export async function postizFetch( config: PostizConfig, path: string, opts: RequestInit = {}, ): Promise { const headers = new Headers(opts.headers); headers.set("Authorization", config.apiKey); if (!headers.has("Content-Type") && opts.body) { headers.set("Content-Type", "application/json"); } const base = config.url.endsWith('/api') ? config.url : `${config.url}/api`; return fetch(`${base}${path}`, { ...opts, headers }); } /** GET /public/v1/integrations — list connected social channels. */ export async function getIntegrations(config: PostizConfig) { const res = await postizFetch(config, "/public/v1/integrations"); if (!res.ok) throw new Error(`Postiz integrations error: ${res.status}`); return res.json(); } /** 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' /** Per-integration settings overrides merged on top of defaults. */ settings?: Record; /** Optional title (YouTube/Reddit/Pinterest) — forwarded into settings. */ title?: string; } // Platform-specific settings Postiz validates on POST /posts. Keys that are // required-non-empty for each provider live here. We merge defaults when the // caller doesn't supply overrides. function defaultSettingsFor(identifier: string): Record { const base: Record = { __type: identifier }; switch (identifier) { case 'x': base.who_can_reply_post = 'everyone'; break; case 'linkedin': base.post_to_company = false; break; case 'instagram': base.post_type = 'post'; break; case 'youtube': base.type = 'public'; base.title = ''; base.category = '22'; break; // Others (bluesky, threads, mastodon, reddit, ...) don't require settings // beyond __type as of Postiz 5.x. } return base; } /** Merge caller overrides on top of defaults while preserving `__type`. */ function mergeSettings( identifier: string, overrides?: Record, title?: string, ): Record { const merged = { ...defaultSettingsFor(identifier), ...(overrides || {}) }; if (title !== undefined) merged.title = title; merged.__type = identifier; // always wins — Postiz routes on this return merged; } export async function createPost( config: PostizConfig, payload: { content: 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: mergeSettings(integ.identifier, integ.settings, integ.title), })), }; 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 createPost error: ${res.status} ${text}`); } return res.json(); } /** GET /public/v1/posts — list posts in a date range (used for status reconciliation). */ export interface PostizListedPost { id: string; content?: string; publishDate?: string; releaseURL?: string; state: 'QUEUE' | 'PUBLISHED' | 'ERROR' | 'DRAFT'; integration?: { id: string; providerIdentifier?: string; name?: string }; } export async function listPosts( config: PostizConfig, startDate: Date, endDate: Date, ): Promise { const qs = new URLSearchParams({ startDate: startDate.toISOString(), endDate: endDate.toISOString(), }); const res = await postizFetch(config, `/public/v1/posts?${qs}`); if (!res.ok) throw new Error(`Postiz listPosts error: ${res.status}`); const data = await res.json(); // Postiz wraps list as { posts: [...] }; be defensive against raw array. if (Array.isArray(data)) return data as PostizListedPost[]; if (Array.isArray(data?.posts)) return data.posts as PostizListedPost[]; return []; } /** DELETE /public/v1/posts/:id — remove a queued post from Postiz. * * Used by the planner's "resync" flow: when a local node has drifted from * what was sent (detected via postizSyncedHash), we delete + recreate at * Postiz so the scheduler publishes the latest content. Postiz returns 404 * if the post was already published or never existed; we treat that as a * no-op so the caller can proceed to recreate. */ export async function deletePost( config: PostizConfig, postizPostId: string, ): Promise<{ ok: true } | { ok: false; status: number; error: string }> { const res = await postizFetch(config, `/public/v1/posts/${encodeURIComponent(postizPostId)}`, { method: "DELETE", }); if (res.ok || res.status === 404) return { ok: true }; const text = await res.text().catch(() => ""); return { ok: false, status: res.status, error: text || res.statusText }; } /** 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: { integrations: PostizIntegrationRef[]; type: 'draft' | 'schedule' | 'now'; scheduledAt?: string; }, ) { 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: mergeSettings(integ.identifier, integ.settings, integ.title), })), }; 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 res.json(); }