← 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 }); }
});
},
};