Files
twenty/packages/twenty-apps/rollup-engine/scripts/run-rollup-smoke.ts
T
James BryantandGitHub 7ec56433c7 [HACKTOBERFEST] Add rollup engine app with UI-driven configuration (#15482)
## Summary

- add packages/twenty-apps/rollup-engine: a parameterised rollup engine
that ships a default Opportunity → Company
    aggregation.
- declare runtime config in package.json (TWENTY_API_KEY, optional
TWENTY_API_BASE_URL, and ROLLUP_ENGINE_CONFIG) so
app configuration lives entirely in Settings → Apps → [App] →
Configuration.
- document the workflow in README.md: deploy via twenty app sync,
populate env vars in the UI, use the Test panel with
    a ready JSON payload, and reference troubleshooting tips.
- adjust the function entry point to a named async export and add
fallback logic for blank base URLs, matching the
    UI’s env behaviour.
- prune legacy templates and examples so
config/templates/opportunity-to-company.json is the single copy/paste
    starting point.

  ## UI / UX impact

  After syncing the app:

- the Configuration screen shows the three env keys with helpful
descriptions (API key, optional base URL, JSON
    override),
  - the built-in Test your function panel works immediately
- and the default JSON config is available from
config/templates/opportunity-to-company.json for users who need to
    customise rollups.

  ## Testing

  - Out-of-the-box deploy to the hosted workspace (Opportunity update) ✓
  - “Test your function” with the default config ✓
- Override example: point debugOpportunityCount at a scratch field via
ROLLUP_ENGINE_CONFIG ✓
  - Optional: local smoke test (yarn install && yarn smoke) still passes
2025-11-04 12:03:00 +01:00

155 lines
4.5 KiB
TypeScript

import assert from 'node:assert/strict';
import { main as runRollups } from '../serverlessFunctions/calculaterollups/src/index.ts';
type Json = Record<string, unknown>;
interface RequestLogEntry {
url: string;
method: string;
body?: unknown;
}
const now = new Date();
const currentYear = now.getUTCFullYear();
const previousYear = currentYear - 1;
const mockOpportunities = [
{
id: 'opp-1',
companyId: 'company-1',
amount: { amountMicros: 150_000_000, currencyCode: 'USD' },
stage: 'CUSTOMER',
closeDate: `${currentYear}-01-10T12:00:00.000Z`,
},
{
id: 'opp-2',
companyId: 'company-1',
amount: { amountMicros: 90_000_000, currencyCode: 'USD' },
stage: 'PROPOSAL',
closeDate: `${currentYear}-03-05T18:00:00.000Z`,
},
{
id: 'opp-3',
companyId: 'company-1',
amount: { amountMicros: 60_000_000, currencyCode: 'USD' },
stage: 'SCREENING',
closeDate: `${previousYear}-12-15T09:00:00.000Z`,
},
];
const requestLog: RequestLogEntry[] = [];
const updatePayloads: Array<{ id: string; payload: Json }> = [];
const jsonResponse = (data: Json | Json[]) =>
new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
global.fetch = async (
rawUrl: string | URL,
init?: { method?: string; body?: unknown },
): Promise<any> => {
const url = typeof rawUrl === 'string' ? new URL(rawUrl) : rawUrl;
const method = (init?.method ?? 'GET').toUpperCase();
requestLog.push({
url: url.toString(),
method,
body: init?.body ? safeParse(init.body) : undefined,
});
if (url.pathname.endsWith('/opportunities') && method === 'GET') {
const companyId = url.searchParams.get('filter[companyId]');
const items = companyId
? mockOpportunities.filter((opportunity) => opportunity.companyId === companyId)
: mockOpportunities;
return jsonResponse({
data: {
opportunities: items,
},
pageInfo: {
hasNextPage: false,
},
});
}
if (url.pathname.includes('/companies/') && method === 'PATCH') {
const id = url.pathname.split('/').pop() ?? 'unknown';
const payload = safeParse(init?.body) ?? {};
updatePayloads.push({ id, payload });
return jsonResponse({ data: { companies: [{ id, ...payload }] } });
}
throw new Error(`Unhandled request in mock fetch: ${method} ${url.toString()}`);
};
function safeParse(body: unknown) {
if (!body) {
return undefined;
}
if (typeof body === 'string') {
try {
return JSON.parse(body) as Json;
} catch (error) {
console.warn('Failed to parse request body', error);
return undefined;
}
}
return undefined;
}
async function main() {
process.env.TWENTY_API_KEY = 'mock-api-key';
process.env.TWENTY_API_BASE_URL = 'https://mock.twenty/api';
const params = {
trigger: { type: 'databaseEvent' },
record: { companyId: 'company-1' },
opportunity: { companyId: 'company-1' },
};
const result = await runRollups(params);
assert.equal(result.status, 'ok', 'rollup execution should succeed');
const expectedPayload = {
totalPipelineAmount: { amountMicros: 300_000_000, currencyCode: 'USD' },
totalOpportunityCount: 3,
wonPipelineAmount: { amountMicros: 150_000_000, currencyCode: 'USD' },
wonOpportunityCount: 1,
openPipelineAmount: { amountMicros: 150_000_000, currencyCode: 'USD' },
openOpportunityCount: 2,
lastOpportunityCloseDate: `${currentYear.toString().padStart(4, '0')}-03-05`,
};
const companyUpdate = updatePayloads.find((payload) => payload.id === 'company-1');
assert(companyUpdate, 'expected a PATCH payload for company-1');
assert.deepStrictEqual(companyUpdate.payload, expectedPayload);
const opportunitiesRequest = requestLog.find(
(entry) => entry.method === 'GET' && entry.url.includes('/opportunities'),
);
assert(opportunitiesRequest, 'expected at least one GET request for /opportunities');
const filterParams = new URL(opportunitiesRequest.url).searchParams.getAll('filter');
assert.deepStrictEqual(
filterParams,
['companyId[eq]:"company-1"'],
'expected opportunity filter to use the new syntax',
);
console.log('--- Rollup execution summary ---');
console.dir(result, { depth: null });
console.log('\n--- PATCH payloads sent to companies ---');
console.dir(updatePayloads, { depth: null });
console.log('\n--- Requests made ---');
console.dir(requestLog, { depth: null });
}
main().catch((error) => {
console.error('Smoke test failed', error);
process.exitCode = 1;
});