← back to Dw Marketing Reels

scripts/build-ig-accounts.js

107 lines

#!/usr/bin/env node
/*
 * build-ig-accounts.js — regenerate data/ig-accounts.json, the "managed" account
 * layer the Reels console reads (server.js → readAccountsUnion).
 *
 * WHY THIS EXISTS (2026-08-25): the console showed ~35 owned Instagram accounts as
 * "(needs creds)" even though they are ALL postable RIGHT NOW. Norma's registry
 * (accounts.json) reports token_source = META_ACCESS_TOKEN (shared, never-expiring),
 * accounts_postable = 35 — one durable page token authorizes every one of those 35
 * IG accounts. There are no per-account credentials to acquire. The only reason they
 * rendered "(needs creds)" is that readAccountsUnion() forces every canonical-only
 * account to status:'pending-creds' unless it has an explicit managed entry here, and
 * this file previously listed only the main `dw` account as ready.
 *
 * This generator promotes every postable canonical account (data/dw-canonical-accounts.json,
 * mirrored from Norma by build-dw-accounts.js) to status:'ready' with its ig_user_id, so
 * publish-ig.mjs can post to any of them on the shared token. Accounts that have NO real
 * Instagram account yet (aspirational brands) are preserved as pending-creds via the
 * ASPIRATIONAL manifest below — those genuinely need an IG Business account created +
 * linked to the Meta Business before they can go live.
 *
 * Idempotent. Runs on Mac2 (where Norma lives). The OUT file is committed so it ships
 * to Kamatera.  →  node scripts/build-ig-accounts.js
 */
import { readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CANON = join(ROOT, 'data', 'dw-canonical-accounts.json');
const OUT = join(ROOT, 'data', 'ig-accounts.json');

// Postable-BY-HANDLE accounts whose IG account is NO LONGER reachable on the shared
// durable page token — the account still exists publicly but is not (or no longer)
// linked to the Page/Business this token authorizes, OR the recorded ig_user_id is
// stale. Empirically confirmed 2026-08-25 (TK-10447): a read-only Graph GET of the
// stored ig_user_id returns "Object does not exist / missing permissions" while the
// other 34 resolve on the SAME token. Demoted to status:'needs-relink' so the console
// does not advertise it as postable and publish-ig.mjs won't silently fail on it.
// Fix = re-link the IG account to the Page in Meta Business Manager (or capture the
// correct linked ig_user_id) — a gated Meta identity action. Keyed by handle.
const NEEDS_RELINK = {
  grassclothwallpaper: 'Graph GET of stored ig_user_id 17841408868631110 fails on the shared token (unlinked from Page/Business or stale ID); @grassclothwallpaper still exists publicly (HTTP 200). Re-link in Meta Business Manager to restore.',
};

// Brands with NO postable Instagram account yet (handle NOT among the 35 postable).
// These stay pending-creds — the gated work is: create an IG Business account + link
// it to the Meta Business so the shared token covers it. Keyed by handle (or a synthetic
// id when there is no handle at all).
// NOTE: the @philipperomano aspirational placeholder was dropped (Steve, 2026-09-02) —
// this brand's active postable account is @philliperomanodesigns, already listed as ready,
// so the placeholder was a permanent-red duplicate. Revert this deletion to restore it.
const ASPIRATIONAL = [
  { id: 'apartment-wallpaper', label: 'Apartment Wallpaper', handle: null, business: 'apartmentwallpaper.com',
    note: 'Peel-and-stick line. No Instagram account yet — create + link to the Meta Business to activate.' },
  { id: 'novasuede', label: 'Novasuede', handle: null, business: 'novasuede.com',
    note: 'No Instagram account yet — create + link to the Meta Business to activate.' },
  { id: 'architectural-wallcoverings', label: 'Architectural Wallcoverings', handle: null, business: 'architecturalwallcoverings.com',
    note: 'Contract/commercial line. No Instagram account yet — create + link to the Meta Business to activate.' },
  { id: 'malibu-wallpaper', label: 'Malibu Wallpaper', handle: null, business: 'malibu wallpaper line',
    note: 'Private-label line (never name the upstream vendor publicly). No Instagram account yet — create + link to the Meta Business to activate.' },
];

if (!existsSync(CANON)) {
  console.error('✗ canonical source missing:', CANON, '— run scripts/build-dw-accounts.js first');
  process.exit(1);
}
const canon = JSON.parse(readFileSync(CANON, 'utf8'));
const postable = (canon.accounts || []).filter(a => a && a.handle);

// The main DW account keeps its historical id `dw` (other tooling references it).
const idFor = handle => handle === 'designerwallcoverings'
  ? 'dw'
  : 'ig-' + String(handle).toLowerCase().replace(/[^a-z0-9-]+/g, '-');

const ready = postable.map(a => {
  const relink = NEEDS_RELINK[String(a.handle).toLowerCase()];
  return {
    id: idFor(a.handle),
    label: a.name || a.handle,
    handle: a.handle,
    business: `${a.handle} · Instagram (Meta Graph)`,
    ig_user_id: a.ig_user_id || null,
    agentUrl: null,                       // published via scripts/publish-ig.mjs, not the HTTP agent
    token_source: 'shared-meta-durable',  // one never-expiring META page token covers the postable set
    status: relink ? 'needs-relink' : (a.ig_user_id ? 'ready' : 'pending-creds'),
    source: 'canonical-postable',
    note: relink
      ? relink
      : 'Postable now on the shared durable META token via scripts/publish-ig.mjs — no per-account creds needed.',
  };
});

// Keep aspirational placeholders that don't collide (by handle) with a postable account.
const postableHandles = new Set(postable.map(a => a.handle.toLowerCase()));
const pending = ASPIRATIONAL
  .filter(a => !(a.handle && postableHandles.has(a.handle.toLowerCase())))
  .map(a => ({ ...a, agentUrl: null, status: 'pending-creds', source: 'aspirational' }));

const out = [...ready, ...pending];
const tmp = OUT + '.tmp';
writeFileSync(tmp, JSON.stringify(out, null, 2) + '\n');
renameSync(tmp, OUT);
const readyCount = out.filter(a => a.status === 'ready').length;
const relinkCount = out.filter(a => a.status === 'needs-relink').length;
console.log(`wrote data/ig-accounts.json — ${readyCount} postable (ready) + ${relinkCount} needs-relink + ${pending.length} aspirational (pending-creds)`);