server.js 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094
  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 / Ollama ──────────────────────────────────────────────────────────────
  305. const DEFAULT_OLLAMA_ENDPOINT = process.env.OLLAMA_ENDPOINT || 'http://ollama:11434';
  306. const DEFAULT_OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2';
  307. app.get('/ai/config', async () => {
  308. const config = await getCredentials('ai_config');
  309. return {
  310. provider: config?.provider || 'ollama',
  311. endpoint: config?.endpoint || DEFAULT_OLLAMA_ENDPOINT,
  312. model: config?.model || DEFAULT_OLLAMA_MODEL,
  313. visionModel: config?.visionModel || 'llava',
  314. enabled: config?.enabled ?? true,
  315. };
  316. });
  317. app.put('/ai/config', async (request, reply) => {
  318. const { provider = 'ollama', endpoint, model, visionModel = 'llava', enabled = true } = request.body || {};
  319. if (!endpoint) return reply.code(400).send({ error: 'endpoint is required' });
  320. await setCredentials('ai_config', { provider, endpoint, model, visionModel, enabled });
  321. return { success: true };
  322. });
  323. app.get('/ai/models', async (request, reply) => {
  324. const config = await getCredentials('ai_config');
  325. // Allow caller to override endpoint for test-without-save UX
  326. const endpoint = request.query.endpoint || config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  327. try {
  328. const res = await axios.get(`${endpoint}/api/tags`, { timeout: 5000 });
  329. const models = (res.data.models || []).map((m) => m.name);
  330. return { models, endpoint };
  331. } catch (err) {
  332. return reply.code(503).send({ error: 'Could not reach Ollama — check the endpoint', detail: err.message });
  333. }
  334. });
  335. app.post('/ai/generate', async (request, reply) => {
  336. const { prompt, system, model: reqModel } = request.body || {};
  337. if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
  338. const config = await getCredentials('ai_config');
  339. const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  340. const model = reqModel || config?.model || DEFAULT_OLLAMA_MODEL;
  341. try {
  342. const res = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 90000 });
  343. return { text: res.data.response, model, done: res.data.done };
  344. } catch (err) {
  345. const status = err.response?.status || 503;
  346. return reply.code(status).send({ error: 'AI generation failed', detail: err.message });
  347. }
  348. });
  349. // Vision caption — fetches image, passes base64 to Ollama vision model
  350. app.post('/ai/caption', async (request, reply) => {
  351. const { imageUrl, model: reqModel } = request.body || {};
  352. if (!imageUrl) return reply.code(400).send({ error: 'imageUrl is required' });
  353. const config = await getCredentials('ai_config');
  354. const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  355. const model = reqModel || config?.visionModel || 'llava';
  356. // Fetch image → base64
  357. let imageBase64;
  358. try {
  359. let imageBuffer;
  360. if (imageUrl.startsWith('/media/')) {
  361. const filename = path.basename(imageUrl);
  362. const filepath = path.join(UPLOAD_DIR, filename);
  363. imageBuffer = fs.readFileSync(filepath);
  364. } else {
  365. const imgRes = await axios.get(imageUrl, { responseType: 'arraybuffer', timeout: 15000 });
  366. imageBuffer = Buffer.from(imgRes.data);
  367. }
  368. imageBase64 = imageBuffer.toString('base64');
  369. } catch (err) {
  370. return reply.code(400).send({ error: 'Could not load image', detail: err.message });
  371. }
  372. try {
  373. const res = await axios.post(`${endpoint}/api/generate`, {
  374. model,
  375. prompt: 'Generate an engaging, concise social media caption for this image. Write only the caption text with relevant hashtags. No explanations or preamble.',
  376. images: [imageBase64],
  377. stream: false,
  378. }, { timeout: 90000 });
  379. return { caption: res.data.response, model };
  380. } catch (err) {
  381. const status = err.response?.status || 503;
  382. return reply.code(status).send({ error: 'Caption generation failed', detail: err.message });
  383. }
  384. });
  385. // SSE streaming endpoint — sends token-by-token as text/event-stream
  386. app.post('/ai/stream', async (request, reply) => {
  387. const { prompt, system, model: reqModel } = request.body || {};
  388. if (!prompt?.trim()) return reply.code(400).send({ error: 'prompt is required' });
  389. const config = await getCredentials('ai_config');
  390. const endpoint = config?.endpoint || DEFAULT_OLLAMA_ENDPOINT;
  391. const model = reqModel || config?.model || DEFAULT_OLLAMA_MODEL;
  392. reply.raw.setHeader('Content-Type', 'text/event-stream');
  393. reply.raw.setHeader('Cache-Control', 'no-cache');
  394. reply.raw.setHeader('X-Accel-Buffering', 'no');
  395. reply.raw.setHeader('Connection', 'keep-alive');
  396. reply.raw.flushHeaders();
  397. try {
  398. const ollamaRes = await axios.post(`${endpoint}/api/generate`, { model, prompt, system, stream: true }, { responseType: 'stream', timeout: 120000 });
  399. ollamaRes.data.on('data', (chunk) => {
  400. try {
  401. const lines = chunk.toString().split('\n').filter(Boolean);
  402. for (const line of lines) {
  403. const data = JSON.parse(line);
  404. reply.raw.write(`data: ${JSON.stringify({ token: data.response || '', done: !!data.done })}\n\n`);
  405. }
  406. } catch (_) {}
  407. });
  408. ollamaRes.data.on('end', () => { reply.raw.end(); });
  409. ollamaRes.data.on('error', (err) => {
  410. reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
  411. reply.raw.end();
  412. });
  413. } catch (err) {
  414. reply.raw.write(`data: ${JSON.stringify({ error: err.message, done: true })}\n\n`);
  415. reply.raw.end();
  416. }
  417. });
  418. // ─── Platform service URLs ────────────────────────────────────────────────────
  419. const PLATFORM_SERVICES = {
  420. twitter: process.env.TWITTER_SERVICE_URL || 'http://twitter:3001',
  421. linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
  422. mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
  423. bluesky: process.env.BLUESKY_SERVICE_URL || 'http://bluesky:3004',
  424. instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
  425. facebook: process.env.FACEBOOK_SERVICE_URL || 'http://facebook:3006',
  426. };
  427. // Direct multi-platform post endpoint.
  428. // Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
  429. app.post('/post', async (request, reply) => {
  430. const { content, destinations = [] } = request.body || {};
  431. if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
  432. if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
  433. const results = await Promise.allSettled(
  434. destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
  435. const serviceUrl = PLATFORM_SERVICES[platform];
  436. if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
  437. const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
  438. return { platform, accountId, ...res.data };
  439. })
  440. );
  441. const output = results.map((r, i) =>
  442. r.status === 'fulfilled'
  443. ? r.value
  444. : { platform: destinations[i].platform, accountId: destinations[i].accountId, success: false, error: r.reason?.message }
  445. );
  446. const anyFailed = output.some((r) => !r.success);
  447. const anySucceeded = output.some((r) => r.success);
  448. const postStatus = anyFailed && anySucceeded ? 'partial' : anyFailed ? 'failed' : 'published';
  449. // Record the post for analytics
  450. try {
  451. const db = await getDb();
  452. await db.collection('posts').insertOne({
  453. _id: crypto.randomUUID(),
  454. type: 'immediate',
  455. content,
  456. destinations,
  457. platformResults: Object.fromEntries(
  458. output.map((r) => [
  459. r.accountId ? `${r.platform}:${r.accountId}` : r.platform,
  460. { success: r.success, ...(r.error && { error: r.error }) },
  461. ])
  462. ),
  463. status: postStatus,
  464. publishedAt: new Date(),
  465. createdAt: new Date(),
  466. });
  467. } catch (err) {
  468. app.log.warn({ action: 'post_record', outcome: 'failure', err: err.message });
  469. }
  470. return reply.code(anyFailed ? 207 : 200).send({ results: output });
  471. });
  472. // ─── Legacy post route ────────────────────────────────────────────────────────
  473. let rabbitMQProducer = new RabbitMQProducer();
  474. app.post('/', async (request, reply) => {
  475. try {
  476. await rabbitMQProducer.sendMessage('formatter', request.body.message);
  477. reply.send({ status: 'ok' });
  478. } catch (error) {
  479. app.log.error({ action: 'legacy_post', outcome: 'failure', err: error.message });
  480. reply.status(500).send({ error: 'Internal Server Error' });
  481. }
  482. });
  483. // ─── Meta App Credentials ────────────────────────────────────────────────────
  484. // Save Facebook App ID + Secret (entered by user in Settings UI)
  485. app.post('/credentials/meta-app', async (request, reply) => {
  486. const { appId, appSecret } = request.body || {};
  487. if (!appId || !appSecret) {
  488. return reply.code(400).send({ error: 'appId and appSecret are required' });
  489. }
  490. await setCredentials('meta_app', { appId, appSecret: encryptToken(appSecret) });
  491. return { success: true };
  492. });
  493. // Get Meta App config (secret is masked for UI display)
  494. app.get('/credentials/meta-app', async () => {
  495. const cred = await getCredentials('meta_app');
  496. if (!cred) return { configured: false };
  497. const plainSecret = decryptToken(cred.appSecret) || '';
  498. return { configured: true, appId: cred.appId, appSecretHint: plainSecret ? `****${plainSecret.slice(-4)}` : '****' };
  499. });
  500. // ─── Meta OAuth Flow ──────────────────────────────────────────────────────────
  501. // Return the Facebook OAuth URL to redirect the user to
  502. app.get('/auth/meta/init', async (request, reply) => {
  503. const cred = await getCredentials('meta_app');
  504. if (!cred?.appId) {
  505. return reply.code(400).send({ error: 'Save your Facebook App ID and Secret first' });
  506. }
  507. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  508. const scopes = [
  509. 'pages_manage_posts',
  510. 'pages_read_engagement',
  511. 'instagram_basic',
  512. 'instagram_content_publish',
  513. 'instagram_manage_insights',
  514. ].join(',');
  515. const url = `https://www.facebook.com/v22.0/dialog/oauth?client_id=${cred.appId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}&response_type=code`;
  516. return { url };
  517. });
  518. // OAuth callback — Facebook redirects here after user authorises
  519. app.get('/auth/meta/callback', async (request, reply) => {
  520. const { code, error: oauthError } = request.query;
  521. if (oauthError) {
  522. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(oauthError)}`);
  523. }
  524. if (!code) {
  525. return reply.redirect(`${APP_BASE_URL}/settings?meta_error=no_code`);
  526. }
  527. try {
  528. const appCred = await getCredentials('meta_app');
  529. if (!appCred?.appId) throw new Error('App credentials not configured');
  530. const appSecret = decryptToken(appCred.appSecret);
  531. if (!appSecret) throw new Error('Failed to decrypt app secret');
  532. const redirectUri = `${APP_BASE_URL}/api/auth/meta/callback`;
  533. // Exchange code for short-lived token
  534. const shortRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  535. params: {
  536. client_id: appCred.appId,
  537. client_secret: appSecret,
  538. redirect_uri: redirectUri,
  539. code,
  540. },
  541. });
  542. // Upgrade to long-lived user token (~60 days)
  543. const longRes = await axios.get(`${GRAPH_API}/oauth/access_token`, {
  544. params: {
  545. grant_type: 'fb_exchange_token',
  546. client_id: appCred.appId,
  547. client_secret: appSecret,
  548. fb_exchange_token: shortRes.data.access_token,
  549. },
  550. });
  551. const userToken = longRes.data.access_token;
  552. // Fetch all managed Facebook Pages
  553. const pagesRes = await axios.get(`${GRAPH_API}/me/accounts`, {
  554. params: { access_token: userToken, fields: 'id,name,access_token,picture' },
  555. });
  556. const pages = [];
  557. const igAccounts = [];
  558. for (const page of pagesRes.data.data || []) {
  559. pages.push({
  560. id: page.id,
  561. name: page.name,
  562. accessToken: encryptToken(page.access_token),
  563. picture: page.picture?.data?.url || null,
  564. selected: false,
  565. });
  566. // Check for linked Instagram Business Account
  567. try {
  568. const igRes = await axios.get(`${GRAPH_API}/${page.id}`, {
  569. params: {
  570. fields: 'instagram_business_account',
  571. access_token: page.access_token,
  572. },
  573. });
  574. if (igRes.data.instagram_business_account?.id) {
  575. const igId = igRes.data.instagram_business_account.id;
  576. // Fetch IG account details
  577. const igProfile = await axios.get(`${GRAPH_API}/${igId}`, {
  578. params: {
  579. fields: 'id,username,name,profile_picture_url',
  580. access_token: userToken,
  581. },
  582. });
  583. igAccounts.push({
  584. id: igId,
  585. username: igProfile.data.username || igProfile.data.name,
  586. name: igProfile.data.name,
  587. avatar: igProfile.data.profile_picture_url || null,
  588. accessToken: encryptToken(userToken),
  589. pageId: page.id,
  590. selected: false,
  591. });
  592. }
  593. } catch (_) {
  594. // Page has no linked Instagram account — skip
  595. }
  596. }
  597. // Store discovery results for the UI to pick from
  598. await setCredentials('meta_discovery', { pages, igAccounts, discoveredAt: new Date() });
  599. reply.redirect(`${APP_BASE_URL}/settings?meta_discovery=1`);
  600. } catch (err) {
  601. app.log.error({ action: 'meta_oauth_callback', platform: 'meta', outcome: 'failure', err: err.response?.data?.error?.message || err.message });
  602. reply.redirect(`${APP_BASE_URL}/settings?meta_error=${encodeURIComponent(err.message)}`);
  603. }
  604. });
  605. // Return pending discovery results so the UI can render the page picker
  606. app.get('/auth/meta/discovered', async () => {
  607. const discovery = await getCredentials('meta_discovery');
  608. if (!discovery) return { pages: [], igAccounts: [] };
  609. return { pages: discovery.pages || [], igAccounts: discovery.igAccounts || [] };
  610. });
  611. // User has chosen which pages/accounts to connect
  612. app.post('/auth/meta/save', async (request, reply) => {
  613. const { selectedPageIds = [], selectedIgAccountIds = [] } = request.body || {};
  614. const discovery = await getCredentials('meta_discovery');
  615. if (!discovery) return reply.code(400).send({ error: 'No discovery data found — reconnect via OAuth' });
  616. const fbPages = (discovery.pages || []).map((p) => ({
  617. ...p,
  618. selected: selectedPageIds.includes(p.id),
  619. }));
  620. const igAccounts = (discovery.igAccounts || []).map((a) => ({
  621. ...a,
  622. selected: selectedIgAccountIds.includes(a.id),
  623. }));
  624. await setCredentials('facebook', { pages: fbPages });
  625. await setCredentials('instagram', { accounts: igAccounts });
  626. await deleteCredentials('meta_discovery');
  627. _tokenExpiryCache = null; // invalidate cache after reconnect
  628. return { success: true, facebookPages: fbPages.filter((p) => p.selected).length, instagramAccounts: igAccounts.filter((a) => a.selected).length };
  629. });
  630. // Disconnect all Meta platforms
  631. app.delete('/credentials/meta', async () => {
  632. await deleteCredentials('facebook');
  633. await deleteCredentials('instagram');
  634. await deleteCredentials('meta_discovery');
  635. return { success: true };
  636. });
  637. // ─── Credential Status ────────────────────────────────────────────────────────
  638. // Aggregate connection status for all DB-managed platforms
  639. app.get('/credentials', async () => {
  640. const [metaApp, fb, ig] = await Promise.all([
  641. getCredentials('meta_app'),
  642. getCredentials('facebook'),
  643. getCredentials('instagram'),
  644. ]);
  645. const fbPages = (fb?.pages || []).filter((p) => p.selected);
  646. const igAccounts = (ig?.accounts || []).filter((a) => a.selected);
  647. return {
  648. metaApp: { configured: !!(metaApp?.appId) },
  649. facebook: {
  650. connected: fbPages.length > 0,
  651. pages: fbPages.map(({ id, name, picture }) => ({ id, name, picture })),
  652. },
  653. instagram: {
  654. connected: igAccounts.length > 0,
  655. accounts: igAccounts.map(({ id, username, avatar }) => ({ id, username, avatar })),
  656. },
  657. };
  658. });
  659. // ─── Analytics Metrics Crawl ─────────────────────────────────────────────────
  660. async function crawlFacebookMetrics(db) {
  661. const fb = await getCredentials('facebook');
  662. const pages = (fb?.pages || []).filter((p) => p.selected && p.accessToken);
  663. if (!pages.length) return { count: 0 };
  664. let count = 0;
  665. for (const page of pages) {
  666. const token = decryptToken(page.accessToken);
  667. if (!token) continue;
  668. try {
  669. const res = await axios.get(`${GRAPH_API}/${page.id}/posts`, {
  670. params: {
  671. fields: 'id,message,created_time,reactions.summary(total_count),comments.summary(total_count),shares',
  672. limit: 100,
  673. access_token: token,
  674. },
  675. timeout: 30000,
  676. });
  677. for (const post of res.data.data || []) {
  678. const likes = post.reactions?.summary?.total_count || 0;
  679. const comments = post.comments?.summary?.total_count || 0;
  680. const shares = post.shares?.count || 0;
  681. const publishedAt = new Date(post.created_time);
  682. await db.collection('post_metrics').updateOne(
  683. { platform: 'facebook', postId: post.id },
  684. {
  685. $set: {
  686. platform: 'facebook',
  687. accountId: page.id,
  688. accountName: page.name,
  689. postId: post.id,
  690. content: post.message || null,
  691. publishedAt,
  692. metrics: { likes, comments, shares, views: 0, saves: 0, engagementTotal: likes + comments + shares },
  693. hourOfDay: publishedAt.getUTCHours(),
  694. dayOfWeek: publishedAt.getUTCDay(),
  695. fetchedAt: new Date(),
  696. },
  697. },
  698. { upsert: true }
  699. );
  700. count++;
  701. }
  702. } catch (err) {
  703. app.log.warn({ action: 'metrics_crawl', platform: 'facebook', pageId: page.id, outcome: 'failure', err: err.message });
  704. }
  705. }
  706. return { count };
  707. }
  708. async function crawlInstagramMetrics(db) {
  709. const ig = await getCredentials('instagram');
  710. const accounts = (ig?.accounts || []).filter((a) => a.selected && a.accessToken);
  711. if (!accounts.length) return { count: 0 };
  712. let count = 0;
  713. for (const account of accounts) {
  714. const token = decryptToken(account.accessToken);
  715. if (!token) continue;
  716. try {
  717. const mediaRes = await axios.get(`${GRAPH_API}/${account.id}/media`, {
  718. params: { fields: 'id,caption,timestamp,like_count,comments_count', limit: 100, access_token: token },
  719. timeout: 30000,
  720. });
  721. for (const media of mediaRes.data.data || []) {
  722. const likes = media.like_count || 0;
  723. const comments = media.comments_count || 0;
  724. const publishedAt = new Date(media.timestamp);
  725. let views = 0;
  726. let saves = 0;
  727. try {
  728. const insRes = await axios.get(`${GRAPH_API}/${media.id}/insights`, {
  729. params: { metric: 'reach,saved', access_token: token },
  730. timeout: 10000,
  731. });
  732. for (const ins of insRes.data.data || []) {
  733. if (ins.name === 'reach') views = ins.values?.[0]?.value || 0;
  734. if (ins.name === 'saved') saves = ins.values?.[0]?.value || 0;
  735. }
  736. } catch (_) {}
  737. await db.collection('post_metrics').updateOne(
  738. { platform: 'instagram', postId: media.id },
  739. {
  740. $set: {
  741. platform: 'instagram',
  742. accountId: account.id,
  743. accountName: account.username,
  744. postId: media.id,
  745. content: media.caption || null,
  746. publishedAt,
  747. metrics: { likes, comments, shares: 0, views, saves, engagementTotal: likes + comments },
  748. hourOfDay: publishedAt.getUTCHours(),
  749. dayOfWeek: publishedAt.getUTCDay(),
  750. fetchedAt: new Date(),
  751. },
  752. },
  753. { upsert: true }
  754. );
  755. count++;
  756. }
  757. } catch (err) {
  758. app.log.warn({ action: 'metrics_crawl', platform: 'instagram', accountId: account.id, outcome: 'failure', err: err.message });
  759. }
  760. }
  761. return { count };
  762. }
  763. app.post('/analytics/crawl', async () => {
  764. const db = await getDb();
  765. const results = {};
  766. for (const [platform, crawler] of [['facebook', crawlFacebookMetrics], ['instagram', crawlInstagramMetrics]]) {
  767. try {
  768. results[platform] = await crawler(db);
  769. } catch (err) {
  770. app.log.error({ action: 'metrics_crawl', platform, outcome: 'failure', err: err.message });
  771. results[platform] = { count: 0, error: err.message };
  772. }
  773. }
  774. const total = Object.values(results).reduce((sum, r) => sum + (r.count || 0), 0);
  775. app.log.info({ action: 'metrics_crawl', outcome: 'complete', total });
  776. return { success: true, total, byPlatform: results };
  777. });
  778. app.get('/analytics/insights', async () => {
  779. const db = await getDb();
  780. const total = await db.collection('post_metrics').countDocuments({});
  781. if (total === 0) return { empty: true };
  782. const [byHourRaw, byDayRaw, topPosts, platformComparison, heatmapRaw] = await Promise.all([
  783. db.collection('post_metrics').aggregate([
  784. { $group: { _id: '$hourOfDay', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
  785. { $sort: { _id: 1 } },
  786. ]).toArray(),
  787. db.collection('post_metrics').aggregate([
  788. { $group: { _id: '$dayOfWeek', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
  789. { $sort: { _id: 1 } },
  790. ]).toArray(),
  791. db.collection('post_metrics').find({}).sort({ 'metrics.engagementTotal': -1 }).limit(5).toArray(),
  792. db.collection('post_metrics').aggregate([
  793. { $group: {
  794. _id: '$platform',
  795. avgEngagement: { $avg: '$metrics.engagementTotal' },
  796. avgLikes: { $avg: '$metrics.likes' },
  797. avgComments: { $avg: '$metrics.comments' },
  798. avgShares: { $avg: '$metrics.shares' },
  799. totalPosts: { $sum: 1 },
  800. }},
  801. { $sort: { avgEngagement: -1 } },
  802. ]).toArray(),
  803. db.collection('post_metrics').aggregate([
  804. { $group: {
  805. _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
  806. avgEngagement: { $avg: '$metrics.engagementTotal' },
  807. count: { $sum: 1 },
  808. }},
  809. ]).toArray(),
  810. ]);
  811. const byHour = Array.from({ length: 24 }, (_, h) => {
  812. const e = byHourRaw.find((r) => r._id === h);
  813. return { hour: h, avgEngagement: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
  814. });
  815. const byDay = Array.from({ length: 7 }, (_, d) => {
  816. const e = byDayRaw.find((r) => r._id === d);
  817. return { day: d, avgEngagement: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
  818. });
  819. const heatmap = Array.from({ length: 7 * 24 }, (_, i) => {
  820. const day = Math.floor(i / 24);
  821. const hour = i % 24;
  822. const e = heatmapRaw.find((r) => r._id.day === day && r._id.hour === hour);
  823. return { day, hour, avg: Math.round(e?.avgEngagement || 0), count: e?.count || 0 };
  824. });
  825. return {
  826. empty: false,
  827. total,
  828. byHour,
  829. byDay,
  830. heatmap,
  831. topPosts: topPosts.map((p) => ({
  832. platform: p.platform, accountName: p.accountName, postId: p.postId,
  833. content: p.content, publishedAt: p.publishedAt, metrics: p.metrics,
  834. })),
  835. platformComparison: platformComparison.map((p) => ({
  836. platform: p._id,
  837. avgEngagement: Math.round(p.avgEngagement),
  838. avgLikes: Math.round(p.avgLikes),
  839. avgComments: Math.round(p.avgComments),
  840. avgShares: Math.round(p.avgShares),
  841. totalPosts: p.totalPosts,
  842. })),
  843. };
  844. });
  845. // ─── Analytics ────────────────────────────────────────────────────────────────
  846. app.get('/analytics/summary', async () => {
  847. const db = await getDb();
  848. const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
  849. const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  850. // scheduled_jobs is the primary source — it holds the full history of all
  851. // scheduled posts. posts (type: immediate) supplements it for direct dispatches.
  852. const [
  853. schedCompleted, schedFailed,
  854. immPublished, immFailed,
  855. recentSched, recentImm,
  856. schedPlatformRaw, immPlatformRaw,
  857. schedDayRaw, immDayRaw,
  858. ] = await Promise.all([
  859. db.collection('scheduled_jobs').countDocuments({ status: 'completed' }),
  860. db.collection('scheduled_jobs').countDocuments({ status: 'failed' }),
  861. db.collection('posts').countDocuments({ type: 'immediate', status: { $in: ['published', 'partial'] } }),
  862. db.collection('posts').countDocuments({ type: 'immediate', status: 'failed' }),
  863. db.collection('scheduled_jobs').countDocuments({ status: 'completed', completedAt: { $gte: sevenDaysAgo } }),
  864. db.collection('posts').countDocuments({ type: 'immediate', publishedAt: { $gte: sevenDaysAgo } }),
  865. // Platform breakdown from scheduled_jobs destinations
  866. db.collection('scheduled_jobs').aggregate([
  867. { $match: { status: 'completed' } },
  868. { $unwind: '$destinations' },
  869. { $group: { _id: '$destinations.platform', count: { $sum: 1 } } },
  870. { $sort: { count: -1 } },
  871. ]).toArray(),
  872. // Platform breakdown from immediate posts platformResults
  873. db.collection('posts').aggregate([
  874. { $match: { type: 'immediate' } },
  875. { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
  876. { $unwind: '$results' },
  877. { $match: { 'results.v.success': true } },
  878. { $project: { platform: { $arrayElemAt: [{ $split: ['$results.k', ':'] }, 0] } } },
  879. { $group: { _id: '$platform', count: { $sum: 1 } } },
  880. ]).toArray(),
  881. // Activity by day from scheduled_jobs (using completedAt)
  882. db.collection('scheduled_jobs').aggregate([
  883. { $match: { status: 'completed', completedAt: { $gte: thirtyDaysAgo } } },
  884. { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$completedAt' } }, count: { $sum: 1 } } },
  885. { $sort: { _id: 1 } },
  886. ]).toArray(),
  887. // Activity by day from immediate posts
  888. db.collection('posts').aggregate([
  889. { $match: { type: 'immediate', publishedAt: { $gte: thirtyDaysAgo } } },
  890. { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
  891. { $sort: { _id: 1 } },
  892. ]).toArray(),
  893. ]);
  894. // Merge byDay from both sources
  895. const dayMap = {};
  896. for (const { _id, count } of [...schedDayRaw, ...immDayRaw]) {
  897. dayMap[_id] = (dayMap[_id] || 0) + count;
  898. }
  899. const byDay = Object.entries(dayMap).map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date));
  900. // Merge byPlatform from both sources
  901. const platformMap = {};
  902. for (const { _id, count } of [...schedPlatformRaw, ...immPlatformRaw]) {
  903. if (_id) platformMap[_id] = (platformMap[_id] || 0) + count;
  904. }
  905. const published = schedCompleted + immPublished;
  906. const failed = schedFailed + immFailed;
  907. const total = published + failed;
  908. const successRate = total > 0 ? Math.round((published / total) * 100) : 0;
  909. const recentCount = recentSched + recentImm;
  910. return { total, published, failed, partial: 0, successRate, byPlatform: platformMap, byDay, recentCount };
  911. });
  912. app.get('/analytics/posts', async (request) => {
  913. const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
  914. const skip = parseInt(request.query.skip || '0', 10);
  915. const db = await getDb();
  916. // scheduled_jobs holds all scheduled-post history (content stored from now on;
  917. // older records have content: undefined). Immediate posts come from the posts collection.
  918. const [scheduledJobs, immediatePosts, schedTotal, immTotal] = await Promise.all([
  919. db.collection('scheduled_jobs')
  920. .find({ status: { $in: ['completed', 'failed'] } })
  921. .sort({ completedAt: -1, scheduledAt: -1 })
  922. .skip(skip)
  923. .limit(limit)
  924. .project({ content: 1, destinations: 1, status: 1, completedAt: 1, scheduledAt: 1 })
  925. .toArray(),
  926. db.collection('posts')
  927. .find({ type: 'immediate' })
  928. .sort({ publishedAt: -1 })
  929. .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1 })
  930. .toArray(),
  931. db.collection('scheduled_jobs').countDocuments({ status: { $in: ['completed', 'failed'] } }),
  932. db.collection('posts').countDocuments({ type: 'immediate' }),
  933. ]);
  934. // Normalise to a single shape expected by the frontend
  935. const normalised = [
  936. ...scheduledJobs.map((j) => ({
  937. _id: String(j._id),
  938. type: 'scheduled',
  939. content: j.content || null,
  940. destinations: j.destinations || [],
  941. platformResults: null,
  942. status: j.status === 'completed' ? 'published' : 'failed',
  943. publishedAt: j.completedAt || j.scheduledAt,
  944. })),
  945. ...immediatePosts.map((p) => ({
  946. _id: String(p._id),
  947. type: 'immediate',
  948. content: p.content || null,
  949. destinations: p.destinations || [],
  950. platformResults: p.platformResults || null,
  951. status: p.status,
  952. publishedAt: p.publishedAt,
  953. })),
  954. ].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt))
  955. .slice(0, limit);
  956. return { posts: normalised, total: schedTotal + immTotal };
  957. });
  958. module.exports = app;