/** * Notion Exchange Rate Worker * * Fetches daily exchange rates from Open Exchange Rates API * and updates a Notion database with the latest rates. * * - Run `npm run dev` in your terminal to start a development server * - Run `curl "http://localhost:8787/__scheduled?cron=0+0+*+*+*"` to test the scheduled handler * - Run `npm run deploy` to publish your Worker */ interface OXRResponse { base: string; rates: Record; timestamp: number; } interface NotionPage { id: string; code: string; rate: number | null; updatedAt: string | null; } interface NotionQueryResponse { results: Array<{ id: string; properties: { Code: { title: Array<{ plain_text: string }> }; Rate: { number: number | null }; 'Updated At': { date: { start: string } | null }; }; }>; has_more: boolean; next_cursor: string | null; } interface UpdateResult { currency: string; success: boolean; error?: string; } interface SyncResult { success: boolean; currenciesFound: number; ratesFetched: number; succeeded: number; failed: number; errors: string[]; } interface ConvertResult { success: boolean; from: string; to: string; amount: number; rate: number; result: number; timestamp: string | null; error?: string; } interface RatesResult { base: string; timestamp: string | null; rates: Record; } async function fetchExchangeRates(appId: string): Promise { const url = `https://openexchangerates.org/api/latest.json?app_id=${appId}`; const response = await fetch(url); if (!response.ok) { throw new Error(`OXR API error: ${response.status} ${response.statusText}`); } return response.json() as Promise; } async function fetchNotionRates( databaseId: string, token: string ): Promise { const pages: NotionPage[] = []; let hasMore = true; let nextCursor: string | null = null; while (hasMore) { const body: { page_size: number; start_cursor?: string } = { page_size: 100 }; if (nextCursor) { body.start_cursor = nextCursor; } const response = await fetch(`https://api.notion.com/v1/databases/${databaseId}/query`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28', }, body: JSON.stringify(body), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Notion query error: ${response.status} - ${errorText}`); } const data = (await response.json()) as NotionQueryResponse; for (const page of data.results) { const code = page.properties.Code.title[0]?.plain_text; if (code) { pages.push({ id: page.id, code, rate: page.properties.Rate.number, updatedAt: page.properties['Updated At'].date?.start || null, }); } } hasMore = data.has_more; nextCursor = data.next_cursor; } return pages; } async function updateNotionPage( pageId: string, token: string, rate: number ): Promise { const now = new Date().toISOString(); const response = await fetch(`https://api.notion.com/v1/pages/${pageId}`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28', }, body: JSON.stringify({ properties: { Rate: { number: rate, }, 'Updated At': { date: { start: now, }, }, }, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Notion update error: ${response.status} - ${errorText}`); } } async function syncExchangeRates(env: Env): Promise { const result: SyncResult = { success: false, currenciesFound: 0, ratesFetched: 0, succeeded: 0, failed: 0, errors: [], }; if (!env.OXR_APP_ID || !env.NOTION_TOKEN || !env.NOTION_DATABASE_ID) { result.errors.push('Missing required environment variables'); return result; } try { console.log('Fetching existing pages from Notion...'); const notionPages = await fetchNotionRates(env.NOTION_DATABASE_ID, env.NOTION_TOKEN); result.currenciesFound = notionPages.length; console.log(`Found ${notionPages.length} currencies in Notion database`); if (notionPages.length === 0) { result.success = true; console.log('No currencies found in Notion database. Nothing to update.'); return result; } console.log('Fetching exchange rates from OXR...'); const oxrData = await fetchExchangeRates(env.OXR_APP_ID); result.ratesFetched = Object.keys(oxrData.rates).length; console.log(`Fetched ${result.ratesFetched} currency rates. Base: ${oxrData.base}`); const results: UpdateResult[] = []; for (const page of notionPages) { const rate = oxrData.rates[page.code]; if (rate === undefined) { console.warn(`No rate found for ${page.code} in OXR data`); continue; } try { await updateNotionPage(page.id, env.NOTION_TOKEN, rate); results.push({ currency: page.code, success: true }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); results.push({ currency: page.code, success: false, error: errorMessage }); result.errors.push(`${page.code}: ${errorMessage}`); console.error(`Failed to update ${page.code}: ${errorMessage}`); } } result.succeeded = results.filter((r) => r.success).length; result.failed = results.filter((r) => !r.success).length; result.success = true; console.log(`Sync completed: ${result.succeeded} succeeded, ${result.failed} failed`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.errors.push(errorMessage); console.error(`Exchange rate sync failed: ${errorMessage}`); } return result; } async function getRatesFromNotion(env: Env): Promise { if (!env.NOTION_TOKEN || !env.NOTION_DATABASE_ID) { throw new Error('Missing NOTION_TOKEN or NOTION_DATABASE_ID'); } const pages = await fetchNotionRates(env.NOTION_DATABASE_ID, env.NOTION_TOKEN); const rates: Record = {}; let latestTimestamp: string | null = null; for (const page of pages) { if (page.rate !== null) { rates[page.code] = page.rate; if (page.updatedAt && (!latestTimestamp || page.updatedAt > latestTimestamp)) { latestTimestamp = page.updatedAt; } } } return { base: 'USD', timestamp: latestTimestamp, rates, }; } async function convertCurrency( env: Env, from: string, to: string, amount: number ): Promise { const result: ConvertResult = { success: false, from: from.toUpperCase(), to: to.toUpperCase(), amount, rate: 0, result: 0, timestamp: null, }; try { const ratesData = await getRatesFromNotion(env); result.timestamp = ratesData.timestamp; const fromRate = ratesData.rates[from.toUpperCase()]; const toRate = ratesData.rates[to.toUpperCase()]; if (!fromRate) { result.error = `Unknown currency: ${from}`; return result; } if (!toRate) { result.error = `Unknown currency: ${to}`; return result; } // Convert: amount in USD = amount / fromRate // Then convert to target: result = amountInUSD * toRate const amountInUSD = amount / fromRate; const convertedAmount = amountInUSD * toRate; // Calculate the direct rate (1 from = X to) const directRate = toRate / fromRate; result.rate = directRate; result.result = convertedAmount; result.success = true; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); result.error = errorMessage; } return result; } export default { async fetch(req, env, ctx): Promise { const url = new URL(req.url); // Manual sync endpoint if (url.pathname === '/sync') { const result = await syncExchangeRates(env); return new Response(JSON.stringify(result, null, 2), { headers: { 'Content-Type': 'application/json' }, }); } // Currency conversion endpoint (data from Notion) if (url.pathname === '/convert') { const from = url.searchParams.get('from'); const to = url.searchParams.get('to'); const amountStr = url.searchParams.get('amount'); if (!from || !to || !amountStr) { return new Response( JSON.stringify({ error: 'Missing required parameters: from, to, amount', usage: '/convert?from=USD&to=CNY&amount=100', }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } const amount = parseFloat(amountStr); if (isNaN(amount)) { return new Response( JSON.stringify({ error: 'Invalid amount. Must be a number.' }), { status: 400, headers: { 'Content-Type': 'application/json' }, } ); } const result = await convertCurrency(env, from, to, amount); return new Response(JSON.stringify(result, null, 2), { headers: { 'Content-Type': 'application/json' }, }); } // Rates endpoint - get all rates from Notion database if (url.pathname === '/rates') { const base = url.searchParams.get('base') || 'USD'; try { const ratesData = await getRatesFromNotion(env); // If base is USD, return as-is if (base.toUpperCase() === 'USD') { return new Response( JSON.stringify(ratesData, null, 2), { headers: { 'Content-Type': 'application/json' } } ); } // Otherwise, recalculate rates for the new base const baseRate = ratesData.rates[base.toUpperCase()]; if (!baseRate) { return new Response( JSON.stringify({ error: `Unknown currency: ${base}` }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const convertedRates: Record = {}; for (const [currency, rate] of Object.entries(ratesData.rates)) { convertedRates[currency] = rate / baseRate; } convertedRates['USD'] = 1 / baseRate; return new Response( JSON.stringify( { base: base.toUpperCase(), timestamp: ratesData.timestamp, rates: convertedRates, }, null, 2 ), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return new Response( JSON.stringify({ error: errorMessage }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } } // Home page return new Response( `Notion Exchange Rate Worker API Endpoints: GET /convert?from=USD&to=CNY&amount=100 - Convert currency (data from Notion) GET /rates?base=USD - Get exchange rates (data from Notion) GET /sync - Sync rates from OXR to Notion`, { headers: { 'Content-Type': 'text/plain' } } ); }, async scheduled(event, env, ctx): Promise { console.log(`Exchange rate sync started at ${event.cron}`); await syncExchangeRates(env); }, } satisfies ExportedHandler;