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