[object Object]

← back to Marketing Command Center

engine: add LinkedIn as 7th channel — image-upload adapter (initializeUpload→PUT→post), status entry, trade-voice captions, channel backfill for already-generated days

3779b0f869a6d143a9ffeab17f5567f391da89a1 · 2026-07-21 15:45:01 -0700 · Steve

Files touched

Diff

commit 3779b0f869a6d143a9ffeab17f5567f391da89a1
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 21 15:45:01 2026 -0700

    engine: add LinkedIn as 7th channel — image-upload adapter (initializeUpload→PUT→post), status entry, trade-voice captions, channel backfill for already-generated days
---
 data/engine-queue.json      | 112 ++++++++++++++++++++++++++++++++++++++++++++
 modules/channels/index.js   |  48 ++++++++++++++++++-
 modules/engine/config.js    |   2 +
 modules/engine/generate.js  |  25 ++++++++--
 modules/engine/llm.js       |  12 ++++-
 modules/engine/scheduler.js |   2 +-
 public/panels/engine.js     |   1 +
 7 files changed, 196 insertions(+), 6 deletions(-)

diff --git a/data/engine-queue.json b/data/engine-queue.json
index 24b2a83..9f19441 100644
--- a/data/engine-queue.json
+++ b/data/engine-queue.json
@@ -656,5 +656,117 @@
     "outboxId": null,
     "postedAt": null,
     "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866952319027-linkedin",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T22:44:24.725Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866952319027",
+      "dwSku": "",
+      "handle": "onda-pearl-and-silver-dwtt-73419",
+      "title": "Onda, Pearl and Silver Wallcoverings | Thibaut",
+      "vendor": "Thibaut",
+      "productType": "Wallcovering",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+      "productUrl": "https://designerwallcoverings.com/products/onda-pearl-and-silver-dwtt-73419"
+    },
+    "channel": "linkedin",
+    "caption": "Thibaut’s Onda, Pearl and Silver Wallcovering exemplifies the marriage of texture and light, offering a refined touch to any interior space.",
+    "hashtags": [
+      "#InteriorDesign",
+      "#LuxuryDesign",
+      "#Wallcovering"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT7900_ee1a8674-8c14-4d27-85ee-af82dcbed06c.jpg?v=1782268872",
+    "videoUrl": null,
+    "suggestedSlot": "09:30",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866951958579-linkedin",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T22:44:34.684Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866951958579",
+      "dwSku": "",
+      "handle": "bahama-cloth-ii-medium-celadon-dwyp-1000002",
+      "title": "Bahama Cloth II, Medium Celadon | Plains",
+      "vendor": "Plains",
+      "productType": "Fabric",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+      "productUrl": "https://designerwallcoverings.com/products/bahama-cloth-ii-medium-celadon-dwyp-1000002"
+    },
+    "channel": "linkedin",
+    "caption": "Bahama Cloth II, Medium Celadon from Plains offers a refined fabric with a quiet presence, ideal for spaces that demand subtlety and sophistication. A testament to the enduring appeal of fabric in design. #PlainsCollection #FabricInDesign #InteriorDesign",
+    "hashtags": [
+      "#PlainsCollection",
+      "#FabricInDesign",
+      "#InteriorDesign"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/medium-celadon-2523_1_145959.jpg?v=1782268842",
+    "videoUrl": null,
+    "suggestedSlot": "09:30",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
+  },
+  {
+    "id": "eng-20260721-gid-shopify-Product-7866357973043-linkedin",
+    "date": "2026-07-21",
+    "createdAt": "2026-07-21T22:44:43.516Z",
+    "product": {
+      "shopifyId": "gid://shopify/Product/7866357973043",
+      "dwSku": "",
+      "handle": "croci-pu2909-2-wallcovering-tres-tintas",
+      "title": "Croci PU2909-2 Wallcovering | Tres Tintas",
+      "vendor": "Tres Tintas",
+      "productType": "Wallcoverings",
+      "imageUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+      "productUrl": "https://designerwallcoverings.com/products/croci-pu2909-2-wallcovering-tres-tintas"
+    },
+    "channel": "linkedin",
+    "caption": "The Croci PU2909-2 Wallcovering from Tres Tintas offers a sophisticated touch with its refined texture and subtle sheen, ideal for creating depth and visual interest in high-end interiors.",
+    "hashtags": [
+      "#InteriorDesign"
+    ],
+    "mediaUrl": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/croci-pu2909-2-repeated-patterns-coordonne.jpg?v=1782183546",
+    "videoUrl": null,
+    "suggestedSlot": "09:30",
+    "scheduledAt": null,
+    "privacy": null,
+    "status": "suggested",
+    "gateReason": null,
+    "source": {
+      "llm": "qwen3:14b@mac2",
+      "cost": 0
+    },
+    "attempts": 0,
+    "lastError": null,
+    "outboxId": null,
+    "postedAt": null,
+    "detail": null
   }
 ]
