server.js 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736
  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 & Auto-Refresh ────────────────────────────────────────
  157. let _tokenExpiryCache = null;
  158. let _tokenExpiryCacheAt = 0;
  159. const TOKEN_EXPIRY_TTL = 60 * 60 * 1000; // 1 hour
  160. const TOKEN_REFRESH_THRESHOLD_DAYS = 7; // refresh when ≤ this many days remain
  161. app.get('/meta/token-expiry', async (request, reply) => {
  162. if (_tokenExpiryCache && Date.now() - _tokenExpiryCacheAt < TOKEN_EXPIRY_TTL) {
  163. return _tokenExpiryCache;
  164. }
  165. const appCred = await getCredentials('meta_app');
  166. if (!appCred?.appId || !appCred?.appSecret) return { accounts: [] };
  167. const plainAppSecret = decryptToken(appCred.appSecret);
  168. if (!plainAppSecret) return { accounts: [] };
  169. const ig = await getCredentials('instagram');
  170. const selectedAccounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
  171. if (!selectedAccounts.length) return { accounts: [] };
  172. const appToken = `${appCred.appId}|${plainAppSecret}`;
  173. const accounts = [];
  174. for (const account of selectedAccounts) {
  175. const plainToken = decryptToken(account.accessToken);
  176. if (!plainToken) continue;
  177. try {
  178. const res = await axios.get(`${GRAPH_API}/debug_token`, {
  179. params: { input_token: plainToken, access_token: appToken },
  180. timeout: 10000,
  181. });
  182. const data = res.data.data;
  183. const expiresAt = data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null;
  184. const daysLeft = expiresAt
  185. ? Math.ceil((new Date(expiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
  186. : null;
  187. accounts.push({ id: account.id, username: account.username, expiresAt, daysLeft, isValid: !!data.is_valid });
  188. } catch (err) {
  189. app.log.warn({ action: 'token_expiry_check', platform: 'instagram', username: account.username, outcome: 'failure', err: err.message });
  190. }
  191. }
  192. _tokenExpiryCache = { accounts, checkedAt: new Date().toISOString() };
  193. _tokenExpiryCacheAt = Date.now();
  194. return _tokenExpiryCache;
  195. });
  196. // Refresh Instagram long-lived tokens that are within TOKEN_REFRESH_THRESHOLD_DAYS of expiry.
  197. // Called by the scheduler's daily BullMQ job; can also be triggered manually from Settings.
  198. app.post('/meta/token-refresh', async (request, reply) => {
  199. const appCred = await getCredentials('meta_app');
  200. if (!appCred?.appId || !appCred?.appSecret) {
  201. return reply.code(400).send({ success: false, error: 'Meta app credentials not configured' });
  202. }
  203. const plainAppSecret = decryptToken(appCred.appSecret);
  204. if (!plainAppSecret) {
  205. return reply.code(500).send({ success: false, error: 'Failed to decrypt app secret' });
  206. }
  207. const ig = await getCredentials('instagram');
  208. const allAccounts = ig?.accounts || [];
  209. const selectedAccounts = allAccounts.filter((a) => a.selected && a.accessToken);
  210. if (!selectedAccounts.length) {
  211. return { success: true, refreshed: 0, skipped: 0, errors: 0 };
  212. }
  213. const appToken = `${appCred.appId}|${plainAppSecret}`;
  214. const refreshed = [];
  215. const skipped = [];
  216. const errors = [];
  217. for (const account of selectedAccounts) {
  218. const plainToken = decryptToken(account.accessToken);
  219. if (!plainToken) {
  220. errors.push({ username: account.username, error: 'decrypt_failed' });
  221. continue;
  222. }
  223. // Check current token expiry via debug_token
  224. let daysLeft = null;
  225. try {
  226. const debugRes = await axios.get(`${GRAPH_API}/debug_token`, {
  227. params: { input_token: plainToken, access_token: appToken },
  228. timeout: 10000,
  229. });
  230. const data = debugRes.data.data;
  231. if (!data.is_valid) {
  232. app.log.warn({ action: 'token_refresh', platform: 'instagram', username: account.username, outcome: 'skip', reason: 'invalid_token' });
  233. errors.push({ username: account.username, error: 'token_invalid' });
  234. continue;
  235. }
  236. // expires_at is a Unix timestamp; null means never-expiring (page token etc.)
  237. daysLeft = data.expires_at
  238. ? Math.ceil((data.expires_at * 1000 - Date.now()) / (1000 * 60 * 60 * 24))
  239. : null;
  240. } catch (err) {
  241. app.log.warn({ action: 'token_refresh', platform: 'instagram', username: account.username, step: 'debug_token', outcome: 'failure', err: err.message });
  242. errors.push({ username: account.username, error: err.message });
  243. continue;
  244. }
  245. // Token never expires or has plenty of time — skip
  246. if (daysLeft !== null && daysLeft > TOKEN_REFRESH_THRESHOLD_DAYS) {
  247. skipped.push({ username: account.username, daysLeft });
  248. continue;
  249. }
  250. // Refresh: exchange current long-lived token for a new one
  251. try {
  252. const refreshRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  253. params: {
  254. grant_type: 'fb_exchange_token',
  255. client_id: appCred.appId,
  256. client_secret: plainAppSecret,
  257. fb_exchange_token: plainToken,
  258. },
  259. timeout: 15000,
  260. });
  261. // Mutates the element inside allAccounts (same object reference)
  262. account.accessToken = encryptToken(refreshRes.data.access_token);
  263. refreshed.push({ username: account.username, previousDaysLeft: daysLeft });
  264. app.log.info({ action: 'token_refresh', platform: 'instagram', username: account.username, outcome: 'success', previousDaysLeft: daysLeft });
  265. } catch (err) {
  266. app.log.error({ action: 'token_refresh', platform: 'instagram', username: account.username, outcome: 'failure', err: err.message });
  267. errors.push({ username: account.username, error: err.message });
  268. }
  269. }
  270. if (refreshed.length > 0) {
  271. await setCredentials('instagram', { accounts: allAccounts });
  272. _tokenExpiryCache = null; // force fresh expiry check on next poll
  273. }
  274. app.log.info({ action: 'token_refresh', platform: 'meta', outcome: 'complete', refreshed: refreshed.length, skipped: skipped.length, errors: errors.length });
  275. return { success: true, refreshed: refreshed.length, skipped: skipped.length, errors: errors.length };
  276. });
  277. // ─── Account Profiles ────────────────────────────────────────────────────────
  278. app.get('/profiles', async () => {
  279. const db = await getDb();
  280. const profiles = await db.collection('account_profiles').find({}).toArray();
  281. return { profiles };
  282. });
  283. app.get('/profiles/:accountKey', async (request, reply) => {
  284. const { accountKey } = request.params;
  285. const db = await getDb();
  286. const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
  287. return profile ?? { _id: accountKey };
  288. });
  289. app.put('/profiles/:accountKey', async (request, reply) => {
  290. const { accountKey } = request.params;
  291. const {
  292. businessName = '', description = '', websiteUrl = '', industry = '',
  293. targetAudience = '', toneOfVoice = '', keywords = '', hashtags = '',
  294. postingGuidelines = '',
  295. } = request.body || {};
  296. const db = await getDb();
  297. await db.collection('account_profiles').updateOne(
  298. { _id: accountKey },
  299. { $set: { businessName, description, websiteUrl, industry, targetAudience, toneOfVoice, keywords, hashtags, postingGuidelines, updatedAt: new Date() } },
  300. { upsert: true }
  301. );
  302. return { success: true };
  303. });
  304. // ─── AI / Multi-provider ─────────────────────────────────────────────────────
  305. const DEFAULT_OLLAMA_ENDPOINT = process.env.OLLAMA_ENDPOINT || 'http://ollama:11434';
  306. const DEFAULT_OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
  307. const PROVIDER_MODELS = {
  308. openai: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'],
  309. groq: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'],
  310. gemini: ['gemini-2.0-flash', 'gemini-1.5-flash', 'gemini-1.5-pro'],
  311. };
  312. const PROVIDER_BASE_URLS = {
  313. openai: 'https://api.openai.com/v1',
  314. groq: 'https://api.groq.com/openai/v1',
  315. };
  316. // Returns decrypted runtime config for the currently active provider
  317. async function getActiveProviderConfig() {
  318. const aiConfig = await getCredentials('ai_config');
  319. const provider = aiConfig?.provider || 'ollama';
  320. if (provider === 'openai' || provider === 'groq') {
  321. const doc = await getCredentials(`${provider}_config`);
  322. return {
  323. provider,
  324. apiKey: doc?.apiKey ? decryptToken(doc.apiKey) : null,
  325. model: doc?.model || PROVIDER_MODELS[provider][0],
  326. baseUrl: PROVIDER_BASE_URLS[provider],
  327. };
  328. }
  329. if (provider === 'gemini') {
  330. const doc = await getCredentials('gemini_config');
  331. return {
  332. provider,
  333. apiKey: doc?.apiKey ? decryptToken(doc.apiKey) : null,
  334. model: doc?.model || PROVIDER_MODELS.gemini[0],
  335. };
  336. }
  337. return {
  338. provider: 'ollama',
  339. endpoint: aiConfig?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
  340. model: aiConfig?.model || DEFAULT_OLLAMA_MODEL,
  341. visionModel: aiConfig?.visionModel || 'llava',
  342. };
  343. }
  344. function buildOpenAIMessages(prompt, system) {
  345. const messages = [];
  346. if (system) messages.push({ role: 'system', content: system });
  347. messages.push({ role: 'user', content: prompt });
  348. return messages;
  349. }
  350. // Gemini encodes system as a leading user/model conversation pair
  351. function buildGeminiContents(prompt, system) {
  352. const contents = [];
  353. if (system) {
  354. contents.push({ role: 'user', parts: [{ text: system }] });
  355. contents.push({ role: 'model', parts: [{ text: 'Understood.' }] });
  356. }
  357. contents.push({ role: 'user', parts: [{ text: prompt }] });
  358. return contents;
  359. }
  360. app.get('/ai/config', async () => {
  361. const config = await getCredentials('ai_config');
  362. return {
  363. provider: config?.provider || 'ollama',
  364. endpoint: config?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
  365. model: config?.model || DEFAULT_OLLAMA_MODEL,
  366. visionModel: config?.visionModel || 'llava',
  367. enabled: config?.enabled ?? true,
  368. };
  369. });
  370. app.put('/ai/config', async (request, reply) => {
  371. const { provider = 'ollama', endpoint, model, visionModel = 'llava', enabled = true } = request.body || {};
  372. if (provider === 'ollama' && !endpoint) return reply.code(400).send({ error: 'endpoint is required for Ollama' });
  373. await setCredentials('ai_config', { provider, endpoint, model, visionModel, enabled });
  374. return { success: true };
  375. });
  376. // ─── Provider management routes ───────────────────────────────────────────────
  377. app.get('/ai/providers', async () => {
  378. const aiConfig = await getCredentials('ai_config');
  379. const active = aiConfig?.provider || 'ollama';
  380. const [openaiDoc, groqDoc, geminiDoc] = await Promise.all([
  381. getCredentials('openai_config'),
  382. getCredentials('groq_config'),
  383. getCredentials('gemini_config'),
  384. ]);
  385. return {
  386. active,
  387. providers: [
  388. {
  389. name: 'ollama',
  390. configured: true,
  391. active: active === 'ollama',
  392. endpoint: aiConfig?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
  393. model: aiConfig?.model || DEFAULT_OLLAMA_MODEL,
  394. visionModel: aiConfig?.visionModel || 'llava',
  395. },
  396. {
  397. name: 'openai',
  398. configured: !!openaiDoc?.apiKey,
  399. active: active === 'openai',
  400. model: openaiDoc?.model || PROVIDER_MODELS.openai[0],
  401. apiKeyHint: openaiDoc?.apiKey ? `sk-...${decryptToken(openaiDoc.apiKey).slice(-4)}` : null,
  402. },
  403. {
  404. name: 'groq',
  405. configured: !!groqDoc?.apiKey,
  406. active: active === 'groq',
  407. model: groqDoc?.model || PROVIDER_MODELS.groq[0],
  408. apiKeyHint: groqDoc?.apiKey ? `gsk_...${decryptToken(groqDoc.apiKey).slice(-4)}` : null,
  409. },
  410. {
  411. name: 'gemini',
  412. configured: !!geminiDoc?.apiKey,
  413. active: active === 'gemini',
  414. model: geminiDoc?.model || PROVIDER_MODELS.gemini[0],
  415. apiKeyHint: geminiDoc?.apiKey ? `AIza...${decryptToken(geminiDoc.apiKey).slice(-4)}` : null,
  416. },
  417. ],
  418. };
  419. });
  420. // PUT /ai/provider/:name — save credentials and optionally set as active
  421. // ollama body: { endpoint, model, visionModel, setActive? }
  422. // others body: { apiKey, model, setActive? }
  423. app.put('/ai/provider/:name', async (request, reply) => {
  424. const { name } = request.params;
  425. const { apiKey, model, endpoint, visionModel, setActive = false } = request.body || {};
  426. if (name === 'ollama') {
  427. if (!endpoint) return reply.code(400).send({ error: 'endpoint is required for Ollama' });
  428. const existing = await getCredentials('ai_config') || {};
  429. await setCredentials('ai_config', {
  430. ...existing,
  431. provider: setActive ? 'ollama' : (existing.provider || 'ollama'),
  432. endpoint,
  433. model: model || DEFAULT_OLLAMA_MODEL,
  434. visionModel: visionModel || 'llava',
  435. });
  436. } else if (['openai', 'groq', 'gemini'].includes(name)) {
  437. if (!apiKey) return reply.code(400).send({ error: 'apiKey is required' });
  438. await setCredentials(`${name}_config`, {
  439. apiKey: encryptToken(apiKey),
  440. model: model || PROVIDER_MODELS[name][0],
  441. });
  442. if (setActive) {
  443. const existing = await getCredentials('ai_config') || {};
  444. await setCredentials('ai_config', { ...existing, provider: name });
  445. }
  446. } else {
  447. return reply.code(404).send({ error: `Unknown provider: ${name}` });
  448. }
  449. return { success: true };
  450. });
  451. // DELETE /ai/provider/:name — remove provider credentials; falls back to ollama if it was active
  452. app.delete('/ai/provider/:name', async (request, reply) => {
  453. const { name } = request.params;
  454. if (name === 'ollama') return reply.code(400).send({ error: 'Cannot remove Ollama provider' });
  455. if (!['openai', 'groq', 'gemini'].includes(name)) return reply.code(404).send({ error: `Unknown provider: ${name}` });
  456. const db = await getDb();
  457. await db.collection('platform_credentials').deleteOne({ _id: `${name}_config` });
  458. const aiConfig = await getCredentials('ai_config') || {};
  459. if (aiConfig.provider === name) {
  460. await setCredentials('ai_config', { ...aiConfig, provider: 'ollama' });
  461. }
  462. return { success: true };
  463. });
  464. // POST /ai/provider/:name/models — list models for a provider (test without saving key)
  465. app.post('/ai/provider/:name/models', async (request, reply) => {
  466. const { name } = request.params;
  467. const { apiKey: bodyApiKey, endpoint: bodyEndpoint } = request.body || {};
  468. if (name === 'ollama') {
  469. const aiConfig = await getCredentials('ai_config');
  470. const ep = bodyEndpoint || aiConfig?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  471. try {
  472. const res = await axios.get(`${ep}/api/tags`, { timeout: 5000 });
  473. return { models: (res.data.models || []).map((m) => m.name) };
  474. } catch (err) {
  475. return reply.code(503).send({ error: 'Could not reach Ollama', detail: err.message });
  476. }
  477. }
  478. if (['openai', 'groq', 'gemini'].includes(name)) {
  479. return { models: PROVIDER_MODELS[name] };
  480. }
  481. return reply.code(404).send({ error: `Unknown provider: ${name}` });
  482. });
  483. app.get('/ai/models', async (request, reply) => {
  484. const config = await getCredentials('ai_config');
  485. const provider = config?.provider || 'ollama';
  486. if (provider !== 'ollama') {
  487. return { models: PROVIDER_MODELS[provider] || [], provider };
  488. }
  489. const endpoint = request.query.endpoint || config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  490. try {
  491. const res = await axios.get(`${endpoint}/api/tags`, { timeout: 5000 });
  492. const models = (res.data.models || []).map((m) => m.name);
  493. return { models, endpoint };
  494. } catch (err) {
  495. return reply.code(503).send({ error: 'Could not reach Ollama — check the endpoint', detail: err.message });
  496. }
  497. });
  498. app.post('/ai/generate', async (request, reply) => {
  499. const { prompt, system, model: reqModel } = request.body || {};
  500. if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
  501. const pconf = await getActiveProviderConfig();
  502. const model = reqModel || pconf.model;
  503. try {
  504. if (pconf.provider === 'ollama') {
  505. const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
  506. return { text: res.data.response, model, done: res.data.done };
  507. }
  508. if (pconf.provider === 'openai' || pconf.provider === 'groq') {
  509. if (!pconf.apiKey) return reply.code(503).send({ error: `${pconf.provider} API key not configured` });
  510. const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
  511. model, messages: buildOpenAIMessages(prompt, system), stream: false,
  512. }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 90000 });
  513. return { text: res.data.choices[0]?.message?.content || '', model, done: true };
  514. }
  515. if (pconf.provider === 'gemini') {
  516. if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
  517. const res = await axios.post(
  518. `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
  519. { contents: buildGeminiContents(prompt, system) },
  520. { timeout: 90000 },
  521. );
  522. return { text: res.data.candidates?.[0]?.content?.parts?.[0]?.text || '', model, done: true };
  523. }
  524. return reply.code(400).send({ error: `Unknown provider: ${pconf.provider}` });
  525. } catch (err) {
  526. const status = err.response?.status || 503;
  527. return reply.code(status).send({ error: 'AI generation failed', detail: err.message });
  528. }
  529. });
  530. const CAPTION_PROMPT = 'Generate an engaging, concise social media caption for this image. Write only the caption text with relevant hashtags. No explanations or preamble.';
  531. // Vision caption — supports ollama, openai, gemini (groq has no vision)
  532. app.post('/ai/caption', async (request, reply) => {
  533. const { imageUrl, model: reqModel } = request.body || {};
  534. if (!imageUrl) return reply.code(400).send({ error: 'imageUrl is required' });
  535. const pconf = await getActiveProviderConfig();
  536. let imageBase64, imageMime;
  537. try {
  538. let imageBuffer;
  539. if (imageUrl.startsWith('/media/')) {
  540. const filename = path.basename(imageUrl);
  541. imageBuffer = fs.readFileSync(path.join(UPLOAD_DIR, filename));
  542. } else {
  543. const imgRes = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 15000 });
  544. imageBuffer = Buffer.from(imgRes.data);
  545. imageMime = imgRes.headers['content-type'] || 'image/jpeg';
  546. }
  547. imageBase64 = imageBuffer.toString('base64');
  548. if (!imageMime) imageMime = 'image/jpeg';
  549. } catch (err) {
  550. return reply.code(400).send({ error: 'Could not load image', detail: err.message });
  551. }
  552. try {
  553. const model = reqModel || pconf.visionModel || pconf.model;
  554. if (pconf.provider === 'ollama') {
  555. const res = await axios.post(`${pconf.endpoint}/api/generate`, {
  556. model, prompt: CAPTION_PROMPT, images: [imageBase64], stream: false,
  557. }, { timeout: 90000 });
  558. return { caption: res.data.response, model };
  559. }
  560. if (pconf.provider === 'openai') {
  561. if (!pconf.apiKey) return reply.code(503).send({ error: 'OpenAI API key not configured' });
  562. const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
  563. model: model || 'gpt-4o',
  564. messages: [{ role: 'user', content: [
  565. { type: 'text', text: CAPTION_PROMPT },
  566. { type: 'image_url', image_url: { url: `data:${imageMime};base64,${imageBase64}` } },
  567. ]}],
  568. stream: false,
  569. }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 90000 });
  570. return { caption: res.data.choices[0]?.message?.content || '', model };
  571. }
  572. if (pconf.provider === 'gemini') {
  573. if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
  574. const geminiModel = model || 'gemini-1.5-flash';
  575. const res = await axios.post(
  576. `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${pconf.apiKey}`,
  577. { contents: [{ role: 'user', parts: [
  578. { text: CAPTION_PROMPT },
  579. { inlineData: { mimeType: imageMime, data: imageBase64 } },
  580. ]}]},
  581. { timeout: 90000 },
  582. );
  583. return { caption: res.data.candidates?.[0]?.content?.parts?.[0]?.text || '', model: geminiModel };
  584. }
  585. return reply.code(400).send({ error: `Provider ${pconf.provider} does not support vision captions` });
  586. } catch (err) {
  587. const status = err.response?.status || 503;
  588. return reply.code(status).send({ error: 'Caption generation failed', detail: err.message });
  589. }
  590. });
  591. // SSE streaming endpoint — normalized data: { token, done } format for all providers
  592. app.post('/ai/stream', async (request, reply) => {
  593. const { prompt, system, model: reqModel } = request.body || {};
  594. if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
  595. const pconf = await getActiveProviderConfig();
  596. const model = reqModel || pconf.model;
  597. reply.raw.setHeader('Content-Type', 'text/event-stream');
  598. reply.raw.setHeader('Cache-Control', 'no-cache');
  599. reply.raw.setHeader('X-Accel-Buffering', 'no');
  600. reply.raw.setHeader('Connection', 'keep-alive');
  601. reply.raw.flushHeaders();
  602. const writeToken = (token, done = false) => reply.raw.write(`data: ${JSON.stringify({ token, done })}\n\n`);
  603. const writeError = (msg) => { reply.raw.write(`data: ${JSON.stringify({ error: msg, done: true })}\n\n`); reply.raw.end(); };
  604. try {
  605. if (pconf.provider === 'ollama') {
  606. const ollamaRes = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: true }, { responseType: 'stream', timeout: 120000 });
  607. ollamaRes.data.on('data', (chunk) => {
  608. try {
  609. for (const line of chunk.toString().split('\n').filter(Boolean)) {
  610. const data = JSON.parse(line);
  611. writeToken(data.response || '', !!data.done);
  612. }
  613. } catch (_) {}
  614. });
  615. ollamaRes.data.on('end', () => reply.raw.end());
  616. ollamaRes.data.on('error', (err) => writeError(err.message));
  617. return;
  618. }
  619. if (pconf.provider === 'openai' || pconf.provider === 'groq') {
  620. if (!pconf.apiKey) return writeError(`${pconf.provider} API key not configured`);
  621. const upstreamRes = await axios.post(`${pconf.baseUrl}/chat/completions`, {
  622. model, messages: buildOpenAIMessages(prompt, system), stream: true,
  623. }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, responseType: 'stream', timeout: 120000 });
  624. upstreamRes.data.on('data', (chunk) => {
  625. try {
  626. for (const line of chunk.toString().split('\n').filter(Boolean)) {
  627. if (!line.startsWith('data: ')) continue;
  628. const payload = line.slice(6).trim();
  629. if (payload === '[DONE]') { writeToken('', true); return; }
  630. const data = JSON.parse(payload);
  631. const token = data.choices?.[0]?.delta?.content || '';
  632. if (token) writeToken(token);
  633. }
  634. } catch (_) {}
  635. });
  636. upstreamRes.data.on('end', () => reply.raw.end());
  637. upstreamRes.data.on('error', (err) => writeError(err.message));
  638. return;
  639. }
  640. if (pconf.provider === 'gemini') {
  641. if (!pconf.apiKey) return writeError('Gemini API key not configured');
  642. const geminiRes = await axios.post(
  643. `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${pconf.apiKey}`,
  644. { contents: buildGeminiContents(prompt, system) },
  645. { responseType: 'stream', timeout: 120000 },
  646. );
  647. geminiRes.data.on('data', (chunk) => {
  648. try {
  649. for (const line of chunk.toString().split('\n').filter(Boolean)) {
  650. if (!line.startsWith('data: ')) continue;
  651. const data = JSON.parse(line.slice(6));
  652. const token = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
  653. if (token) writeToken(token);
  654. }
  655. } catch (_) {}
  656. });
  657. geminiRes.data.on('end', () => { writeToken('', true); reply.raw.end(); });
  658. geminiRes.data.on('error', (err) => writeError(err.message));
  659. return;
  660. }
  661. writeError(`Unknown provider: ${pconf.provider}`);
  662. } catch (err) {
  663. writeError(err.message);
  664. }
  665. });
  666. // ─── Bulk AI Draft Generation ─────────────────────────────────────────────────
  667. // POST /ai/bulk-draft — kick off a batch; returns batchId immediately (non-blocking)
  668. // Body: { topics: string[], destinations: Destination[], tone?: string, model?: string }
  669. app.post('/ai/bulk-draft', async (request, reply) => {
  670. const { topics, destinations = [], tone = '', model: reqModel } = request.body || {};
  671. if (!Array.isArray(topics) || !topics.length) return reply.code(400).send({ error: 'topics array is required' });
  672. const filteredTopics = topics.map((t) => (typeof t === 'string' ? t.trim() : '')).filter(Boolean);
  673. if (!filteredTopics.length) return reply.code(400).send({ error: 'No valid topics provided' });
  674. const db = await getDb();
  675. const batchId = new ObjectId();
  676. const now = new Date();
  677. await db.collection('bulk_draft_batches').insertOne({
  678. _id: batchId,
  679. total: filteredTopics.length,
  680. completed: 0,
  681. failed: 0,
  682. status: 'processing',
  683. createdAt: now,
  684. updatedAt: now,
  685. });
  686. const selectedDests = destinations.filter((d) => d.selected);
  687. const toneClause = tone ? `Write in a ${tone} tone.` : '';
  688. const system = `You are a social media content writer. Create engaging, concise posts that perform well. ${toneClause} Write only the post text with relevant hashtags. No explanations or preamble.`;
  689. // Fire-and-forget — process topics sequentially in the background
  690. (async () => {
  691. const pconf = await getActiveProviderConfig();
  692. const model = reqModel || pconf.model;
  693. for (const topic of filteredTopics) {
  694. try {
  695. const prompt = `Write a social media post about: ${topic}`;
  696. let content = '';
  697. if (pconf.provider === 'ollama') {
  698. const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
  699. content = res.data.response || '';
  700. } else if (pconf.provider === 'openai' || pconf.provider === 'groq') {
  701. if (!pconf.apiKey) throw new Error(`${pconf.provider} API key not configured`);
  702. const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
  703. model, messages: buildOpenAIMessages(prompt, system), stream: false,
  704. }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 90000 });
  705. content = res.data.choices[0]?.message?.content || '';
  706. } else if (pconf.provider === 'gemini') {
  707. if (!pconf.apiKey) throw new Error('Gemini API key not configured');
  708. const res = await axios.post(
  709. `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
  710. { contents: buildGeminiContents(prompt, system) },
  711. { timeout: 90000 },
  712. );
  713. content = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
  714. }
  715. if (content.trim()) {
  716. const draftNow = new Date();
  717. await db.collection('drafts').insertOne({
  718. content: content.trim(),
  719. mediaUrl: '',
  720. scheduledAt: '',
  721. destinations: selectedDests,
  722. batchId: batchId.toString(),
  723. topic,
  724. createdAt: draftNow,
  725. updatedAt: draftNow,
  726. });
  727. }
  728. await db.collection('bulk_draft_batches').updateOne(
  729. { _id: batchId },
  730. { $inc: { completed: 1 }, $set: { updatedAt: new Date() } },
  731. );
  732. } catch (err) {
  733. log.error({ action: 'bulk_draft_topic', topic, outcome: 'failure', err: err.message });
  734. await db.collection('bulk_draft_batches').updateOne(
  735. { _id: batchId },
  736. { $inc: { failed: 1 }, $set: { updatedAt: new Date() } },
  737. );
  738. }
  739. }
  740. await db.collection('bulk_draft_batches').updateOne(
  741. { _id: batchId },
  742. { $set: { status: 'done', updatedAt: new Date() } },
  743. );
  744. log.info({ action: 'bulk_draft_batch', batchId: batchId.toString(), total: filteredTopics.length, outcome: 'success' });
  745. })().catch((err) => {
  746. log.error({ action: 'bulk_draft_batch', batchId: batchId.toString(), outcome: 'failure', err: err.message });
  747. getDb().then((d) => d.collection('bulk_draft_batches').updateOne(
  748. { _id: batchId },
  749. { $set: { status: 'failed', updatedAt: new Date() } },
  750. )).catch(() => {});
  751. });
  752. return reply.code(202).send({ batchId: batchId.toString() });
  753. });
  754. // GET /ai/bulk-draft/:batchId — poll batch progress
  755. app.get('/ai/bulk-draft/:batchId', async (request, reply) => {
  756. const { batchId } = request.params;
  757. let oid;
  758. try { oid = new ObjectId(batchId); } catch { return reply.code(400).send({ error: 'Invalid batchId' }); }
  759. const db = await getDb();
  760. const batch = await db.collection('bulk_draft_batches').findOne({ _id: oid });
  761. if (!batch) return reply.code(404).send({ error: 'Batch not found' });
  762. return {
  763. batchId: batch._id.toString(),
  764. total: batch.total,
  765. completed: batch.completed,
  766. failed: batch.failed,
  767. status: batch.status,
  768. processed: batch.completed + batch.failed,
  769. };
  770. });
  771. // ─── Platform service URLs ────────────────────────────────────────────────────
  772. const PLATFORM_SERVICES = {
  773. twitter: process.env.TWITTER_SERVICE_URL || 'http://twitter:3001',
  774. linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
  775. mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
  776. bluesky: process.env.BLUESKY_SERVICE_URL || 'http://bluesky:3004',
  777. instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
  778. facebook: process.env.FACEBOOK_SERVICE_URL || 'http://facebook:3006',
  779. };
  780. // Direct multi-platform post endpoint.
  781. // Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
  782. app.post('/post', async (request, reply) => {
  783. const { content, destinations = [] } = request.body || {};
  784. if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
  785. if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
  786. const results = await Promise.allSettled(
  787. destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
  788. const serviceUrl = PLATFORM_SERVICES[platform];
  789. if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
  790. const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
  791. return { platform, accountId, ...res.data };
  792. })
  793. );
  794. const output = results.map((r, i) =>
  795. r.status === 'fulfilled'
  796. ? r.value
  797. : { platform: destinations[i].platform, accountId: destinations[i].accountId, success: false, error: r.reason?.message }
  798. );
  799. const anyFailed = output.some((r) => !r.success);
  800. const anySucceeded = output.some((r) => r.success);
  801. const postStatus = anyFailed && anySucceeded ? 'partial' : anyFailed ? 'failed' : 'published';
  802. // Record the post for analytics
  803. try {
  804. const db = await getDb();
  805. await db.collection('posts').insertOne({
  806. _id: crypto.randomUUID(),
  807. type: 'immediate',
  808. content,
  809. destinations,
  810. platformResults: Object.fromEntries(
  811. output.map((r) => [
  812. r.accountId ? `${r.platform}:${r.accountId}` : r.platform,
  813. { success: r.success, ...(r.error && { error: r.error }) },
  814. ])
  815. ),
  816. status: postStatus,
  817. publishedAt: new Date(),
  818. createdAt: new Date(),
  819. });
  820. } catch (err) {
  821. app.log.warn({ action: 'post_record', outcome: 'failure', err: err.message });
  822. }
  823. return reply.code(anyFailed ? 207 : 200).send({ results: output });
  824. });
  825. // ─── Legacy post route ────────────────────────────────────────────────────────
  826. let rabbitMQProducer = new RabbitMQProducer();
  827. app.post('/', async (request, reply) => {
  828. try {
  829. await rabbitMQProducer.sendMessage('formatter', request.body.message);
  830. reply.send({ status: 'ok' });
  831. } catch (error) {
  832. app.log.error({ action: 'legacy_post', outcome: 'failure', err: error.message });
  833. reply.status(500).send({ error: 'Internal Server Error' });
  834. }
  835. });
  836. // ─── Meta App Credentials ────────────────────────────────────────────────────
  837. // Save Facebook App ID + Secret (entered by user in Settings UI)
  838. app.post('/credentials/meta-app', async (request, reply) => {
  839. const { appId, appSecret } = request.body || {};
  840. if (!appId || !appSecret) {
  841. return reply.code(400).send({ error: 'appId and appSecret are required' });
  842. }
  843. await setCredentials('meta_app', { appId, appSecret: encryptToken(appSecret) });
  844. return { success: true };
  845. });
  846. // Get Meta App config (secret is masked for UI display)
  847. app.get('/credentials/meta-app', async () => {
  848. const cred = await getCredentials('meta_app');
  849. if (!cred) return { configured: false };
  850. const plainSecret = decryptToken(cred.appSecret) || '';
  851. return { configured: true, appId: cred.appId, appSecretHint: plainSecret ? `****${plainSecret.slice(-4)}` : '****' };
  852. });
  853. // ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
  854. // Return the Facebook OAuth URL to redirect the user to
  855. app.get('/auth/meta/init', async (request, reply) => {
  856. const cred = await getCredentials('meta_app');
  857. if (!cred?.appId) {
  858. return reply.code(400).send({ error: 'Save your Facebook App ID and Secret first' });
  859. }
  860. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  861. const scopes = [
  862. 'pages_manage_posts',
  863. 'pages_read_engagement',
  864. 'instagram_basic',
  865. 'instagram_content_publish',
  866. 'instagram_manage_insights',
  867. ].join(',');
  868. const url = `https://www.facebook.com/v22.0/dialog/oauth?client_id=${cred.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}&response_type=code`;
  869. return { url };
  870. });
  871. // OAuth callback — Facebook redirects here after user authorises
  872. app.get('/auth/meta/callback', async (request, reply) => {
  873. const { code, error: oauthError } = request.query;
  874. if (oauthError) {
  875. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(oauthError)}`);
  876. }
  877. if (!code) {
  878. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=no_code`);
  879. }
  880. try {
  881. const appCred = await getCredentials('meta_app');
  882. if (!appCred?.appId) throw new Error('App credentials not configured');
  883. const appSecret = decryptToken(appCred.appSecret);
  884. if (!appSecret) throw new Error('Failed to decrypt app secret');
  885. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  886. // Exchange code for short-lived token
  887. const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  888. params: {
  889. client_id: appCred.appId,
  890. client_secret: appSecret,
  891. redirect_uri: redirectUri,
  892. code,
  893. },
  894. });
  895. // Upgrade to long-lived user token (~60 days)
  896. const longRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  897. params: {
  898. grant_type: 'fb_exchange_token',
  899. client_id: appCred.appId,
  900. client_secret: appSecret,
  901. fb_exchange_token: shortRes.data.access_token,
  902. },
  903. });
  904. const userToken = longRes.data.access_token;
  905. // Fetch all managed Facebook Pages
  906. const pagesRes = await axios.get(`${GRAPH_API}/me/accounts`, {
  907. params: { access_token: userToken, fields: 'id,name,access_token,picture' },
  908. });
  909. const pages = [];
  910. const igAccounts = [];
  911. for (const page of pagesRes.data.data || []) {
  912. pages.push({
  913. id: page.id,
  914. name: page.name,
  915. accessToken: encryptToken(page.access_token),
  916. picture: page.picture?.data?.url || null,
  917. selected: false,
  918. });
  919. // Check for linked Instagram Business Account
  920. try {
  921. const igRes = await axios.get(`${GRAPH_API}/${page.id}`, {
  922. params: {
  923. fields: 'instagram_business_account',
  924. access_token: page.access_token,
  925. },
  926. });
  927. if (igRes.data.instagram_business_account?.id) {
  928. const igId = igRes.data.instagram_business_account.id;
  929. // Fetch IG account details
  930. const igProfile = await axios.get(`${GRAPH_API}/${igId}`, {
  931. params: {
  932. fields: 'id,username,name,profile_picture_url',
  933. access_token: userToken,
  934. },
  935. });
  936. igAccounts.push({
  937. id: igId,
  938. username: igProfile.data.username || igProfile.data.name,
  939. name: igProfile.data.name,
  940. avatar: igProfile.data.profile_picture_url || null,
  941. accessToken: encryptToken(userToken),
  942. pageId: page.id,
  943. selected: false,
  944. });
  945. }
  946. } catch (_) {
  947. // Page has no linked Instagram account — skip
  948. }
  949. }
  950. // Store discovery results for the UI to pick from
  951. await setCredentials('meta_discovery', { pages, igAccounts, discoveredAt: new Date() });
  952. reply.redirect(`${APP_BASE_URL}/settings?meta_discovery=1`);
  953. } catch (err) {
  954. app.log.error({ action: 'meta_oauth_callback', platform: 'meta', outcome: 'failure', err: err.response?.data?.error?.message || err.message });
  955. reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(err.message)}`);
  956. }
  957. });
  958. // Return pending discovery results so the UI can render the page picker
  959. app.get('/auth/meta/discovered', async () => {
  960. const discovery = await getCredentials('meta_discovery');
  961. if (!discovery) return { pages: [], igAccounts: [] };
  962. return { pages: discovery.pages || [], igAccounts: discovery.igAccounts || [] };
  963. });
  964. // User has chosen which pages/accounts to connect
  965. app.post('/auth/meta/save', async (request, reply) => {
  966. const { selectedPageIds = [], selectedIgAccountIds = [] } = request.body || {};
  967. const discovery = await getCredentials('meta_discovery');
  968. if (!discovery) return reply.code(400).send({ error: 'No discovery data found — reconnect via OAuth' });
  969. const fbPages = (discovery.pages || []).map((p) => ({
  970. ...p,
  971. selected: selectedPageIds.includes(p.id),
  972. }));
  973. const igAccounts = (discovery.igAccounts || []).map((a) => ({
  974. ...a,
  975. selected: selectedIgAccountIds.includes(a.id),
  976. }));
  977. await setCredentials('facebook', { pages: fbPages });
  978. await setCredentials('instagram', { accounts: igAccounts });
  979. await deleteCredentials('meta_discovery');
  980. _tokenExpiryCache = null; // invalidate cache after reconnect
  981. return { success: true, facebookPages: fbPages.filter((p) => p.selected).length, instagramAccounts: igAccounts.filter((a) => a.selected).length };
  982. });
  983. // Disconnect all Meta platforms
  984. app.delete('/credentials/meta', async () => {
  985. await deleteCredentials('facebook');
  986. await deleteCredentials('instagram');
  987. await deleteCredentials('meta_discovery');
  988. return { success: true };
  989. });
  990. // ─── Pinterest OAuth Flow ─────────────────────────────────────────────────────
  991. const PINTEREST_API = 'https://api.pinterest.com/v5';
  992. const PINTEREST_AUTH_URL = 'https://www.pinterest.com/oauth/';
  993. const PINTEREST_TOKEN_URL = 'https://api.pinterest.com/v5/oauth/token';
  994. app.post('/credentials/pinterest-app', async (request, reply) => {
  995. const { clientId, clientSecret } = request.body || {};
  996. if (!clientId || !clientSecret) {
  997. return reply.code(400).send({ error: 'clientId and clientSecret are required' });
  998. }
  999. await setCredentials('pinterest_app', { clientId, clientSecret: encryptToken(clientSecret) });
  1000. log.info({ action: 'pinterest_app_save', outcome: 'success' });
  1001. return { success: true };
  1002. });
  1003. app.get('/credentials/pinterest-app', async () => {
  1004. const cred = await getCredentials('pinterest_app');
  1005. if (!cred) return { configured: false };
  1006. const plain = decryptToken(cred.clientSecret) || '';
  1007. return { configured: true, clientId: cred.clientId, clientSecretHint: plain ? `****${plain.slice(-4)}` : '****' };
  1008. });
  1009. app.get('/auth/pinterest/init', async (request, reply) => {
  1010. const cred = await getCredentials('pinterest_app');
  1011. if (!cred?.clientId) {
  1012. return reply.code(400).send({ error: 'Save your Pinterest Client ID and Secret first' });
  1013. }
  1014. const redirectUri = `${APP_BASE_URL}/api/auth/pinterest/callback`;
  1015. const scopes = 'pins:read,pins:write,boards:read,user_accounts:read';
  1016. const url = `${PINTEREST_AUTH_URL}?client_id=${cred.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scopes}`;
  1017. return { url };
  1018. });
  1019. app.get('/auth/pinterest/callback', async (request, reply) => {
  1020. const { code, error: oauthError } = request.query;
  1021. if (oauthError) {
  1022. return reply.redirect(`${APP_BASE_URL}/settings?pinterest_error=${encodeURIComponent(oauthError)}`);
  1023. }
  1024. if (!code) {
  1025. return reply.redirect(`${APP_BASE_URL}/settings?pinterest_error=no_code`);
  1026. }
  1027. try {
  1028. const appCred = await getCredentials('pinterest_app');
  1029. if (!appCred?.clientId) throw new Error('App credentials not configured');
  1030. const clientSecret = decryptToken(appCred.clientSecret);
  1031. if (!clientSecret) throw new Error('Failed to decrypt client secret');
  1032. const redirectUri = `${APP_BASE_URL}/api/auth/pinterest/callback`;
  1033. const basicAuth = Buffer.from(`${appCred.clientId}:${clientSecret}`).toString('base64');
  1034. const tokenRes = await axios.post(
  1035. PINTEREST_TOKEN_URL,
  1036. new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri }).toString(),
  1037. {
  1038. headers: {
  1039. Authorization: `Basic ${basicAuth}`,
  1040. 'Content-Type': 'application/x-www-form-urlencoded',
  1041. },
  1042. timeout: 15000,
  1043. }
  1044. );
  1045. const { access_token, refresh_token, expires_in } = tokenRes.data;
  1046. const tokenExpiry = new Date(Date.now() + (expires_in || 30 * 24 * 60 * 60) * 1000).toISOString();
  1047. const [userRes, boardsRes] = await Promise.all([
  1048. axios.get(`${PINTEREST_API}/user_account`, {
  1049. headers: { Authorization: `Bearer ${access_token}` },
  1050. timeout: 10000,
  1051. }),
  1052. axios.get(`${PINTEREST_API}/boards`, {
  1053. headers: { Authorization: `Bearer ${access_token}` },
  1054. params: { page_size: 100 },
  1055. timeout: 15000,
  1056. }),
  1057. ]);
  1058. const boards = (boardsRes.data.items || []).map((b) => ({
  1059. id: b.id,
  1060. name: b.name,
  1061. privacy: b.privacy,
  1062. selected: false,
  1063. }));
  1064. await setCredentials('pinterest', {
  1065. userId: userRes.data.username,
  1066. username: userRes.data.username,
  1067. displayName: userRes.data.business_name || userRes.data.username,
  1068. avatar: userRes.data.profile_image,
  1069. accessToken: encryptToken(access_token),
  1070. refreshToken: refresh_token ? encryptToken(refresh_token) : null,
  1071. tokenExpiry,
  1072. boards,
  1073. });
  1074. log.info({ action: 'pinterest_oauth_callback', username: userRes.data.username, boards: boards.length, outcome: 'success' });
  1075. reply.redirect(`${APP_BASE_URL}/settings?pinterest_connected=1`);
  1076. } catch (err) {
  1077. const msg = err.response?.data?.message || err.message;
  1078. log.error({ action: 'pinterest_oauth_callback', outcome: 'failure', err: msg });
  1079. reply.redirect(`${APP_BASE_URL}/settings?pinterest_error=${encodeURIComponent(msg)}`);
  1080. }
  1081. });
  1082. app.post('/credentials/pinterest/boards', async (request, reply) => {
  1083. const { selectedBoardIds = [] } = request.body || {};
  1084. const cred = await getCredentials('pinterest');
  1085. if (!cred) return reply.code(400).send({ error: 'Pinterest not connected' });
  1086. const boards = (cred.boards || []).map((b) => ({ ...b, selected: selectedBoardIds.includes(b.id) }));
  1087. await setCredentials('pinterest', { ...cred, boards });
  1088. log.info({ action: 'pinterest_boards_save', selected: boards.filter((b) => b.selected).length, outcome: 'success' });
  1089. return { success: true, selected: boards.filter((b) => b.selected).length };
  1090. });
  1091. app.delete('/credentials/pinterest', async () => {
  1092. await deleteCredentials('pinterest');
  1093. return { success: true };
  1094. });
  1095. // ─── Credential Status ────────────────────────────────────────────────────────
  1096. // Aggregate connection status for all DB-managed platforms
  1097. app.get('/credentials', async () => {
  1098. const [metaApp, fb, ig, pinterest] = await Promise.all([
  1099. getCredentials('meta_app'),
  1100. getCredentials('facebook'),
  1101. getCredentials('instagram'),
  1102. getCredentials('pinterest'),
  1103. ]);
  1104. const fbPages = (fb?.pages || []).filter((p) => p.selected);
  1105. const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
  1106. const pinterestBoards = (pinterest?.boards || []).filter((b) => b.selected);
  1107. return {
  1108. metaApp: { configured: !!(metaApp?.appId) },
  1109. facebook: {
  1110. connected: fbPages.length > 0,
  1111. pages: fbPages.map(({ id, name, picture }) => ({ id, name, picture })),
  1112. },
  1113. instagram: {
  1114. connected: igAccounts.length > 0,
  1115. accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
  1116. },
  1117. pinterest: {
  1118. connected: pinterestBoards.length > 0,
  1119. username: pinterest?.username || null,
  1120. boards: pinterestBoards.map(({ id, name, privacy }) => ({ id, name, privacy })),
  1121. allBoards: (pinterest?.boards || []).map(({ id, name, privacy, selected }) => ({ id, name, privacy, selected })),
  1122. },
  1123. };
  1124. });
  1125. // ─── Schedule Suggestions ────────────────────────────────────────────────────
  1126. // [dayOfWeek (0=Sun), hourUTC] pairs — research-based best-practice defaults
  1127. const INDUSTRY_DEFAULTS = {
  1128. facebook: [[2,9],[3,9],[4,9],[2,12],[4,10]],
  1129. instagram: [[1,11],[2,11],[3,11],[2,14],[3,14]],
  1130. twitter: [[2,9],[3,9],[4,9],[2,12],[3,12]],
  1131. linkedin: [[2,8],[3,8],[4,8],[3,12],[4,12]],
  1132. mastodon: [[2,10],[3,10],[4,10],[1,11],[2,11]],
  1133. bluesky: [[1,10],[2,10],[3,10],[1,11],[2,11]],
  1134. reddit: [[1,7],[2,7],[3,7],[4,7],[0,9]],
  1135. youtube: [[4,12],[5,12],[6,12],[4,15],[5,15]],
  1136. pinterest: [[5,12],[6,14],[0,15],[5,20],[6,20]],
  1137. };
  1138. const DEFAULT_SLOTS = [[2,9],[3,9],[4,9],[2,12],[3,12]];
  1139. // Returns the next UTC Date that falls on `dayOfWeek` at `hourUTC`:00,
  1140. // at least `afterMs` milliseconds in the future.
  1141. function nextOccurrence(dayOfWeek, hourUTC, afterMs) {
  1142. const candidate = new Date(afterMs);
  1143. candidate.setUTCHours(hourUTC, 0, 0, 0);
  1144. const daysAhead = (dayOfWeek - candidate.getUTCDay() + 7) % 7;
  1145. if (daysAhead === 0 && candidate.getTime() <= afterMs) {
  1146. candidate.setUTCDate(candidate.getUTCDate() + 7);
  1147. } else {
  1148. candidate.setUTCDate(candidate.getUTCDate() + daysAhead);
  1149. }
  1150. return candidate;
  1151. }
  1152. const DAY_ABBR = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
  1153. app.get('/schedule/suggestions', async (request, reply) => {
  1154. const { platform, accountId } = request.query;
  1155. if (!platform) return reply.code(400).send({ error: 'platform is required' });
  1156. const db = await getDb();
  1157. const query = { platform, ...(accountId && { accountId }) };
  1158. const dataPoints = await db.collection('post_metrics').countDocuments(query);
  1159. let slots;
  1160. let source;
  1161. if (dataPoints >= 10) {
  1162. const agg = await db.collection('post_metrics').aggregate([
  1163. { $match: query },
  1164. { $group: {
  1165. _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
  1166. avgEngagement: { $avg: '$metrics.engagementTotal' },
  1167. count: { $sum: 1 },
  1168. }},
  1169. { $sort: { avgEngagement: -1 } },
  1170. { $limit: 5 },
  1171. ]).toArray();
  1172. slots = agg.map((r) => [r._id.day, r._id.hour]);
  1173. source = 'history';
  1174. } else {
  1175. slots = INDUSTRY_DEFAULTS[platform] || DEFAULT_SLOTS;
  1176. source = 'default';
  1177. }
  1178. // 30-minute lead time so the user has time to finish writing
  1179. const afterMs = Date.now() + 30 * 60 * 1000;
  1180. const suggestions = slots
  1181. .map(([day, hour]) => {
  1182. const dt = nextOccurrence(day, hour, afterMs);
  1183. const h12 = hour % 12 || 12;
  1184. const ampm = hour < 12 ? 'am' : 'pm';
  1185. return {
  1186. utc: dt.toISOString(),
  1187. dayOfWeek: day,
  1188. hour,
  1189. label: `${DAY_ABBR[day]} ${h12}${ampm}`,
  1190. };
  1191. })
  1192. .sort((a, b) => new Date(a.utc) - new Date(b.utc))
  1193. .slice(0, 4);
  1194. app.log.info({ action: 'schedule_suggestions', platform, source, count: suggestions.length });
  1195. return { source, suggestions };
  1196. });
  1197. // ─── Analytics Metrics Crawl ─────────────────────────────────────────────────
  1198. async function crawlFacebookMetrics(db) {
  1199. const fb = await getCredentials('facebook');
  1200. const pages = (fb?.pages || []).filter((p) => p.selected && p.accessToken);
  1201. if (!pages.length) return { count: 0 };
  1202. let count = 0;
  1203. for (const page of pages) {
  1204. const token = decryptToken(page.accessToken);
  1205. if (!token) continue;
  1206. try {
  1207. const res = await axios.get(`${GRAPH_API}/${page.id}/posts`, {
  1208. params: {
  1209. fields: 'id,message,created_time,reactions.summary(total_count),comments.summary(total_count),shares',
  1210. limit: 100,
  1211. access_token: token,
  1212. },
  1213. timeout: 30000,
  1214. });
  1215. for (const post of res.data.data || []) {
  1216. const likes = post.reactions?.summary?.total_count || 0;
  1217. const comments = post.comments?.summary?.total_count || 0;
  1218. const shares = post.shares?.count || 0;
  1219. const publishedAt = new Date(post.created_time);
  1220. await db.collection('post_metrics').updateOne(
  1221. { platform: 'facebook', postId: post.id },
  1222. {
  1223. $set: {
  1224. platform: 'facebook',
  1225. accountId: page.id,
  1226. accountName: page.name,
  1227. postId: post.id,
  1228. content: post.message || null,
  1229. publishedAt,
  1230. metrics: { likes, comments, shares, views: 0, saves: 0, engagementTotal: likes + comments + shares },
  1231. hourOfDay: publishedAt.getUTCHours(),
  1232. dayOfWeek: publishedAt.getUTCDay(),
  1233. fetchedAt: new Date(),
  1234. },
  1235. },
  1236. { upsert: true }
  1237. );
  1238. count++;
  1239. }
  1240. } catch (err) {
  1241. app.log.warn({ action: 'metrics_crawl', platform: 'facebook', pageId: page.id, outcome: 'failure', err: err.message });
  1242. }
  1243. }
  1244. return { count };
  1245. }
  1246. async function crawlInstagramMetrics(db) {
  1247. const ig = await getCredentials('instagram');
  1248. const accounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
  1249. if (!accounts.length) return { count: 0 };
  1250. let count = 0;
  1251. for (const account of accounts) {
  1252. const token = decryptToken(account.accessToken);
  1253. if (!token) continue;
  1254. try {
  1255. const mediaRes = await axios.get(`${GRAPH_API}/${account.id}/media`, {
  1256. params: { fields: 'id,caption,timestamp,like_count,comments_count', limit: 100, access_token: token },
  1257. timeout: 30000,
  1258. });
  1259. for (const media of mediaRes.data.data || []) {
  1260. const likes = media.like_count || 0;
  1261. const comments = media.comments_count || 0;
  1262. const publishedAt = new Date(media.timestamp);
  1263. let views = 0;
  1264. let saves = 0;
  1265. try {
  1266. const insRes = await axios.get(`${GRAPH_API}/${media.id}/insights`, {
  1267. params: { metric: 'reach,saved', access_token: token },
  1268. timeout: 10000,
  1269. });
  1270. for (const ins of insRes.data.data || []) {
  1271. if (ins.name === 'reach') views = ins.values?.[0]?.value || 0;
  1272. if (ins.name === 'saved') saves = ins.values?.[0]?.value || 0;
  1273. }
  1274. } catch (_) {}
  1275. await db.collection('post_metrics').updateOne(
  1276. { platform: 'instagram', postId: media.id },
  1277. {
  1278. $set: {
  1279. platform: 'instagram',
  1280. accountId: account.id,
  1281. accountName: account.username,
  1282. postId: media.id,
  1283. content: media.caption || null,
  1284. publishedAt,
  1285. metrics: { likes, comments, shares: 0, views, saves, engagementTotal: likes + comments },
  1286. hourOfDay: publishedAt.getUTCHours(),
  1287. dayOfWeek: publishedAt.getUTCDay(),
  1288. fetchedAt: new Date(),
  1289. },
  1290. },
  1291. { upsert: true }
  1292. );
  1293. count++;
  1294. }
  1295. } catch (err) {
  1296. app.log.warn({ action: 'metrics_crawl', platform: 'instagram', accountId: account.id, outcome: 'failure', err: err.message });
  1297. }
  1298. }
  1299. return { count };
  1300. }
  1301. app.post('/analytics/crawl', async () => {
  1302. const db = await getDb();
  1303. const results = {};
  1304. for (const [platform, crawler] of [['facebook', crawlFacebookMetrics], ['instagram', crawlInstagramMetrics]]) {
  1305. try {
  1306. results[platform] = await crawler(db);
  1307. } catch (err) {
  1308. app.log.error({ action: 'metrics_crawl', platform, outcome: 'failure', err: err.message });
  1309. results[platform] = { count: 0, error: err.message };
  1310. }
  1311. }
  1312. const total = Object.values(results).reduce((sum, r) => sum + (r.count || 0), 0);
  1313. app.log.info({ action: 'metrics_crawl', outcome: 'complete', total });
  1314. return { success: true, total, byPlatform: results };
  1315. });
  1316. app.get('/analytics/insights', async (request) => {
  1317. const filter = parseAccountFilter(request.query.account);
  1318. const metricsMatch = filter
  1319. ? { platform: filter.platform, ...(filter.accountId && { accountId: filter.accountId }) }
  1320. : {};
  1321. const db = await getDb();
  1322. const total = await db.collection('post_metrics').countDocuments(metricsMatch);
  1323. if (total === 0) return { empty: true };
  1324. const [byHourRaw, byDayRaw, topPosts, platformComparison, heatmapRaw] = await Promise.all([
  1325. db.collection('post_metrics').aggregate([
  1326. { $match: metricsMatch },
  1327. { $group: { _id: '$hourOfDay', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
  1328. { $sort: { _id: 1 } },
  1329. ]).toArray(),
  1330. db.collection('post_metrics').aggregate([
  1331. { $match: metricsMatch },
  1332. { $group: { _id: '$dayOfWeek', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
  1333. { $sort: { _id: 1 } },
  1334. ]).toArray(),
  1335. db.collection('post_metrics').find(metricsMatch).sort({ 'metrics.engagementTotal': -1 }).limit(5).toArray(),
  1336. db.collection('post_metrics').aggregate([
  1337. { $match: metricsMatch },
  1338. { $group: {
  1339. _id: '$platform',
  1340. avgEngagement: { $avg: '$metrics.engagementTotal' },
  1341. avgLikes: { $avg: '$metrics.likes' },
  1342. avgComments: { $avg: '$metrics.comments' },
  1343. avgShares: { $avg: '$metrics.shares' },
  1344. totalPosts: { $sum: 1 },
  1345. }},
  1346. { $sort: { avgEngagement: -1 } },
  1347. ]).toArray(),
  1348. db.collection('post_metrics').aggregate([
  1349. { $match: metricsMatch },
  1350. { $group: {
  1351. _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
  1352. avgEngagement: { $avg: '$metrics.engagementTotal' },
  1353. count: { $sum: 1 },
  1354. }},
  1355. ]).toArray(),
  1356. ]);
  1357. const byHour = Array.from({ length: 24 }, (_, h) => {
  1358. const e = byHourRaw.find((r) => r._id === h);
  1359. return { hour: h, avgEngagement: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
  1360. });
  1361. const byDay = Array.from({ length: 7 }, (_, d) => {
  1362. const e = byDayRaw.find((r) => r._id === d);
  1363. return { day: d, avgEngagement: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
  1364. });
  1365. const heatmap = Array.from({ length: 7 * 24 }, (_, i) => {
  1366. const day = Math.floor(i / 24);
  1367. const hour = i % 24;
  1368. const e = heatmapRaw.find((r) => r._id.day === day && r._id.hour === hour);
  1369. return { day, hour, avg: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
  1370. });
  1371. return {
  1372. empty: false,
  1373. total,
  1374. byHour,
  1375. byDay,
  1376. heatmap,
  1377. topPosts: topPosts.map((p) => ({
  1378. platform: p.platform, accountName: p.accountName, postId: p.postId,
  1379. content: p.content, publishedAt: p.publishedAt, metrics: p.metrics,
  1380. })),
  1381. platformComparison: platformComparison.map((p) => ({
  1382. platform: p._id,
  1383. avgEngagement: Math.round(p.avgEngagement),
  1384. avgLikes: Math.round(p.avgLikes),
  1385. avgComments: Math.round(p.avgComments),
  1386. avgShares: Math.round(p.avgShares),
  1387. totalPosts: p.totalPosts,
  1388. })),
  1389. };
  1390. });
  1391. // ─── Analytics ────────────────────────────────────────────────────────────────
  1392. // Parse "platform" or "platform:accountId" filter strings from the account query param.
  1393. function parseAccountFilter(account) {
  1394. if (!account) return null;
  1395. const idx = account.indexOf(':');
  1396. if (idx === -1) return { platform: account };
  1397. return { platform: account.slice(0, idx), accountId: account.slice(idx + 1) };
  1398. }
  1399. // Build a MongoDB match fragment for scheduled_jobs given an account filter.
  1400. function sjFilter(filter) {
  1401. if (!filter) return {};
  1402. return {
  1403. 'destinations.platform': filter.platform,
  1404. ...(filter.accountId && { 'destinations.accountId': filter.accountId }),
  1405. };
  1406. }
  1407. // Build a MongoDB match fragment for posts (type:immediate) given an account filter.
  1408. function ipFilter(filter) {
  1409. if (!filter) return {};
  1410. return {
  1411. 'destinations.platform': filter.platform,
  1412. ...(filter.accountId && { 'destinations.accountId': filter.accountId }),
  1413. };
  1414. }
  1415. app.get('/analytics/summary', async (request) => {
  1416. const filter = parseAccountFilter(request.query.account);
  1417. const db = await getDb();
  1418. const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
  1419. const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  1420. // Post-unwind filter for scheduled_jobs platform breakdown — re-applies the
  1421. // account filter after $unwind so a job targeting multiple platforms only
  1422. // counts the platform(s) that match the filter.
  1423. const unwindFilter = filter ? [{ $match: sjFilter(filter) }] : [];
  1424. const [
  1425. schedCompleted, schedFailed,
  1426. immPublished, immFailed,
  1427. recentSched, recentImm,
  1428. schedPlatformRaw, immPlatformRaw,
  1429. schedDayRaw, immDayRaw,
  1430. ] = await Promise.all([
  1431. db.collection('scheduled_jobs').countDocuments({ status: 'completed', ...sjFilter(filter) }),
  1432. db.collection('scheduled_jobs').countDocuments({ status: 'failed', ...sjFilter(filter) }),
  1433. db.collection('posts').countDocuments({ type: 'immediate', status: { $in: ['published', 'partial'] }, ...ipFilter(filter) }),
  1434. db.collection('posts').countDocuments({ type: 'immediate', status: 'failed', ...ipFilter(filter) }),
  1435. db.collection('scheduled_jobs').countDocuments({ status: 'completed', completedAt: { $gte: sevenDaysAgo }, ...sjFilter(filter) }),
  1436. db.collection('posts').countDocuments({ type: 'immediate', publishedAt: { $gte: sevenDaysAgo }, ...ipFilter(filter) }),
  1437. // Platform breakdown from scheduled_jobs destinations
  1438. db.collection('scheduled_jobs').aggregate([
  1439. { $match: { status: 'completed', ...sjFilter(filter) } },
  1440. { $unwind: '$destinations' },
  1441. ...unwindFilter,
  1442. { $group: { _id: '$destinations.platform', count: { $sum: 1 } } },
  1443. { $sort: { count: -1 } },
  1444. ]).toArray(),
  1445. // Platform breakdown from immediate posts platformResults
  1446. db.collection('posts').aggregate([
  1447. { $match: { type: 'immediate', ...ipFilter(filter) } },
  1448. { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
  1449. { $unwind: '$results' },
  1450. { $match: { 'results.v.success': true } },
  1451. { $project: { platform: { $arrayElemAt: [{ $split: ['$results.k', ':'] }, 0] } } },
  1452. { $group: { _id: '$platform', count: { $sum: 1 } } },
  1453. ]).toArray(),
  1454. // Activity by day from scheduled_jobs (using completedAt)
  1455. db.collection('scheduled_jobs').aggregate([
  1456. { $match: { status: 'completed', completedAt: { $gte: thirtyDaysAgo }, ...sjFilter(filter) } },
  1457. { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$completedAt' } }, count: { $sum: 1 } } },
  1458. { $sort: { _id: 1 } },
  1459. ]).toArray(),
  1460. // Activity by day from immediate posts
  1461. db.collection('posts').aggregate([
  1462. { $match: { type: 'immediate', publishedAt: { $gte: thirtyDaysAgo }, ...ipFilter(filter) } },
  1463. { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
  1464. { $sort: { _id: 1 } },
  1465. ]).toArray(),
  1466. ]);
  1467. const dayMap = {};
  1468. for (const { _id, count } of [...schedDayRaw, ...immDayRaw]) {
  1469. dayMap[_id] = (dayMap[_id] || 0) + count;
  1470. }
  1471. const byDay = Object.entries(dayMap).map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date));
  1472. const platformMap = {};
  1473. for (const { _id, count } of [...schedPlatformRaw, ...immPlatformRaw]) {
  1474. if (_id) platformMap[_id] = (platformMap[_id] || 0) + count;
  1475. }
  1476. const published = schedCompleted + immPublished;
  1477. const failed = schedFailed + immFailed;
  1478. const total = published + failed;
  1479. const successRate = total > 0 ? Math.round((published / total) * 100) : 0;
  1480. const recentCount = recentSched + recentImm;
  1481. return { total, published, failed, partial: 0, successRate, byPlatform: platformMap, byDay, recentCount };
  1482. });
  1483. app.get('/analytics/posts', async (request) => {
  1484. const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
  1485. const skip = parseInt(request.query.skip || '0', 10);
  1486. const filter = parseAccountFilter(request.query.account);
  1487. const db = await getDb();
  1488. const sjMatch = { status: { $in: ['completed', 'failed'] }, ...sjFilter(filter) };
  1489. const ipMatch = { type: 'immediate', ...ipFilter(filter) };
  1490. const [scheduledJobs, immediatePosts, schedTotal, immTotal] = await Promise.all([
  1491. db.collection('scheduled_jobs')
  1492. .find(sjMatch)
  1493. .sort({ completedAt: -1, scheduledAt: -1 })
  1494. .skip(skip)
  1495. .limit(limit)
  1496. .project({ content: 1, destinations: 1, status: 1, completedAt: 1, scheduledAt: 1 })
  1497. .toArray(),
  1498. db.collection('posts')
  1499. .find(ipMatch)
  1500. .sort({ publishedAt: -1 })
  1501. .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1 })
  1502. .toArray(),
  1503. db.collection('scheduled_jobs').countDocuments(sjMatch),
  1504. db.collection('posts').countDocuments(ipMatch),
  1505. ]);
  1506. const normalised = [
  1507. ...scheduledJobs.map((j) => ({
  1508. _id: String(j._id),
  1509. type: 'scheduled',
  1510. content: j.content || null,
  1511. destinations: j.destinations || [],
  1512. platformResults: null,
  1513. status: j.status === 'completed' ? 'published' : 'failed',
  1514. publishedAt: j.completedAt || j.scheduledAt,
  1515. })),
  1516. ...immediatePosts.map((p) => ({
  1517. _id: String(p._id),
  1518. type: 'immediate',
  1519. content: p.content || null,
  1520. destinations: p.destinations || [],
  1521. platformResults: p.platformResults || null,
  1522. status: p.status,
  1523. publishedAt: p.publishedAt,
  1524. })),
  1525. ].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
  1526. .slice(0, limit);
  1527. return { posts: normalised, total: schedTotal + immTotal };
  1528. });
  1529. module.exports = app;