diff --git a/apps/api/src/routes/api.test.ts b/apps/api/src/routes/api.test.ts index 68a2e305..e0911a7a 100644 --- a/apps/api/src/routes/api.test.ts +++ b/apps/api/src/routes/api.test.ts @@ -26,7 +26,7 @@ jest.mock('../client/Instagram', () => ({ jest.mock('../services/actionLog', () => ({ logAction: jest.fn().mockResolvedValue(undefined), getActionSummary: jest.fn().mockResolvedValue({}), - listActionLogs: jest.fn().mockResolvedValue([]), + listActionLogs: jest.fn().mockResolvedValue({ actions: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }), })); jest.mock('../services/metrics', () => ({ @@ -155,4 +155,131 @@ describe('API routes', () => { expect(res.body.account).toBe('default'); }); }); + + describe('GET /api/actions/unified', () => { + const token = signToken({ username: 'testuser', account: 'default' }); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { listActionLogs } = require('../services/actionLog'); + + beforeEach(() => { + (listActionLogs as jest.Mock).mockReset(); + }); + + test('responds with 401 without authentication', async () => { + const res = await request(app).get('/api/actions/unified'); + expect(res.status).toBe(401); + }); + + test('returns a merged, paginated IG + Twitter action log', async () => { + // Return different datasets depending on the platform filter argument. + (listActionLogs as jest.Mock).mockImplementation((opts: { platform?: string }) => { + if (opts?.platform === 'instagram') { + return { + actions: [ + { + id: 'ig-1', + platform: 'instagram', + action: 'like', + account: 'default', + username: 'ig_user', + status: 'success', + createdAt: '2026-07-01T10:00:00.000Z', + details: { target: 'post_123' }, + }, + ], + pagination: { total: 1, limit: 40, offset: 0, hasMore: false }, + }; + } + if (opts?.platform === 'twitter') { + return { + actions: [ + { + id: 'tw-1', + platform: 'twitter', + action: 'tweet', + account: 'default', + username: 'tw_user', + status: 'success', + createdAt: '2026-07-01T11:00:00.000Z', + details: { tweetId: '999' }, + }, + ], + pagination: { total: 1, limit: 40, offset: 0, hasMore: false }, + }; + } + return { actions: [], pagination: { total: 0, limit: 20, offset: 0, hasMore: false } }; + }); + + const res = await request(app) + .get('/api/actions/unified') + .set('Cookie', `token=${token}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body.actions)).toBe(true); + expect(res.body.actions).toHaveLength(2); + // Twitter (11:00) should sort before Instagram (10:00) in desc order + expect(res.body.actions[0].platform).toBe('twitter'); + expect(res.body.actions[1].platform).toBe('instagram'); + + // Every item has the consistent unified shape + for (const item of res.body.actions) { + expect(item).toEqual( + expect.objectContaining({ + platform: expect.any(String), + action: expect.any(String), + timestamp: expect.any(String), + status: expect.any(String), + metadata: expect.any(Object), + }), + ); + // metadata should carry the original id + details + expect(item.metadata.id).toBeDefined(); + } + + // Pagination metadata is present + expect(res.body.pagination).toMatchObject({ + total: 2, + limit: 20, + offset: 0, + hasMore: false, + }); + }); + + test('honours limit and offset pagination params', async () => { + (listActionLogs as jest.Mock).mockImplementation((opts: { platform?: string }) => { + if (opts?.platform === 'instagram') { + return { + actions: [ + { id: 'ig-1', platform: 'instagram', action: 'like', account: 'default', status: 'success', createdAt: '2026-07-01T10:00:00.000Z' }, + { id: 'ig-2', platform: 'instagram', action: 'dm', account: 'default', status: 'success', createdAt: '2026-07-01T09:00:00.000Z' }, + ], + pagination: { total: 2, limit: 40, offset: 0, hasMore: false }, + }; + } + return { actions: [], pagination: { total: 0, limit: 40, offset: 0, hasMore: false } }; + }); + + const res = await request(app) + .get('/api/actions/unified?limit=1&offset=1') + .set('Cookie', `token=${token}`); + + expect(res.status).toBe(200); + expect(res.body.actions).toHaveLength(1); + expect(res.body.pagination).toMatchObject({ + total: 2, + limit: 1, + offset: 1, + hasMore: false, + }); + }); + + test('returns 500 when the action log service throws', async () => { + (listActionLogs as jest.Mock).mockRejectedValue(new Error('boom')); + const res = await request(app) + .get('/api/actions/unified') + .set('Cookie', `token=${token}`); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/unified action log/i); + }); + }); }); diff --git a/apps/api/src/routes/api.ts b/apps/api/src/routes/api.ts index ff9f3f27..96592f3b 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 { ActionLogStatus } from '../models/ActionLog'; import { AdminLogLevel, listAdminErrors, listAdminLogs } from '../services/adminLogs'; import { createWebhook, @@ -240,6 +241,12 @@ const apiEndpoints = [ description: 'Export logs as CSV/JSON', }, { method: 'GET', path: '/api/actions/stats', auth: true, description: 'Get action statistics' }, + { + method: 'GET', + path: '/api/actions/unified', + auth: true, + description: 'Merged, paginated IG + Twitter action log with a consistent shape', + }, { method: 'DELETE', path: '/api/actions/cleanup', @@ -1252,6 +1259,120 @@ router.get('/actions/summary', async (req: Request, res: Response) => { } }); +/** + * GET /api/actions/unified + * Returns a merged, paginated action log from Instagram and Twitter sources + * in a single consistent JSON schema. + * + * Query params: + * limit — page size (default 20, max 100) + * offset — page offset (default 0) + * account — optional account filter + * status — optional status filter ("success" | "error") + * sort — "asc" | "desc" (default "desc") + * + * Each item shape: { platform, action, timestamp, status, metadata } + */ +type UnifiedAction = { + platform: string; + action: string; + timestamp: string; + status: ActionLogStatus; + metadata: Record; +}; + +router.get('/actions/unified', async (req: Request, res: Response) => { + try { + const rawLimit = Number(req.query.limit); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 100) : 20; + const rawOffset = Number(req.query.offset); + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + const account = typeof req.query.account === 'string' ? req.query.account : undefined; + const status = + req.query.status === 'success' || req.query.status === 'error' + ? (req.query.status as ActionLogStatus) + : undefined; + const sort = req.query.sort === 'asc' ? 'asc' : 'desc'; + + // Fetch a generous window from each platform in parallel, then merge + // client-side. We over-fetch (limit * 2 per platform) so that after + // merging + sorting + pagination the caller still gets a full page in + // the common case. + const fetchLimit = Math.min(limit * 2, 100); + + const [igResult, twitterResult] = await Promise.all([ + listActionLogs({ + limit: fetchLimit, + offset: 0, + platform: 'instagram', + account, + status, + sort, + }), + listActionLogs({ + limit: fetchLimit, + offset: 0, + platform: 'twitter', + account, + status, + sort, + }), + ]); + + const toUnified = (record: { + platform: string; + action: string; + status: ActionLogStatus; + account: string; + username?: string; + error?: string; + details?: Record; + createdAt: string; + id: string; + }): UnifiedAction => ({ + platform: record.platform, + action: record.action, + timestamp: record.createdAt, + status: record.status, + metadata: { + id: record.id, + account: record.account, + username: record.username, + error: record.error, + details: record.details, + }, + }); + + const merged: UnifiedAction[] = [ + ...igResult.actions.map(toUnified), + ...twitterResult.actions.map(toUnified), + ]; + + // Sort by timestamp (default newest first) + merged.sort((a, b) => + sort === 'asc' + ? a.timestamp.localeCompare(b.timestamp) + : b.timestamp.localeCompare(a.timestamp), + ); + + const total = merged.length; + const paged = merged.slice(offset, offset + limit); + + return res.json({ + actions: paged, + pagination: { + total, + limit, + offset, + hasMore: offset + paged.length < total, + }, + }); + } catch (error) { + logger.error('Unified actions error:', error); + return res.status(500).json({ error: 'Failed to load unified action log' }); + } +}); + router.get('/admin/logs', async (req: Request, res: Response) => { try { const limit = Number(req.query.limit || 50);