← back to Norma

agents/instagram-agent/account-notes.js

84 lines

/**
 * account-notes.js — per-account "what to post next from DW Shopify" planning notes for
 * norma-instagram (:9810).
 *
 * Lets Steve attach a free-text brief to EACH of the 35 Instagram accounts telling that account
 * what to post next from the DW Shopify store (product handles, collections, themes, timing…).
 * The notes are a human-authored PLAN, read by whoever composes the next posts.
 *
 * Storage: a SINGLE data/account-notes.json keyed by ig_user_id — deliberately NOT stored inside
 * the per-account live-media caches, because those get overwritten wholesale by every refresh
 * sweep (which would clobber the notes). Keeping the plan in its own file makes it survive refreshes.
 *
 * Security: only accounts present in the roster (accounts.json + env default) can be written — an
 * arbitrary ig_user_id is rejected, matching live-media.js.
 *
 * Routes:
 *   GET  /notes                                  → planning board (public/notes-viewer.html)
 *   GET  /api/live/account-notes[?account=<id>]  → all notes (or one)
 *   POST /api/live/account-notes {account, note} → save/overwrite one account's note
 */
const fs = require('fs');
const path = require('path');
const { listAccounts } = require('./live-media');

const DATA = path.join(__dirname, 'data');
const NOTES_FILE = path.join(DATA, 'account-notes.json');
const VIEWER = path.join(__dirname, 'public', 'notes-viewer.html');
const MAX_NOTE_LENGTH = 4000; // per-account note character cap

function readNotes() {
  try { return JSON.parse(fs.readFileSync(NOTES_FILE, 'utf8')); } catch { return {}; }
}
function writeNotes(obj) {
  try { fs.mkdirSync(DATA, { recursive: true }); } catch { /* ignore */ }
  fs.writeFileSync(NOTES_FILE, JSON.stringify(obj, null, 2));
}

function rosterIds() {
  const s = new Set();
  for (const a of listAccounts()) s.add(a.ig_user_id);
  return s;
}

function registerNotesRoutes(app) {
  app.get('/notes', (req, res) => {
    if (!fs.existsSync(VIEWER)) return res.status(404).send('notes-viewer.html missing');
    res.type('html').send(fs.readFileSync(VIEWER, 'utf8'));
  });

  // Read: all notes, or one account's, joined onto the roster so the board can render every account.
  app.get('/api/live/account-notes', (req, res) => {
    try {
      const notes = readNotes();
      if (req.query.account) {
        const id = String(req.query.account);
        return res.json({ account: id, ...(notes[id] || { note: '', updated_at: null }) });
      }
      const accounts = listAccounts().map((a) => ({
        handle: a.handle, ig_user_id: a.ig_user_id, page_name: a.page_name || '',
        note: notes[a.ig_user_id]?.note || '',
        updated_at: notes[a.ig_user_id]?.updated_at || null,
      }));
      res.json({ accounts });
    } catch (e) { res.status(500).json({ error: e.message }); }
  });

  // Save: overwrite one account's note. Reversible — it's a single keyed value; clearing = empty note.
  app.post('/api/live/account-notes', (req, res) => {
    try {
      const b = req.body || {};
      const id = String(b.account || '');
      if (!rosterIds().has(id)) return res.status(400).json({ error: 'unknown account (not in roster)' });
      const note = typeof b.note === 'string' ? b.note.slice(0, MAX_NOTE_LENGTH) : '';
      const notes = readNotes();
      if (note.trim() === '') delete notes[id];              // empty = clear the note
      else notes[id] = { note, updated_at: new Date().toISOString() };
      writeNotes(notes);
      res.json({ ok: true, account: id, note, updated_at: notes[id] ? notes[id].updated_at : null });
    } catch (e) { res.status(500).json({ error: e.message }); }
  });
}

module.exports = { registerNotesRoutes, readNotes };