diff --git a/modules/rsocials/components/campaign-planner.css b/modules/rsocials/components/campaign-planner.css index cf6e8a14..75245b07 100644 --- a/modules/rsocials/components/campaign-planner.css +++ b/modules/rsocials/components/campaign-planner.css @@ -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; } diff --git a/modules/rsocials/components/folk-campaign-planner.ts b/modules/rsocials/components/folk-campaign-planner.ts index ef6bc0e3..1e4bf0bd 100644 --- a/modules/rsocials/components/folk-campaign-planner.ts +++ b/modules/rsocials/components/folk-campaign-planner.ts @@ -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 { + 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 ? `
${d.postizStatus === 'published' @@ -861,6 +881,12 @@ class FolkCampaignPlanner extends HTMLElement { : `\u23f3 Queued in Postiz (${esc(d.postizPostId || '')})`}
` : ''; + const resyncRow = isQueued + ? `` + : ''; const spec = getPlatformSpec(d.platform); const charCount = (d.content || '').length; const charMax = spec?.charLimit ?? 2200; @@ -935,6 +961,7 @@ class FolkCampaignPlanner extends HTMLElement { ${extraFields} ${postizBadge} + ${resyncRow} @@ -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 diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts index 18b6b22f..d87e72ec 100644 --- a/modules/rsocials/lib/postiz-client.ts +++ b/modules/rsocials/lib/postiz-client.ts @@ -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; + /** 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 { 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: { @@ -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", { diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 195f955b..e3bb3767 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -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(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(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(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); diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index 5961512d..a877a787 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -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 {