Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion apps/api/src/routes/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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);
});
});
});
121 changes: 121 additions & 0 deletions apps/api/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, unknown>;
};

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<string, unknown>;
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);
Expand Down