← back to Marketing Command Center
modules/accounts/index.js
140 lines
// Accounts — a single Amazon-style rail listing EVERY social account with its
// live connection status and, when credentials are missing, the exact "how to
// fix" (which env keys to paste, the app portal, the redirect URI to register,
// the permissions to request, and the one-click Connect URL when OAuth is ready).
//
// This module owns NO credential logic of its own — it is a read-only VIEW that
// aggregates the authoritative sources so the truth lives in exactly one place:
// • social platforms (FB/IG/TikTok/YouTube/Bluesky/Threads) → channels module
// • LinkedIn → its own env keys
// If channels changes how "connected" is decided, this rail follows for free.
const channels = require('../channels');
const env = (k) => (process.env[k] || '').trim();
// Map each social platform → the setup provider card that documents how to fix it
// (portal, permissions, redirect URIs, the env fields to paste). Built from the
// channels module's own SETUP_PROVIDERS so the copy never diverges.
function providerByPlatform() {
const out = {};
for (const p of channels.setupProviders()) {
for (const pl of p.platforms) out[pl] = p;
}
return out;
}
// LinkedIn isn't part of the channels publish engine (separate Phase-2 module),
// so its how-to-fix is described here from the same env keys it reads.
function linkedinAccount() {
const token = env('LINKEDIN_ACCESS_TOKEN');
const orgUrn = env('LINKEDIN_ORG_URN');
const personUrn = env('LINKEDIN_AUTHOR_URN');
const connected = !!(token && (orgUrn || personUrn));
const handle = orgUrn || personUrn || null;
return {
platform: 'linkedin',
label: 'LinkedIn',
icon: '💼',
connected,
configured: connected,
accounts: handle ? [handle] : [],
caveat: connected ? null : 'Phase 2 — live posting stages until a token + author/org URN are set.',
needs: 'A LinkedIn access token plus the author URN (personal profile) or org URN (Company Page).',
scopes: ['w_member_social', 'r_liteprofile'],
connectUrl: null,
howToFix: {
portal: 'https://www.linkedin.com/developers/apps',
portalLabel: 'LinkedIn Developers',
permissions: 'w_member_social · r_liteprofile (or r_organization_social for a Company Page)',
redirectUris: [],
fields: [
{ key: 'LINKEDIN_ACCESS_TOKEN', label: 'Access Token', secret: true, set: !!token },
{ key: 'LINKEDIN_AUTHOR_URN', label: 'Author URN (personal)', secret: false, set: !!personUrn },
{ key: 'LINKEDIN_ORG_URN', label: 'Org URN (Company Page)', secret: false, set: !!orgUrn },
],
},
};
}
// Normalize the channels platformStatus() map + its setup providers into the flat,
// UI-ready account list the rail renders.
async function buildAccounts() {
// Use the SAME health-augmented status /channels/status uses — a dead Meta token
// must flip connected:false here too, not show green on mere env-presence.
const st = channels.augmentMetaHealth(channels.platformStatus(), await channels.metaTokenHealth());
const byPlatform = providerByPlatform();
const redir = channels.redirectUriFor;
const list = Object.entries(st).map(([platform, s]) => {
const prov = byPlatform[platform];
const howToFix = prov ? {
portal: prov.portal,
portalLabel: prov.portalLabel,
permissions: prov.permissions,
// redirect URI(s) to register for THIS platform in its portal (OAuth apps only)
redirectUris: prov.noRedirect ? [] : [{ platform, uri: redir(platform) }],
// the env fields for the provider group, flagged with whether each is already set
fields: (prov.fields || []).map(f => ({ key: f.key, label: f.label, secret: !!f.secret, set: !!env(f.key) })),
} : null;
return {
platform,
label: s.label,
icon: s.icon,
connected: !!s.connected,
configured: !!s.configured,
accounts: s.accounts || [],
// a definitively-dead Meta token surfaces its reconnect reason as the caveat
caveat: s.tokenError || s.caveat || null,
needsReconnect: !!s.needsReconnect,
needs: s.needs || null,
scopes: s.scopes || [],
connectUrl: s.connectUrl || null,
howToFix,
};
});
list.push(linkedinAccount());
// Stable, sensible order: the big four lead, then the rest.
const ORDER = ['facebook', 'instagram', 'tiktok', 'youtube', 'linkedin', 'bluesky', 'threads'];
list.sort((a, b) => {
const ia = ORDER.indexOf(a.platform), ib = ORDER.indexOf(b.platform);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
return list;
}
// A per-account "bucket" so the left-rail status facets can count + filter:
// connected — creds present, posts go live
// needs-creds — no creds at all → must paste keys / connect
// staged-only — creds present BUT a caveat means posts still stage (audit/pipeline gap)
function bucketOf(a) {
if (!a.connected) return 'needs-creds';
if (a.caveat) return 'staged-only';
return 'connected';
}
module.exports = {
id: 'accounts',
title: 'Accounts',
icon: '🔑',
mount(router) {
router.get('/list', async (_req, res) => {
try {
const accounts = (await buildAccounts()).map(a => ({ ...a, bucket: bucketOf(a) }));
const counts = accounts.reduce((m, a) => (m[a.bucket] = (m[a.bucket] || 0) + 1, m), {});
res.json({
accounts,
total: accounts.length,
counts: {
all: accounts.length,
connected: counts['connected'] || 0,
'staged-only': counts['staged-only'] || 0,
'needs-creds': counts['needs-creds'] || 0,
},
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
},
};