| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424 |
- /**
- * 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<string, number>;
- 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<string, number>;
- }
- async function fetchExchangeRates(appId: string): Promise<OXRResponse> {
- 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<OXRResponse>;
- }
- async function fetchNotionRates(
- databaseId: string,
- token: string
- ): Promise<NotionPage[]> {
- 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<void> {
- 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<SyncResult> {
- 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<RatesResult> {
- 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<string, number> = {};
- 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<ConvertResult> {
- 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<Response> {
- 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<string, number> = {};
- 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<void> {
- console.log(`Exchange rate sync started at ${event.cron}`);
- await syncExchangeRates(env);
- },
- } satisfies ExportedHandler<Env>;
|