## 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
155 lines
4.5 KiB
TypeScript
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;
|
|
});
|