\ No newline at end of file
diff --git a/modules/channels/index.js b/modules/channels/index.js
index a139182..581597b 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -293,6 +293,13 @@ function platformStatus() {
       scopes: ['threads_basic', 'threads_content_publish'],
       caveat: 'threads_content_publish needs Meta App Review before live posting; until then posts stage.',
     },
+    linkedin: {
+      label: 'LinkedIn', icon: '💼',
+      connected: !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'))),
+      accounts: env('LINKEDIN_ORG_URN') ? [env('LINKEDIN_ORG_URN')] : (env('LINKEDIN_AUTHOR_URN') ? [env('LINKEDIN_AUTHOR_URN')] : []),
+      needs: 'LinkedIn access token (w_organization_social) + org/author URN — same keys the linkedin module reads.',
+      scopes: ['w_organization_social'],
+    },
   };
   // Merge in OAuth-flow state: a platform is `connected` if env creds OR a minted
   // token exists; `configured` = app client id/secret present so a Connect button works.
@@ -603,7 +610,46 @@ async function postThreads(content) {
   const pj = await pub.json().catch(() => ({}));
   return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'Threads publish failed') }];
 }
-const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube, bluesky: postBluesky, threads: postThreads };
+
+async function postLinkedIn(content) {
+  // LinkedIn Community Management API. DW posts always carry media, so this is the
+  // full image flow: initializeUpload → PUT the bytes → create the post with the
+  // image URN. Text+link ride in `commentary`. Version pinned to match liPost.
+  const token = env('LINKEDIN_ACCESS_TOKEN');
+  const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN');
+  if (!token || !author) return [{ ok: false, error: 'LinkedIn not configured (token + org/author URN)' }];
+  const H = { Authorization: `Bearer ${token}`, 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' };
+  let mediaId = null;
+  const src = content.mediaUrl || content.videoUrl;
+  if (src) {
+    const init = await fetch('https://api.linkedin.com/rest/images?action=initializeUpload', {
+      method: 'POST', headers: { ...H, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ initializeUploadRequest: { owner: author } }),
+    });
+    const ij = await init.json().catch(() => ({}));
+    const up = ij.value && ij.value.uploadUrl; mediaId = ij.value && ij.value.image;
+    if (!init.ok || !up) return [{ ok: false, error: `image init ${init.status}: ${JSON.stringify(ij).slice(0, 150)}` }];
+    const img = await fetch(src);
+    if (!img.ok) return [{ ok: false, error: `couldn't fetch media (${img.status})` }];
+    const buf = Buffer.from(await img.arrayBuffer());
+    const put = await fetch(up, { method: 'PUT', headers: { Authorization: `Bearer ${token}` }, body: buf });
+    if (!put.ok) return [{ ok: false, error: `image upload ${put.status}` }];
+  }
+  const body = {
+    author, commentary: content.caption || '', visibility: 'PUBLIC',
+    distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
+    lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
+  };
+  if (mediaId) body.content = { media: { id: mediaId } };
+  const r = await fetch('https://api.linkedin.com/rest/posts', {
+    method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+  });
+  const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
+  let j = null; try { j = await r.json(); } catch { /* create returns empty body */ }
+  return [{ ok: r.ok, id, error: r.ok ? null : ((j && j.message) || `HTTP ${r.status}`) }];
+}
+
+const ADAPTERS = { facebook: postFacebook, instagram: postInstagram, tiktok: postTikTok, youtube: postYouTube, bluesky: postBluesky, threads: postThreads, linkedin: postLinkedIn };
 
 module.exports = {
   id: 'channels',
diff --git a/modules/engine/config.js b/modules/engine/config.js
index 1d05928..3f8f101 100644
--- a/modules/engine/config.js
+++ b/modules/engine/config.js
@@ -20,6 +20,7 @@ const DEFAULTS = {
     youtube: ['12:00'],
     tiktok: ['18:00'],
     threads: ['12:30'],
+    linkedin: ['09:30'],
   },
   productsPerDay: 3,   // how many products the generator picks per day
   lookbackDays: 14,    // how far back to pull candidate products
@@ -34,6 +35,7 @@ const CHANNEL_LIMITS = {
   facebook: 2200,
   tiktok: 150,
   youtube: 95,
+  linkedin: 2900,
 };
 
 // Channels held behind platform App Review — the generator marks these items
diff --git a/modules/engine/generate.js b/modules/engine/generate.js
index a3dfb28..d69d6cd 100644
--- a/modules/engine/generate.js
+++ b/modules/engine/generate.js
@@ -7,7 +7,7 @@ const config = require('./config');
 const { pickCandidates } = require('./candidates');
 const { captionsFor } = require('./llm');
 
-const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads'];
+const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads', 'linkedin'];
 
 // local YYYY-MM-DD — must match modules/engine/index.js today() (local, not UTC).
 function today() {
@@ -83,9 +83,28 @@ function buildItem(product, channel, caps, dateStr) {
 async function generateToday() {
   const dateStr = today();
 
-  // idempotent: today already generated → return as-is
+  // idempotent: today already generated → return as-is. But if a NEW channel was
+  // added since (e.g. linkedin), BACKFILL just the missing channels for today's
+  // already-picked products — existing items (and their statuses) are untouched.
   const existing = store.load().filter(it => it.date === dateStr);
-  if (existing.length) return existing;
+  if (existing.length) {
+    const missing = CHANNELS.filter(ch => !existing.some(it => it.channel === ch));
+    if (!missing.length) return existing;
+    const seen = new Set(); const prods = [];
+    for (const it of existing) {
+      const key = it.product && (it.product.dwSku || it.product.shopifyId);
+      if (key && !seen.has(key)) { seen.add(key); prods.push({ tags: [], ...it.product }); }
+    }
+    for (const product of prods) {
+      let caps;
+      try { caps = await captionsFor(product); } catch (e) { console.error('[engine backfill] captionsFor failed —', e.message); continue; }
+      for (const channel of missing) {
+        const item = buildItem(product, channel, caps, dateStr);
+        if (!store.get(item.id)) store.upsert(item); // never touch existing items
+      }
+    }
+    return store.load().filter(it => it.date === dateStr);
+  }
 
   let products = [];
   try {
diff --git a/modules/engine/llm.js b/modules/engine/llm.js
index e0202e2..9bbd10b 100644
--- a/modules/engine/llm.js
+++ b/modules/engine/llm.js
@@ -11,7 +11,7 @@ const FALLBACK = process.env.OLLAMA_FALLBACK || 'http://192.168.1.133:11434';
 const MODEL = process.env.ENGINE_MODEL || 'qwen3:14b';
 const TIMEOUT_MS = 45000;
 
-const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads'];
+const CHANNELS = ['instagram', 'facebook', 'bluesky', 'youtube', 'tiktok', 'threads', 'linkedin'];
 
 // ── DW brand-voice system prompt ─────────────────────────────────────────────
 const SYSTEM = `You are the social copywriter for Designer Wallcoverings, a luxury wallcoverings retailer.
@@ -25,6 +25,7 @@ Voice rules (HARD):
 - NEVER mention price, cost, sale, or discount.
 Per-channel voice:
 - instagram: polished and editorial, 5-8 hashtags.
+- linkedin: professional, for the interior-design trade; craft and provenance angle; no emojis; 1-3 hashtags.
 - facebook: warm and conversational, a full sentence or two.
 - bluesky: witty and spare, <= 240 characters, no hashtags needed.
 - youtube: a video TITLE (<= 90 characters) plus a 2-sentence description.
@@ -45,6 +46,7 @@ ${JSON.stringify(p, null, 2)}
 Output EXACTLY this JSON shape (fill every field, obey the per-channel voice + limits):
 {
   "instagram": { "caption": "...", "hashtags": ["...", "..."] },
+  "linkedin": { "caption": "...", "hashtags": ["..."] },
   "facebook":  { "caption": "..." },
   "bluesky":   { "caption": "..." },
   "youtube":   { "title": "...", "description": "..." },
@@ -145,6 +147,10 @@ function templateCaptions(product) {
     threads: {
       caption: `New in: ${title}${by}. A ${type} that reads differently as the light moves — texture, depth, hand. What room would you put it in?`,
     },
+    linkedin: {
+      caption: `Now representing ${title}${by} — a ${type} for design professionals specifying with intent. Trade inquiries and sampling available through Designer Wallcoverings.`,
+      hashtags: tags.slice(0, 3),
+    },
   };
 }
 
@@ -204,6 +210,10 @@ function finalize(modelOut, product) {
     threads: {
       caption: tidy(pick('threads', 'caption'), L.threads),
     },
+    linkedin: {
+      caption: tidy(pick('linkedin', 'caption'), L.linkedin),
+      hashtags: tidyHashtags((m.linkedin && m.linkedin.hashtags) || tmpl.linkedin.hashtags).slice(0, 3),
+    },
   };
 }
 
diff --git a/modules/engine/scheduler.js b/modules/engine/scheduler.js
index fa3c45d..5c7e0d1 100644
--- a/modules/engine/scheduler.js
+++ b/modules/engine/scheduler.js
@@ -63,7 +63,7 @@ function _sweep() {
 // link-friendly channels carry it in the caption (Bluesky auto-facets it into a
 // tappable link); YouTube carries it in the description. IG/TikTok captions don't
 // render clickable links, so it's omitted there rather than shipped dead.
-const LINK_CHANNELS = new Set(['bluesky', 'facebook', 'threads']);
+const LINK_CHANNELS = new Set(['bluesky', 'facebook', 'threads', 'linkedin']);
 function buildContent(item) {
   const tags = Array.isArray(item.hashtags) ? item.hashtags.filter(Boolean) : [];
   const url = item.product && item.product.productUrl;
diff --git a/public/panels/engine.js b/public/panels/engine.js
index 31d8195..f0ed1b3 100644
--- a/public/panels/engine.js
+++ b/public/panels/engine.js
@@ -17,6 +17,7 @@ window.MCC_PANELS['engine'] = {
       instagram: { icon: '📷', label: 'Instagram' },
       tiktok:    { icon: '🎵', label: 'TikTok' },
       youtube:   { icon: '▶️', label: 'YouTube' },
+      linkedin:  { icon: '💼', label: 'LinkedIn' },
       bluesky:   { icon: '🦋', label: 'Bluesky' },
       threads:   { icon: '🧵', label: 'Threads' },
     };

← 71a303c 5x: Engine panel verified clean on live marketing.dw (0 app  ·  back to Marketing Command Center  ·  chore: v1.6.3 (session close — FB page names fix) 33d5174 →