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:
parent
98f554888c
commit
dc87eeb91f
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue