diff --git a/apps/api/src/routes/api.test.ts b/apps/api/src/routes/api.test.ts index 68a2e305..3c3fa917 100644 --- a/apps/api/src/routes/api.test.ts +++ b/apps/api/src/routes/api.test.ts @@ -47,6 +47,7 @@ import cookieParser from 'cookie-parser'; import request from 'supertest'; import apiRoutes from './api'; import { signToken } from '../secret'; +import { listActionLogs } from '../services/actionLog'; const app = express(); app.use(express.json()); @@ -110,6 +111,7 @@ describe('API routes', () => { describe('authenticated endpoints', () => { const token = signToken({ username: 'testuser', account: 'default' }); + const mockedListActionLogs = listActionLogs as jest.MockedFunction; test('GET /api/health returns extended payload with valid token', async () => { const res = await request(app).get('/api/health').set('Cookie', `token=${token}`); @@ -154,5 +156,87 @@ describe('API routes', () => { expect(res.body.username).toBe('testuser'); expect(res.body.account).toBe('default'); }); + + test('GET /api/actions/unified returns Instagram and Twitter actions in one feed', async () => { + mockedListActionLogs + .mockResolvedValueOnce({ + actions: [ + { + id: 'ig-1', + platform: 'instagram', + action: 'follow', + account: 'default', + username: 'prospect_ig', + status: 'success', + details: { source: 'campaign-a' }, + createdAt: '2026-07-05T08:00:00.000Z', + }, + ], + pagination: { total: 1, limit: 2, offset: 0, hasMore: false }, + }) + .mockResolvedValueOnce({ + actions: [ + { + id: 'tw-1', + platform: 'twitter', + action: 'like', + account: 'default', + status: 'error', + error: 'rate limited', + createdAt: '2026-07-05T09:00:00.000Z', + }, + ], + pagination: { total: 1, limit: 2, offset: 0, hasMore: false }, + }); + + const res = await request(app) + .get('/api/actions/unified?limit=2&offset=0') + .set('Cookie', `token=${token}`); + + expect(res.status).toBe(200); + expect(mockedListActionLogs).toHaveBeenNthCalledWith(1, { + limit: 2, + offset: 0, + platform: 'instagram', + sort: 'desc', + }); + expect(mockedListActionLogs).toHaveBeenNthCalledWith(2, { + limit: 2, + offset: 0, + platform: 'twitter', + sort: 'desc', + }); + expect(res.body.actions).toEqual([ + { + platform: 'twitter', + action: 'like', + timestamp: '2026-07-05T09:00:00.000Z', + status: 'error', + metadata: { + id: 'tw-1', + account: 'default', + error: 'rate limited', + }, + }, + { + platform: 'instagram', + action: 'follow', + timestamp: '2026-07-05T08:00:00.000Z', + status: 'success', + metadata: { + id: 'ig-1', + account: 'default', + username: 'prospect_ig', + details: { source: 'campaign-a' }, + }, + }, + ]); + expect(res.body.pagination).toMatchObject({ + total: 2, + limit: 2, + offset: 0, + hasMore: false, + }); + }); }); }); diff --git a/apps/api/src/routes/api.ts b/apps/api/src/routes/api.ts index ff9f3f27..294d3495 100644 --- a/apps/api/src/routes/api.ts +++ b/apps/api/src/routes/api.ts @@ -32,6 +32,7 @@ import { getAccount, getAccountsMap } from '../config/accounts'; import { getIgProfile, getEffectiveIgProfile } from '../config/igProfile'; import { getIgRiskSummary } from '../config/igRisk'; import { getActionSummary, listActionLogs, logAction } from '../services/actionLog'; +import type { ActionLogRecord } from '../services/actionLog'; import { AdminLogLevel, listAdminErrors, listAdminLogs } from '../services/adminLogs'; import { createWebhook, @@ -232,6 +233,12 @@ const apiEndpoints = [ auth: true, description: 'List action logs with filtering', }, + { + method: 'GET', + path: '/api/actions/unified', + auth: true, + description: 'List Instagram and Twitter actions in one unified feed', + }, { method: 'GET', path: '/api/actions/summary', auth: true, description: 'Get action summary' }, { method: 'GET', @@ -1198,6 +1205,65 @@ router.get('/scrape-followers', scrapeLimiter, async (req: Request, res: Respons } }); +const UNIFIED_ACTION_PLATFORMS = ['instagram', 'twitter'] as const; + +const parsePaginationQuery = (req: Request, defaultLimit = 20) => { + const rawLimit = Number(req.query.limit); + const limit = + Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.max(1, Math.min(Math.floor(rawLimit), 100)) + : defaultLimit; + const rawOffset = Number(req.query.offset); + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? Math.floor(rawOffset) : 0; + return { limit, offset }; +}; + +const toUnifiedAction = (entry: ActionLogRecord) => ({ + platform: entry.platform, + action: entry.action, + timestamp: entry.createdAt, + status: entry.status, + metadata: { + id: entry.id, + account: entry.account, + ...(entry.username ? { username: entry.username } : {}), + ...(entry.error ? { error: entry.error } : {}), + ...(entry.details ? { details: entry.details } : {}), + }, +}); + +router.get('/actions/unified', async (req: Request, res: Response) => { + try { + const { limit, offset } = parsePaginationQuery(req); + const fetchLimit = Math.max(1, Math.min(limit + offset, 100)); + + const platformResults = await Promise.all( + UNIFIED_ACTION_PLATFORMS.map((platform) => + listActionLogs({ limit: fetchLimit, offset: 0, platform, sort: 'desc' }), + ), + ); + + const mergedActions = platformResults + .flatMap((result) => result.actions) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const actions = mergedActions.slice(offset, offset + limit).map(toUnifiedAction); + const total = platformResults.reduce((sum, result) => sum + result.pagination.total, 0); + + return res.json({ + actions, + pagination: { + total, + limit, + offset, + hasMore: offset + actions.length < total, + }, + }); + } catch (error) { + logger.error('Unified actions listing error:', error); + return res.status(500).json({ error: 'Failed to load unified action logs' }); + } +}); + router.get('/actions', async (req: Request, res: Response) => { try { // Parse pagination params