server.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. require('dotenv').config();
  2. const { createLogger } = require('./utils/logger');
  3. const log = createLogger('gateway');
  4. const app = require('fastify')({ logger: log });
  5. const multipart = require('@fastify/multipart');
  6. const axios = require('axios');
  7. const fs = require('fs');
  8. const path = require('path');
  9. const crypto = require('crypto');
  10. const { pipeline } = require('stream/promises');
  11. const { ObjectId } = require('mongodb');
  12. const { getDb } = require('./utils/MongoDBConnector');
  13. const { encryptToken, decryptToken, warnIfNoKey } = require('./utils/crypto');
  14. const RabbitMQProducer = require('./utils/RabbitMQProducer');
  15. const UPLOAD_DIR = process.env.UPLOAD_DIR || '/uploads';
  16. const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov', '.avi']);
  17. const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
  18. fs.mkdirSync(UPLOAD_DIR, { recursive: true });
  19. app.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
  20. const GRAPH_API = 'https://graph.facebook.com/v22.0';
  21. // The public base URL of this app (used for OAuth redirect_uri)
  22. const APP_BASE_URL = process.env.APP_BASE_URL || 'http://localhost:8081';
  23. // ─── CORS ────────────────────────────────────────────────────────────────────
  24. app.addHook('onSend', async (request, reply) => {
  25. reply.header('Access-Control-Allow-Origin', '*');
  26. reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
  27. reply.header('Access-Control-Allow-Headers', 'Content-Type');
  28. });
  29. app.options('*', async (request, reply) => {
  30. reply.code(204).send();
  31. });
  32. // ─── Helpers ─────────────────────────────────────────────────────────────────
  33. async function getCredentials(id) {
  34. const db = await getDb();
  35. return db.collection('platform_credentials').findOne({ _id: id });
  36. }
  37. async function setCredentials(id, data) {
  38. const db = await getDb();
  39. await db.collection('platform_credentials').updateOne(
  40. { _id: id },
  41. { $set: { _id: id, ...data, updatedAt: new Date() } },
  42. { upsert: true }
  43. );
  44. }
  45. async function deleteCredentials(id) {
  46. const db = await getDb();
  47. await db.collection('platform_credentials').deleteOne({ _id: id });
  48. }
  49. // ─── Media Upload & Library ───────────────────────────────────────────────────
  50. app.post('/upload', async (request, reply) => {
  51. const data = await request.file();
  52. if (!data) return reply.code(400).send({ error: 'No file provided' });
  53. const ext = path.extname(data.filename).toLowerCase();
  54. if (!ALLOWED_EXTENSIONS.has(ext)) {
  55. data.file.resume();
  56. return reply.code(400).send({ error: `File type "${ext}" is not allowed. Allowed: jpg, jpeg, png, gif, webp, mp4, mov, avi` });
  57. }
  58. const filename = `${crypto.randomUUID()}${ext}`;
  59. const filepath = path.join(UPLOAD_DIR, filename);
  60. try {
  61. await pipeline(data.file, fs.createWriteStream(filepath));
  62. } catch (err) {
  63. app.log.error({ action: 'media_upload', outcome: 'failure', err: err.message });
  64. return reply.code(500).send({ error: 'Failed to save file' });
  65. }
  66. const stat = fs.statSync(filepath);
  67. const record = {
  68. filename,
  69. originalName: data.filename,
  70. url: `/media/${filename}`,
  71. mimetype: data.mimetype,
  72. size: stat.size,
  73. uploadedAt: new Date(),
  74. };
  75. try {
  76. const db = await getDb();
  77. await db.collection('media_files').insertOne(record);
  78. } catch (err) {
  79. app.log.error({ action: 'media_metadata_save', outcome: 'failure', err: err.message });
  80. }
  81. return { url: record.url, filename, originalName: data.filename, mimetype: data.mimetype, size: stat.size };
  82. });
  83. // List all uploaded media files, newest first
  84. app.get('/media-library', async () => {
  85. const db = await getDb();
  86. const files = await db.collection('media_files').find({}).sort({ uploadedAt: -1 }).toArray();
  87. return { files };
  88. });
  89. // Delete a media file from disk and database
  90. app.delete('/media/:filename', async (request, reply) => {
  91. const { filename } = request.params;
  92. // Prevent path traversal
  93. if (!filename || filename.includes('/') || filename.includes('..') || filename.includes('\0')) {
  94. return reply.code(400).send({ error: 'Invalid filename' });
  95. }
  96. const filepath = path.join(UPLOAD_DIR, filename);
  97. try {
  98. fs.unlinkSync(filepath);
  99. } catch (err) {
  100. if (err.code !== 'ENOENT') {
  101. app.log.error({ action: 'media_delete', outcome: 'failure', err: err.message });
  102. return reply.code(500).send({ error: 'Failed to delete file' });
  103. }
  104. // Already gone from disk — still clean up DB record
  105. }
  106. const db = await getDb();
  107. await db.collection('media_files').deleteOne({ filename });
  108. return { success: true };
  109. });
  110. // ─── Drafts ──────────────────────────────────────────────────────────────────
  111. app.post('/drafts', async (request, reply) => {
  112. const { content = '', mediaUrl = '', scheduledAt = '', destinations = [] } = request.body || {};
  113. const db = await getDb();
  114. const now = new Date();
  115. const result = await db.collection('drafts').insertOne({
  116. content, mediaUrl, scheduledAt, destinations, createdAt: now, updatedAt: now,
  117. });
  118. const draft = await db.collection('drafts').findOne({ _id: result.insertedId });
  119. return reply.code(201).send(draft);
  120. });
  121. app.get('/drafts', async () => {
  122. const db = await getDb();
  123. const drafts = await db.collection('drafts').find({}).sort({ updatedAt: -1 }).toArray();
  124. return { drafts };
  125. });
  126. app.get('/drafts/:id', async (request, reply) => {
  127. const { id } = request.params;
  128. let oid;
  129. try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
  130. const db = await getDb();
  131. const draft = await db.collection('drafts').findOne({ _id: oid });
  132. if (!draft) return reply.code(404).send({ error: 'Draft not found' });
  133. return draft;
  134. });
  135. app.put('/drafts/:id', async (request, reply) => {
  136. const { id } = request.params;
  137. let oid;
  138. try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
  139. const { content = '', mediaUrl = '', scheduledAt = '', destinations = [] } = request.body || {};
  140. const db = await getDb();
  141. const result = await db.collection('drafts').updateOne(
  142. { _id: oid },
  143. { $set: { content, mediaUrl, scheduledAt, destinations, updatedAt: new Date() } }
  144. );
  145. if (!result.matchedCount) return reply.code(404).send({ error: 'Draft not found' });
  146. return { success: true };
  147. });
  148. app.delete('/drafts/:id', async (request, reply) => {
  149. const { id } = request.params;
  150. let oid;
  151. try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
  152. const db = await getDb();
  153. await db.collection('drafts').deleteOne({ _id: oid });
  154. return { success: true };
  155. });
  156. // ─── Meta Token Expiry ───────────────────────────────────────────────────────
  157. let _tokenExpiryCache = null;
  158. let _tokenExpiryCacheAt = 0;
  159. const TOKEN_EXPIRY_TTL = 60 * 60 * 1000; // 1 hour
  160. app.get('/meta/token-expiry', async (request, reply) => {
  161. if (_tokenExpiryCache && Date.now() - _tokenExpiryCacheAt < TOKEN_EXPIRY_TTL) {
  162. return _tokenExpiryCache;
  163. }
  164. const appCred = await getCredentials('meta_app');
  165. if (!appCred?.appId || !appCred?.appSecret) return { accounts: [] };
  166. const plainAppSecret = decryptToken(appCred.appSecret);
  167. if (!plainAppSecret) return { accounts: [] };
  168. const ig = await getCredentials('instagram');
  169. const selectedAccounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
  170. if (!selectedAccounts.length) return { accounts: [] };
  171. const appToken = `${appCred.appId}|${plainAppSecret}`;
  172. const accounts = [];
  173. for (const account of selectedAccounts) {
  174. const plainToken = decryptToken(account.accessToken);
  175. if (!plainToken) continue;
  176. try {
  177. const res = await axios.get(`${GRAPH_API}/debug_token`, {
  178. params: { input_token: plainToken, access_token: appToken },
  179. timeout: 10000,
  180. });
  181. const data = res.data.data;
  182. const expiresAt = data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null;
  183. const daysLeft = expiresAt
  184. ? Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
  185. : null;
  186. accounts.push({ id: account.id, username: account.username, expiresAt, daysLeft, isValid: !!data.is_valid });
  187. } catch (err) {
  188. app.log.warn({ action: 'token_expiry_check', platform: 'instagram', username: account.username, outcome: 'failure', err: err.message });
  189. }
  190. }
  191. _tokenExpiryCache = { accounts, checkedAt: new Date().toISOString() };
  192. _tokenExpiryCacheAt = Date.now();
  193. return _tokenExpiryCache;
  194. });
  195. // ─── Account Profiles ────────────────────────────────────────────────────────
  196. app.get('/profiles', async () => {
  197. const db = await getDb();
  198. const profiles = await db.collection('account_profiles').find({}).toArray();
  199. return { profiles };
  200. });
  201. app.get('/profiles/:accountKey', async (request, reply) => {
  202. const { accountKey } = request.params;
  203. const db = await getDb();
  204. const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
  205. return profile ?? { _id: accountKey };
  206. });
  207. app.put('/profiles/:accountKey', async (request, reply) => {
  208. const { accountKey } = request.params;
  209. const {
  210. businessName = '', description = '', websiteUrl = '', industry = '',
  211. targetAudience = '', toneOfVoice = '', keywords = '', hashtags = '',
  212. postingGuidelines = '',
  213. } = request.body || {};
  214. const db = await getDb();
  215. await db.collection('account_profiles').updateOne(
  216. { _id: accountKey },
  217. { $set: { businessName, description, websiteUrl, industry, targetAudience, toneOfVoice, keywords, hashtags, postingGuidelines, updatedAt: new Date() } },
  218. { upsert: true }
  219. );
  220. return { success: true };
  221. });
  222. // ─── AI / Ollama ──────────────────────────────────────────────────────────────
  223. const DEFAULT_OLLAMA_ENDPOINT = process.env.OLLAMA_ENDPOINT || 'http://ollama:11434';
  224. const DEFAULT_OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
  225. app.get('/ai/config', async () => {
  226. const config = await getCredentials('ai_config');
  227. return {
  228. provider: config?.provider || 'ollama',
  229. endpoint: config?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
  230. model: config?.model || DEFAULT_OLLAMA_MODEL,
  231. visionModel: config?.visionModel || 'llava',
  232. enabled: config?.enabled ?? true,
  233. };
  234. });
  235. app.put('/ai/config', async (request, reply) => {
  236. const { provider = 'ollama', endpoint, model, visionModel = 'llava', enabled = true } = request.body || {};
  237. if (!endpoint) return reply.code(400).send({ error: 'endpoint is required' });
  238. await setCredentials('ai_config', { provider, endpoint, model, visionModel, enabled });
  239. return { success: true };
  240. });
  241. app.get('/ai/models', async (request, reply) => {
  242. const config = await getCredentials('ai_config');
  243. // Allow caller to override endpoint for test-without-save UX
  244. const endpoint = request.query.endpoint || config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  245. try {
  246. const res = await axios.get(`${endpoint}/api/tags`, { timeout: 5000 });
  247. const models = (res.data.models || []).map((m) => m.name);
  248. return { models, endpoint };
  249. } catch (err) {
  250. return reply.code(503).send({ error: 'Could not reach Ollama — check the endpoint', detail: err.message });
  251. }
  252. });
  253. app.post('/ai/generate', async (request, reply) => {
  254. const { prompt, system, model: reqModel } = request.body || {};
  255. if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
  256. const config = await getCredentials('ai_config');
  257. const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  258. const model = reqModel || config?.model || DEFAULT_OLLAMA_MODEL;
  259. try {
  260. const res = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
  261. return { text: res.data.response, model, done: res.data.done };
  262. } catch (err) {
  263. const status = err.response?.status || 503;
  264. return reply.code(status).send({ error: 'AI generation failed', detail: err.message });
  265. }
  266. });
  267. // Vision caption — fetches image, passes base64 to Ollama vision model
  268. app.post('/ai/caption', async (request, reply) => {
  269. const { imageUrl, model: reqModel } = request.body || {};
  270. if (!imageUrl) return reply.code(400).send({ error: 'imageUrl is required' });
  271. const config = await getCredentials('ai_config');
  272. const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  273. const model = reqModel || config?.visionModel || 'llava';
  274. // Fetch image → base64
  275. let imageBase64;
  276. try {
  277. let imageBuffer;
  278. if (imageUrl.startsWith('/media/')) {
  279. const filename = path.basename(imageUrl);
  280. const filepath = path.join(UPLOAD_DIR, filename);
  281. imageBuffer = fs.readFileSync(filepath);
  282. } else {
  283. const imgRes = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 15000 });
  284. imageBuffer = Buffer.from(imgRes.data);
  285. }
  286. imageBase64 = imageBuffer.toString('base64');
  287. } catch (err) {
  288. return reply.code(400).send({ error: 'Could not load image', detail: err.message });
  289. }
  290. try {
  291. const res = await axios.post(`${endpoint}/api/generate`, {
  292. model,
  293. prompt: 'Generate an engaging, concise social media caption for this image. Write only the caption text with relevant hashtags. No explanations or preamble.',
  294. images: [imageBase64],
  295. stream: false,
  296. }, { timeout: 90000 });
  297. return { caption: res.data.response, model };
  298. } catch (err) {
  299. const status = err.response?.status || 503;
  300. return reply.code(status).send({ error: 'Caption generation failed', detail: err.message });
  301. }
  302. });
  303. // SSE streaming endpoint — sends token-by-token as text/event-stream
  304. app.post('/ai/stream', async (request, reply) => {
  305. const { prompt, system, model: reqModel } = request.body || {};
  306. if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
  307. const config = await getCredentials('ai_config');
  308. const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  309. const model = reqModel || config?.model || DEFAULT_OLLAMA_MODEL;
  310. reply.raw.setHeader('Content-Type', 'text/event-stream');
  311. reply.raw.setHeader('Cache-Control', 'no-cache');
  312. reply.raw.setHeader('X-Accel-Buffering', 'no');
  313. reply.raw.setHeader('Connection', 'keep-alive');
  314. reply.raw.flushHeaders();
  315. try {
  316. const ollamaRes = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: true }, { responseType: 'stream', timeout: 120000 });
  317. ollamaRes.data.on('data', (chunk) => {
  318. try {
  319. const lines = chunk.toString().split('\n').filter(Boolean);
  320. for (const line of lines) {
  321. const data = JSON.parse(line);
  322. reply.raw.write(`data: ${JSON.stringify({ token: data.response || '', done: !!data.done })}\n\n`);
  323. }
  324. } catch (_) {}
  325. });
  326. ollamaRes.data.on('end', () => { reply.raw.end(); });
  327. ollamaRes.data.on('error', (err) => {
  328. reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
  329. reply.raw.end();
  330. });
  331. } catch (err) {
  332. reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
  333. reply.raw.end();
  334. }
  335. });
  336. // ─── Platform service URLs ────────────────────────────────────────────────────
  337. const PLATFORM_SERVICES = {
  338. twitter: process.env.TWITTER_SERVICE_URL || 'http://twitter:3001',
  339. linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
  340. mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
  341. bluesky: process.env.BLUESKY_SERVICE_URL || 'http://bluesky:3004',
  342. instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
  343. facebook: process.env.FACEBOOK_SERVICE_URL || 'http://facebook:3006',
  344. };
  345. // Direct multi-platform post endpoint.
  346. // Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
  347. app.post('/post', async (request, reply) => {
  348. const { content, destinations = [] } = request.body || {};
  349. if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
  350. if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
  351. const results = await Promise.allSettled(
  352. destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
  353. const serviceUrl = PLATFORM_SERVICES[platform];
  354. if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
  355. const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
  356. return { platform, accountId, ...res.data };
  357. })
  358. );
  359. const output = results.map((r, i) =>
  360. r.status === 'fulfilled'
  361. ? r.value
  362. : { platform: destinations[i].platform, accountId: destinations[i].accountId, success: false, error: r.reason?.message }
  363. );
  364. const anyFailed = output.some((r) => !r.success);
  365. return reply.code(anyFailed ? 207 : 200).send({ results: output });
  366. });
  367. // ─── Legacy post route ────────────────────────────────────────────────────────
  368. let rabbitMQProducer = new RabbitMQProducer();
  369. app.post('/', async (request, reply) => {
  370. try {
  371. await rabbitMQProducer.sendMessage('formatter', request.body.message);
  372. reply.send({ status: 'ok' });
  373. } catch (error) {
  374. app.log.error({ action: 'legacy_post', outcome: 'failure', err: error.message });
  375. reply.status(500).send({ error: 'Internal Server Error' });
  376. }
  377. });
  378. // ─── Meta App Credentials ────────────────────────────────────────────────────
  379. // Save Facebook App ID + Secret (entered by user in Settings UI)
  380. app.post('/credentials/meta-app', async (request, reply) => {
  381. const { appId, appSecret } = request.body || {};
  382. if (!appId || !appSecret) {
  383. return reply.code(400).send({ error: 'appId and appSecret are required' });
  384. }
  385. await setCredentials('meta_app', { appId, appSecret: encryptToken(appSecret) });
  386. return { success: true };
  387. });
  388. // Get Meta App config (secret is masked for UI display)
  389. app.get('/credentials/meta-app', async () => {
  390. const cred = await getCredentials('meta_app');
  391. if (!cred) return { configured: false };
  392. const plainSecret = decryptToken(cred.appSecret) || '';
  393. return { configured: true, appId: cred.appId, appSecretHint: plainSecret ? `****${plainSecret.slice(-4)}` : '****' };
  394. });
  395. // ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
  396. // Return the Facebook OAuth URL to redirect the user to
  397. app.get('/auth/meta/init', async (request, reply) => {
  398. const cred = await getCredentials('meta_app');
  399. if (!cred?.appId) {
  400. return reply.code(400).send({ error: 'Save your Facebook App ID and Secret first' });
  401. }
  402. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  403. const scopes = [
  404. 'pages_manage_posts',
  405. 'pages_read_engagement',
  406. 'instagram_basic',
  407. 'instagram_content_publish',
  408. 'instagram_manage_insights',
  409. ].join(',');
  410. const url = `https://www.facebook.com/v22.0/dialog/oauth?client_id=${cred.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}&response_type=code`;
  411. return { url };
  412. });
  413. // OAuth callback — Facebook redirects here after user authorises
  414. app.get('/auth/meta/callback', async (request, reply) => {
  415. const { code, error: oauthError } = request.query;
  416. if (oauthError) {
  417. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(oauthError)}`);
  418. }
  419. if (!code) {
  420. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=no_code`);
  421. }
  422. try {
  423. const appCred = await getCredentials('meta_app');
  424. if (!appCred?.appId) throw new Error('App credentials not configured');
  425. const appSecret = decryptToken(appCred.appSecret);
  426. if (!appSecret) throw new Error('Failed to decrypt app secret');
  427. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  428. // Exchange code for short-lived token
  429. const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  430. params: {
  431. client_id: appCred.appId,
  432. client_secret: appSecret,
  433. redirect_uri: redirectUri,
  434. code,
  435. },
  436. });
  437. // Upgrade to long-lived user token (~60 days)
  438. const longRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  439. params: {
  440. grant_type: 'fb_exchange_token',
  441. client_id: appCred.appId,
  442. client_secret: appSecret,
  443. fb_exchange_token: shortRes.data.access_token,
  444. },
  445. });
  446. const userToken = longRes.data.access_token;
  447. // Fetch all managed Facebook Pages
  448. const pagesRes = await axios.get(`${GRAPH_API}/me/accounts`, {
  449. params: { access_token: userToken, fields: 'id,name,access_token,picture' },
  450. });
  451. const pages = [];
  452. const igAccounts = [];
  453. for (const page of pagesRes.data.data || []) {
  454. pages.push({
  455. id: page.id,
  456. name: page.name,
  457. accessToken: encryptToken(page.access_token),
  458. picture: page.picture?.data?.url || null,
  459. selected: false,
  460. });
  461. // Check for linked Instagram Business Account
  462. try {
  463. const igRes = await axios.get(`${GRAPH_API}/${page.id}`, {
  464. params: {
  465. fields: 'instagram_business_account',
  466. access_token: page.access_token,
  467. },
  468. });
  469. if (igRes.data.instagram_business_account?.id) {
  470. const igId = igRes.data.instagram_business_account.id;
  471. // Fetch IG account details
  472. const igProfile = await axios.get(`${GRAPH_API}/${igId}`, {
  473. params: {
  474. fields: 'id,username,name,profile_picture_url',
  475. access_token: userToken,
  476. },
  477. });
  478. igAccounts.push({
  479. id: igId,
  480. username: igProfile.data.username || igProfile.data.name,
  481. name: igProfile.data.name,
  482. avatar: igProfile.data.profile_picture_url || null,
  483. accessToken: encryptToken(userToken),
  484. pageId: page.id,
  485. selected: false,
  486. });
  487. }
  488. } catch (_) {
  489. // Page has no linked Instagram account — skip
  490. }
  491. }
  492. // Store discovery results for the UI to pick from
  493. await setCredentials('meta_discovery', { pages, igAccounts, discoveredAt: new Date() });
  494. reply.redirect(`${APP_BASE_URL}/settings?meta_discovery=1`);
  495. } catch (err) {
  496. app.log.error({ action: 'meta_oauth_callback', platform: 'meta', outcome: 'failure', err: err.response?.data?.error?.message || err.message });
  497. reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(err.message)}`);
  498. }
  499. });
  500. // Return pending discovery results so the UI can render the page picker
  501. app.get('/auth/meta/discovered', async () => {
  502. const discovery = await getCredentials('meta_discovery');
  503. if (!discovery) return { pages: [], igAccounts: [] };
  504. return { pages: discovery.pages || [], igAccounts: discovery.igAccounts || [] };
  505. });
  506. // User has chosen which pages/accounts to connect
  507. app.post('/auth/meta/save', async (request, reply) => {
  508. const { selectedPageIds = [], selectedIgAccountIds = [] } = request.body || {};
  509. const discovery = await getCredentials('meta_discovery');
  510. if (!discovery) return reply.code(400).send({ error: 'No discovery data found — reconnect via OAuth' });
  511. const fbPages = (discovery.pages || []).map((p) => ({
  512. ...p,
  513. selected: selectedPageIds.includes(p.id),
  514. }));
  515. const igAccounts = (discovery.igAccounts || []).map((a) => ({
  516. ...a,
  517. selected: selectedIgAccountIds.includes(a.id),
  518. }));
  519. await setCredentials('facebook', { pages: fbPages });
  520. await setCredentials('instagram', { accounts: igAccounts });
  521. await deleteCredentials('meta_discovery');
  522. _tokenExpiryCache = null; // invalidate cache after reconnect
  523. return { success: true, facebookPages: fbPages.filter((p) => p.selected).length, instagramAccounts: igAccounts.filter((a) => a.selected).length };
  524. });
  525. // Disconnect all Meta platforms
  526. app.delete('/credentials/meta', async () => {
  527. await deleteCredentials('facebook');
  528. await deleteCredentials('instagram');
  529. await deleteCredentials('meta_discovery');
  530. return { success: true };
  531. });
  532. // ─── Credential Status ────────────────────────────────────────────────────────
  533. // Aggregate connection status for all DB-managed platforms
  534. app.get('/credentials', async () => {
  535. const [metaApp, fb, ig] = await Promise.all([
  536. getCredentials('meta_app'),
  537. getCredentials('facebook'),
  538. getCredentials('instagram'),
  539. ]);
  540. const fbPages = (fb?.pages || []).filter((p) => p.selected);
  541. const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
  542. return {
  543. metaApp: { configured: !!(metaApp?.appId) },
  544. facebook: {
  545. connected: fbPages.length > 0,
  546. pages: fbPages.map(({ id, name, picture }) => ({ id, name, picture })),
  547. },
  548. instagram: {
  549. connected: igAccounts.length > 0,
  550. accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
  551. },
  552. };
  553. });
  554. module.exports = app;