feat(rsocials): two-way planner↔Postiz sync with drift detection

Outbound: forward per-node platformSettings + title through
createPost/createThread via new PostizIntegrationRef.settings/title
merged on top of defaultSettingsFor. Record postizSyncedHash on the
node after each successful send.

Inbound: postizReconcile pulls remote content/publishDate onto queued
and draft nodes when the local hash matches postizSyncedHash, so
Postiz-side edits surface in the planner without clobbering pending
local edits. Published posts always take remote content as final.

Drift→Resync: inline editor shows a yellow "Local edits not yet on
Postiz" row when client SHA-1 (normalized content+title+schedule+tags+
platformSettings) drifts from postizSyncedHash. New
/resync-postiz route deletes the Postiz-side post (via new deletePost
helper, 404-tolerant) then re-creates with current content. Rejected
once published.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-17 14:36:56 -04:00
parent 98f554888c
commit dc87eeb91f
5 changed files with 251 additions and 10 deletions

View File

@ -310,6 +310,31 @@ folk-campaign-planner {
.cp-icp-check input { width: auto !important; margin: 0; }
.cp-postiz-resync {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-top: 6px;
border-radius: 6px;
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.35);
}
.cp-postiz-resync[hidden] { display: none; }
.cp-postiz-resync__msg {
flex: 1;
font-size: 11px;
color: #f59e0b;
line-height: 1.3;
}
.cp-postiz-resync button {
width: auto !important;
padding: 4px 10px !important;
font-size: 11px !important;
}
@media (max-width: 720px) {
.cp-pp { width: 56px; }
.cp-pp-title, .cp-pp-hint { display: none; }

View File

@ -726,6 +726,25 @@ class FolkCampaignPlanner extends HTMLElement {
this.scheduleSave();
}
/** Stable normalized content string — must match server's postNodeContentHash. */
private postNodeContentJSON(d: PostNodeData): string {
return JSON.stringify({
content: (d.content || '').trim(),
title: d.title || '',
scheduledAt: d.scheduledAt || '',
hashtags: [...(d.hashtags || [])].sort(),
platformSettings: d.platformSettings || {},
});
}
/** SHA-1 hex (first 16 chars) via SubtleCrypto. Matches server fingerprint. */
private async contentHash16(d: PostNodeData): Promise<string> {
const enc = new TextEncoder().encode(this.postNodeContentJSON(d));
const buf = await crypto.subtle.digest('SHA-1', enc);
return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}
/**
* Create a platform-tagged post node from a Postiz spec. Pre-populates
* platform, postType, title, and platformSettings so the inline editor
@ -852,6 +871,7 @@ class FolkCampaignPlanner extends HTMLElement {
case 'post': {
const d = node.data as PostNodeData;
const hasPostiz = !!d.postizPostId;
const isQueued = hasPostiz && d.postizStatus === 'queued';
const postizBadge = hasPostiz
? `<div class="cp-postiz-state cp-postiz-state--${d.postizStatus || 'queued'}">
${d.postizStatus === 'published'
@ -861,6 +881,12 @@ class FolkCampaignPlanner extends HTMLElement {
: `\u23f3 Queued in Postiz (${esc(d.postizPostId || '')})`}
</div>`
: '';
const resyncRow = isQueued
? `<div class="cp-postiz-resync" data-resync-row hidden>
<span class="cp-postiz-resync__msg">\u26a0 Local edits not yet on Postiz</span>
<button class="cp-btn cp-btn--postiz" data-action="resync-postiz" style="flex-shrink:0">Resync</button>
</div>`
: '';
const spec = getPlatformSpec(d.platform);
const charCount = (d.content || '').length;
const charMax = spec?.charLimit ?? 2200;
@ -935,6 +961,7 @@ class FolkCampaignPlanner extends HTMLElement {
<input data-field="hashtags" value="${esc(d.hashtags.join(', '))}" placeholder="${spec?.hashtagLimit === 0 ? 'Not used on this platform' : 'comma-separated'}"/>
${extraFields}
${postizBadge}
${resyncRow}
<button class="cp-btn cp-btn--postiz" data-action="send-postiz" ${canSend ? '' : 'disabled'} style="margin-top:8px;width:100%">
${hasPostiz ? 'Sent to Postiz' : (d.scheduledAt ? '\u{1f4c5} Schedule to Postiz' : '\u{1f680} Send to Postiz now')}
</button>
@ -1230,9 +1257,65 @@ class FolkCampaignPlanner extends HTMLElement {
this.generateFromBriefNode(node.id);
} else if (action === 'send-postiz') {
this.sendNodeToPostiz(node.id, el as HTMLButtonElement);
} else if (action === 'resync-postiz') {
this.resyncNodeToPostiz(node.id, el as HTMLButtonElement);
}
});
});
// Live drift indicator: compare local hash vs postizSyncedHash on input.
if (node.type === 'post' && (node.data as PostNodeData).postizPostId) {
const resyncRow = panel.querySelector('[data-resync-row]') as HTMLElement | null;
const updateDrift = async () => {
if (!resyncRow) return;
const d = node.data as PostNodeData;
if (d.postizStatus !== 'queued' || !d.postizSyncedHash) {
resyncRow.hidden = true;
return;
}
const current = await this.contentHash16(d);
resyncRow.hidden = current === d.postizSyncedHash;
};
void updateDrift();
panel.querySelectorAll('input, textarea, select').forEach(el => {
el.addEventListener('input', () => void updateDrift());
el.addEventListener('change', () => void updateDrift());
});
}
}
private async resyncNodeToPostiz(nodeId: string, btn: HTMLButtonElement) {
if (!this.currentFlowId) return;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
this.executeSave();
await new Promise(r => setTimeout(r, 400));
}
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Resyncing…';
try {
const res = await fetch(
`${this.basePath}api/campaign/flows/${encodeURIComponent(this.currentFlowId)}/nodes/${encodeURIComponent(nodeId)}/resync-postiz`,
{ method: 'POST' },
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
const node = this.nodes.find(n => n.id === nodeId);
if (node && node.type === 'post') {
const d = node.data as PostNodeData;
d.postizPostId = data.postizPostId;
d.postizStatus = 'queued';
d.postizSentAt = Date.now();
d.postizError = '';
}
btn.textContent = 'Resynced';
setTimeout(() => { this.enterInlineEdit(nodeId); this.drawCanvasContent(); }, 600);
} catch (err: any) {
btn.textContent = (err.message || 'Error').slice(0, 40);
setTimeout(() => { btn.textContent = original; btn.disabled = false; }, 3000);
}
}
private async sendNodeToPostiz(nodeId: string, btn: HTMLButtonElement) {
@ -2564,14 +2647,20 @@ class FolkCampaignPlanner extends HTMLElement {
});
this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView());
// Wheel zoom
// Wheel: two-finger scroll = pan, Ctrl/Cmd+wheel or trackpad pinch (ctrlKey) = zoom
svg.addEventListener('wheel', (e: WheelEvent) => {
e.preventDefault();
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const factor = 1 - e.deltaY * 0.003;
this.zoomAt(mx, my, factor);
if (e.ctrlKey || e.metaKey) {
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const factor = 1 - e.deltaY * 0.01;
this.zoomAt(mx, my, factor);
} else {
this.canvasPanX -= e.deltaX;
this.canvasPanY -= e.deltaY;
this.updateCanvasTransform();
}
}, { passive: false });
// Context menu

View File

@ -68,6 +68,10 @@ export async function getIntegrations(config: PostizConfig) {
export interface PostizIntegrationRef {
id: string;
identifier: string; // e.g. 'x', 'linkedin', 'bluesky'
/** Per-integration settings overrides merged on top of defaults. */
settings?: Record<string, unknown>;
/** Optional title (YouTube/Reddit/Pinterest) — forwarded into settings. */
title?: string;
}
// Platform-specific settings Postiz validates on POST /posts. Keys that are
@ -96,6 +100,18 @@ function defaultSettingsFor(identifier: string): Record<string, unknown> {
return base;
}
/** Merge caller overrides on top of defaults while preserving `__type`. */
function mergeSettings(
identifier: string,
overrides?: Record<string, unknown>,
title?: string,
): Record<string, unknown> {
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: {
@ -115,7 +131,7 @@ export async function createPost(
integration: { id: integ.id },
value: [{ content: payload.content, image: [] as unknown[] }],
...(payload.group ? { group: payload.group } : {}),
settings: defaultSettingsFor(integ.identifier),
settings: mergeSettings(integ.identifier, integ.settings, integ.title),
})),
};
const res = await postizFetch(config, "/public/v1/posts", {
@ -157,6 +173,26 @@ export async function listPosts(
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
@ -179,7 +215,7 @@ export async function createThread(
posts: opts.integrations.map(integ => ({
integration: { id: integ.id },
value: tweets.map(t => ({ content: t, image: [] as unknown[] })),
settings: defaultSettingsFor(integ.identifier),
settings: mergeSettings(integ.identifier, integ.settings, integ.title),
})),
};
const res = await postizFetch(config, "/public/v1/posts", {

View File

@ -31,7 +31,8 @@ import {
} from "./lib/image-gen";
import { DEMO_FEED } from "./lib/types";
import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy";
import { getPostizConfig, getIntegrations, createPost, createThread, listPosts, type PostizIntegrationRef } from "./lib/postiz-client";
import { getPostizConfig, getIntegrations, createPost, createThread, listPosts, deletePost, type PostizIntegrationRef } from "./lib/postiz-client";
import { createHash } from "node:crypto";
import { verifyToken, extractToken } from "../../server/auth";
import type { EncryptIDClaims } from "../../server/auth";
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
@ -831,6 +832,19 @@ function resolveIntegration(integrations: any[], platform: string): PostizIntegr
return toRef(loose) || toRef(integrations[0]);
}
/** Stable fingerprint of the locally-editable fields that Postiz mirrors.
* Used to detect drift between the node and what was last sent to Postiz. */
function postNodeContentHash(d: PostNodeData): string {
const normalized = {
content: (d.content || '').trim(),
title: d.title || '',
scheduledAt: d.scheduledAt || '',
hashtags: [...(d.hashtags || [])].sort(),
platformSettings: d.platformSettings || {},
};
return createHash("sha1").update(JSON.stringify(normalized)).digest("hex").slice(0, 16);
}
// Push a single campaign-flow Post node to Postiz. Writes postizPostId + status
// back onto the node via Automerge so every client sees the update.
async function sendCampaignNodeToPostiz(
@ -867,6 +881,9 @@ async function sendCampaignNodeToPostiz(
}
ref = ref || resolveIntegration(fetched, data.platform);
if (!ref) return { ok: false, error: `No integration matches platform "${data.platform}"`, code: 400 };
// Forward per-node platform settings + title into Postiz's `settings.__type`.
ref.settings = data.platformSettings ? { ...data.platformSettings } : undefined;
if (data.title !== undefined) ref.title = data.title;
integrations = [ref];
} catch (err: any) {
return { ok: false, error: `Postiz integrations fetch failed: ${err.message}`, code: 502 };
@ -907,6 +924,7 @@ async function sendCampaignNodeToPostiz(
} catch { /* leave id empty, reconcile will treat as lost */ }
}
const syncedHash = postNodeContentHash(data);
_syncServer!.changeDoc<SocialsDoc>(docId, `postiz send ${nodeId}`, (d) => {
const f = d.campaignFlows?.[flowId];
if (!f) return;
@ -918,6 +936,7 @@ async function sendCampaignNodeToPostiz(
nd.postizStatus = 'queued';
nd.postizSentAt = Date.now();
nd.postizError = '';
nd.postizSyncedHash = syncedHash;
if (nd.status === 'draft') nd.status = 'scheduled';
f.updatedAt = Date.now();
});
@ -1050,11 +1069,31 @@ async function postizReconcile() {
nd.status = 'published';
nd.publishedAt = remote.publishDate ? Date.parse(remote.publishDate) : now;
if (remote.releaseURL) nd.postizReleaseURL = remote.releaseURL;
// Final content from Postiz wins once published.
if (remote.content && remote.content !== nd.content) nd.content = remote.content;
} else if (remote.state === 'ERROR') {
nd.postizStatus = 'failed';
nd.postizError = 'Postiz reported publish error';
} else if (remote.state === 'QUEUE' || remote.state === 'DRAFT') {
// Inbound: pull Postiz-side edits back onto the node so the planner
// reflects whatever will actually publish. Only applies while our
// local copy is still considered in-sync with what was sent —
// otherwise the user has newer local edits pending a Resync and
// those shouldn't be clobbered by the stale Postiz-side content.
const localSynced = nd.postizSyncedHash;
const localCurrent = postNodeContentHash(nd);
const localInSync = !localSynced || localSynced === localCurrent;
if (localInSync) {
if (remote.content && remote.content !== nd.content) {
nd.content = remote.content;
}
if (remote.publishDate) {
const iso = new Date(remote.publishDate).toISOString();
if (iso !== nd.scheduledAt) nd.scheduledAt = iso;
}
nd.postizSyncedHash = postNodeContentHash(nd);
}
}
// QUEUE / DRAFT: leave as-is.
}
});
}
@ -1084,6 +1123,54 @@ routes.post("/api/campaign/flows/:flowId/nodes/:nodeId/send-postiz", async (c) =
return c.json({ ok: true, postizPostId: result.postizPostId });
});
/**
* Resync a drifted post: if a node has local edits after its last successful
* Postiz send, delete the Postiz-side post and re-create with current content.
* Only valid while postizStatus === 'queued' published posts can't be
* rewritten.
*/
routes.post("/api/campaign/flows/:flowId/nodes/:nodeId/resync-postiz", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = c.get("effectiveSpace") || space;
const flowId = c.req.param("flowId");
const nodeId = c.req.param("nodeId");
const config = await getPostizConfig(dataSpace);
if (!config) return c.json({ error: "Postiz not configured" }, 404);
const docId = socialsDocId(dataSpace);
const doc = _syncServer!.getDoc<SocialsDoc>(docId);
const flow = doc?.campaignFlows?.[flowId];
const node = flow?.nodes.find(n => n.id === nodeId);
if (!node || node.type !== 'post') return c.json({ error: "Post node not found" }, 404);
const data = node.data as PostNodeData;
if (!data.postizPostId) return c.json({ error: "Post was never sent to Postiz" }, 400);
if (data.postizStatus === 'published') {
return c.json({ error: "Already published — cannot resync" }, 409);
}
// Delete remote copy (404 is OK — just means it was already gone).
const del = await deletePost(config, data.postizPostId);
if (!del.ok) {
return c.json({ error: `Postiz delete failed: ${del.error}` }, del.status as any);
}
// Clear local Postiz fields so sendCampaignNodeToPostiz will re-create.
_syncServer!.changeDoc<SocialsDoc>(docId, `postiz resync-clear ${nodeId}`, (d) => {
const f = d.campaignFlows?.[flowId];
const n = f?.nodes.find(x => x.id === nodeId);
if (!n || n.type !== 'post') return;
const nd = n.data as PostNodeData;
nd.postizPostId = undefined;
nd.postizStatus = undefined;
nd.postizError = '';
});
const result = await sendCampaignNodeToPostiz(dataSpace, flowId, nodeId);
if (!result.ok) return c.json({ error: result.error }, result.code as any);
return c.json({ ok: true, postizPostId: result.postizPostId, resynced: true });
});
routes.post("/api/postiz/threads", async (c) => {
const space = c.req.param("space") || "demo";
const config = await getPostizConfig(space);

View File

@ -80,6 +80,10 @@ export interface PostNodeData {
postizCheckedAt?: number;
postizReleaseURL?: string;
publishedAt?: number;
/** Content fingerprint at last successful Postiz send lets the planner
* detect that the local node has drifted from what Postiz currently has
* (for a "Resync to Postiz" affordance). */
postizSyncedHash?: string;
}
export interface ThreadNodeData {