[object Object]

← back to Marketing Command Center

TikTok: mirror OAuth token into env on callback (fixes Bearer undefined on post) + demo recorder scaffold

63d0cd0f1959cef2d39fc82abb574557a4ac028c · 2026-07-20 16:31:19 -0700 · Steve

Files touched

Diff

commit 63d0cd0f1959cef2d39fc82abb574557a4ac028c
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 20 16:31:19 2026 -0700

    TikTok: mirror OAuth token into env on callback (fixes Bearer undefined on post) + demo recorder scaffold
---
 demo/record-tiktok-demo.mjs | 90 +++++++++++++++++++++++++++++++++++++++++++++
 modules/channels/index.js   |  5 +++
 2 files changed, 95 insertions(+)

diff --git a/demo/record-tiktok-demo.mjs b/demo/record-tiktok-demo.mjs
new file mode 100644
index 0000000..df6bc8d
--- /dev/null
+++ b/demo/record-tiktok-demo.mjs
@@ -0,0 +1,90 @@
+// TikTok App-Review demo recorder for the Marketing Command Center.
+// Records the end-to-end integration flow (Channels → Connect state → Composer →
+// pick our own video → caption → select TikTok → Publish SELF_ONLY → Outbox) as a
+// video, for the "complete end-to-end flow" upload TikTok App Review requires.
+//
+// Prereq: TikTok must be CONNECTED in the MCC (sandbox keys + one Connect) so the
+// composer's TikTok target is enabled. Run: node demo/record-tiktok-demo.mjs
+// Output: demo/out/<video>.webm  → convert to mp4 (≤50MB) with ffmpeg (see README below).
+import { chromium } from 'playwright';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const ROOT = 'https://marketing.designerwallcoverings.com';
+const envTxt = fs.readFileSync(new URL('../.env', import.meta.url), 'utf8');
+const g = (k) => (envTxt.match(new RegExp('^' + k + '=(.*)$', 'm'))?.[1] || '').replace(/^['"]|['"]$/g, '');
+const USER = g('MCC_USER') || 'admin', PASS = g('MCC_PASS');
+const OUT = path.join(path.dirname(new URL(import.meta.url).pathname), 'out');
+fs.mkdirSync(OUT, { recursive: true });
+
+const pause = (ms) => new Promise(r => setTimeout(r, ms));
+
+const browser = await chromium.launch({ headless: true });
+const ctx = await browser.newContext({
+  httpCredentials: { username: USER, password: PASS },
+  viewport: { width: 1366, height: 900 },
+  recordVideo: { dir: OUT, size: { width: 1366, height: 900 } },
+});
+const page = await ctx.newPage();
+
+async function clickByText(re, timeout = 8000) {
+  const el = page.getByText(re).first();
+  await el.waitFor({ state: 'visible', timeout }).catch(() => {});
+  await el.click({ timeout }).catch(() => {});
+}
+
+try {
+  // 1. Dashboard
+  await page.goto(ROOT, { waitUntil: 'networkidle' });
+  await pause(2500);
+
+  // 2. Channels panel — show TikTok connected
+  await clickByText(/channels/i);
+  await pause(3500);
+  // scroll the TikTok card into view if present
+  await page.getByText(/tiktok/i).first().scrollIntoViewIfNeeded().catch(() => {});
+  await pause(3000);
+
+  // 3. Composer — pick one of our own videos
+  await clickByText(/composer/i);
+  await pause(3000);
+  // click first media thumbnail in the source grid
+  const thumb = page.locator('.cmp-thumb, #cmp-srcgrid img, #ch-media').first();
+  await thumb.click({ timeout: 6000 }).catch(() => {});
+  await pause(2000);
+
+  // 4. Caption
+  const cap = page.locator('#cmp-caption, #ch-caption').first();
+  await cap.click().catch(() => {});
+  await cap.fill('New from Designer Wallcoverings — statement wallcoverings for the modern interior. #designerwallcoverings').catch(() => {});
+  await pause(2500);
+
+  // 5. Select TikTok target (enabled only when connected)
+  const tt = page.locator('input[data-ch="tiktok"]').first();
+  await tt.check({ timeout: 5000 }).catch(() => {});
+  await pause(2000);
+
+  // 6. Publish (SELF_ONLY in sandbox). Leave dry-run OFF so it posts to the sandbox account.
+  const pub = page.locator('#cmp-publish, #ch-publish').first();
+  await pub.scrollIntoViewIfNeeded().catch(() => {});
+  await pause(1000);
+  await pub.click({ timeout: 6000 }).catch(() => {});
+  await pause(6000); // let the post fire + result render
+
+  // 7. Result / outbox
+  await page.getByText(/posted|outbox|staged/i).first().scrollIntoViewIfNeeded().catch(() => {});
+  await pause(4000);
+} catch (e) {
+  console.error('recorder error:', e.message);
+} finally {
+  await ctx.close(); // finalizes the video file
+  await browser.close();
+  const vid = fs.readdirSync(OUT).filter(f => f.endsWith('.webm')).map(f => path.join(OUT, f)).sort().pop();
+  console.log('VIDEO:', vid || '(none)');
+}
+/*
+Convert + trim to an mp4 the App-Review upload accepts (mp4/mov, ≤50MB) and drop in ~/Downloads:
+  ffmpeg -y -i demo/out/<video>.webm -vf "scale=1366:-2,fps=30" -c:v libx264 -pix_fmt yuv420p \
+    -movflags +faststart -crf 24 ~/Downloads/dw-tiktok-integration-demo.mp4
+Optionally add a caption/title card and voiceover before upload.
+*/
diff --git a/modules/channels/index.js b/modules/channels/index.js
index f42041e..00994d4 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -683,6 +683,11 @@ module.exports = {
         all[p] = { ...tok, connectedAt: new Date().toISOString() };
         if (p === 'facebook' || p === 'instagram') { all.facebook = all[p]; all.instagram = all[p]; }
         writeTokens(all);
+        // TikTok: platformStatus() connected-check AND postTikTok() both read
+        // env('TIKTOK_ACCESS_TOKEN'), but the callback above only persisted the token to
+        // channels-tokens.json — so a live post would send `Bearer undefined`. Mirror the
+        // freshly-minted token into the live env so posting sends a real Bearer.
+        if (p === 'tiktok' && tok && tok.access_token) { try { setEnvKeys({ TIKTOK_ACCESS_TOKEN: tok.access_token }); } catch { /* non-fatal */ } }
         // Meta short-lived → long-lived, then re-cache ALL Pages. A short-lived
         // user token (and any Page tokens derived from it) dies in ~1-2h; Page
         // tokens derived from a LONG-LIVED user token never expire. So we upgrade

← 9a142fd auto-save: 2026-07-20T15:02:48 (1 files) — package-lock.json  ·  back to Marketing Command Center  ·  Add TikTok App Review submission package (production fields a9c34db →