← back to Marketing Command Center

modules/linkedin/index.js

95 lines

'use strict';
// linkedin — LinkedIn composer + drafts + gated posting for the Marketing
// Command Center. DRAFT-FIRST (DTD verdict A 2026-06-13): compose with DW B2B
// best-practice templates, save drafts, copy/deep-link to post manually TODAY.
// The official LinkedIn API adapter (Community Management API — w_member_social
// for a personal profile, w_organization_social for the DW Company Page) is
// Phase 2: POSTING never auto-fires — it requires a connected token + confirm +
// approve, exactly like the social/channels gates.
//
// LinkedIn's User Agreement PROHIBITS third-party/browser automation, so there
// is intentionally NO scraping/headless posting here — the official API is the
// only programmatic path, and it stays human-approved.
const fs = require('fs');
const path = require('path');

const STORE = path.join(__dirname, '..', '..', 'data', 'linkedin-drafts.json');
const readDrafts = () => { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; } };
const writeDrafts = a => { fs.mkdirSync(path.dirname(STORE), { recursive: true }); fs.writeFileSync(STORE, JSON.stringify(a, null, 2)); };
const env = k => (process.env[k] || '').trim();
// DW standing rule — "Wallpaper" is banned; "Wallcovering(s)" only.
const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
let _seq = 0; const newId = () => 'li' + Date.now().toString(36) + ((_seq = (_seq + 1) % 1000).toString(36));

const TEMPLATES = [
  { id: 'reveal', name: 'Project reveal', body: 'Before → after: {room} transformed with {product}.\n\nThe brief: {goal}. The move: {why}.\n\nWhat would you have specified?' },
  { id: 'insight', name: 'Trade insight', body: 'One thing most {audience} get wrong about {topic}:\n\n{insight}\n\nHere’s how we approach it at Designer Wallcoverings.' },
  { id: 'feature', name: 'Product feature', body: 'Material spotlight: {product}.\n\nWhy it works: {benefit1}; {benefit2}; {benefit3}.\n\nSpecifying for a project? Let’s talk.' },
  { id: 'lesson', name: 'Lesson from an install', body: 'A lesson from a recent install:\n\n{lesson}\n\nSmall detail, big difference.' },
  { id: 'testimonial', name: 'Specifier testimonial', body: '“{quote}”\n\nThat’s from {role} on {project}, after specifying {product}.\n\nNothing means more than a designer trusting us on a project. Thank you, {name}.' },
];

// Connected only when a token + an author/org URN are set (Phase 2).
const liConfigured = () => !!(env('LINKEDIN_ACCESS_TOKEN') && (env('LINKEDIN_AUTHOR_URN') || env('LINKEDIN_ORG_URN')));

async function liPost({ text }) {
  const token = env('LINKEDIN_ACCESS_TOKEN');
  const author = env('LINKEDIN_ORG_URN') || env('LINKEDIN_AUTHOR_URN'); // urn:li:organization:… or urn:li:person:…
  const r = await fetch('https://api.linkedin.com/rest/posts', {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'LinkedIn-Version': '202401', 'X-Restli-Protocol-Version': '2.0.0' },
    body: JSON.stringify({
      author, commentary: text, visibility: 'PUBLIC',
      distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
      lifecycleState: 'PUBLISHED', isReshareDisabledByAuthor: false,
    }),
  });
  const id = r.headers.get('x-restli-id') || r.headers.get('x-linkedin-id');
  let body = null; try { body = await r.json(); } catch { /* may be empty */ }
  return { ok: r.ok, status: r.status, id, error: r.ok ? null : ((body && body.message) || `HTTP ${r.status}`) };
}

module.exports = {
  id: 'linkedin',
  title: 'LinkedIn',
  icon: '💼',
  mount(router) {
    router.get('/connection', (_req, res) => res.json({
      configured: liConfigured(),
      surface: env('LINKEDIN_ORG_URN') ? 'Company Page' : (env('LINKEDIN_AUTHOR_URN') ? 'Personal profile' : null),
      mode: liConfigured() ? 'live (gated)' : 'draft-only (manual post)',
    }));
    router.get('/templates', (_req, res) => res.json({ templates: TEMPLATES }));
    router.get('/drafts', (_req, res) => res.json({ drafts: readDrafts().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) }));

    router.post('/draft', (req, res) => {
      const text = deWallpaper(String((req.body && req.body.text) || '').slice(0, 3000));
      const hashtags = Array.isArray(req.body && req.body.hashtags) ? req.body.hashtags.slice(0, 8) : [];
      if (!text.trim()) return res.status(400).json({ error: 'empty post' });
      const d = { id: newId(), text, hashtags, chars: text.length, status: 'draft', created_at: new Date().toISOString() };
      const arr = readDrafts(); arr.push(d); writeDrafts(arr);
      res.json({ ok: true, draft: d });
    });

    router.delete('/draft/:id', (req, res) => {
      const arr = readDrafts(); const i = arr.findIndex(d => d.id === req.params.id);
      if (i === -1) return res.status(404).json({ error: 'not found' });
      const [x] = arr.splice(i, 1); writeDrafts(arr); res.json({ ok: true, id: x.id });
    });

    // Gated publish — NEVER auto-fires. Stages unless connected + confirm + approve.
    router.post('/publish', async (req, res) => {
      const d = req.body || {};
      const text = deWallpaper(String(d.text || '').slice(0, 3000));
      if (!text.trim()) return res.status(400).json({ error: 'empty post' });
      if (d.confirm !== true) return res.status(400).json({ error: 'A live post requires confirm:true.' });
      if (d.approved !== true) return res.status(400).json({ error: 'This post is not approved. Set approved:true to clear it for live posting.' });
      if (!liConfigured()) {
        return res.json({ ok: true, staged: true, posted: false, message: 'Staged — LinkedIn isn’t connected. Add LINKEDIN_ACCESS_TOKEN + author/org URN (Phase 2) to post live. Nothing was sent.' });
      }
      try { const r = await liPost({ text }); return res.json({ ok: r.ok, posted: r.ok, id: r.id, error: r.error }); }
      catch (e) { return res.status(502).json({ error: e.message }); }
    });
  },
};