|
@@ -1,40 +1,226 @@
|
|
|
/**
|
|
/**
|
|
|
- * Welcome to Cloudflare Workers!
|
|
|
|
|
|
|
+ * Notion Exchange Rate Worker
|
|
|
*
|
|
*
|
|
|
- * This is a template for a Scheduled Worker: a Worker that can run on a
|
|
|
|
|
- * configurable interval:
|
|
|
|
|
- * https://developers.cloudflare.com/workers/platform/triggers/cron-triggers/
|
|
|
|
|
|
|
+ * 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 `npm run dev` in your terminal to start a development server
|
|
|
- * - Run `curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*"` to see your Worker in action
|
|
|
|
|
|
|
+ * - Run `curl "http://localhost:8787/__scheduled?cron=0+0+*+*+*"` to test the scheduled handler
|
|
|
* - Run `npm run deploy` to publish your Worker
|
|
* - Run `npm run deploy` to publish your Worker
|
|
|
- *
|
|
|
|
|
- * Bind resources to your Worker in `wrangler.jsonc`. After adding bindings, a type definition for the
|
|
|
|
|
- * `Env` object can be regenerated with `npm run cf-typegen`.
|
|
|
|
|
- *
|
|
|
|
|
- * Learn more at https://developers.cloudflare.com/workers/
|
|
|
|
|
*/
|
|
*/
|
|
|
|
|
|
|
|
|
|
+interface OXRResponse {
|
|
|
|
|
+ base: string;
|
|
|
|
|
+ rates: Record<string, number>;
|
|
|
|
|
+ timestamp: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface NotionQueryResponse {
|
|
|
|
|
+ results: Array<{
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ Code: { title: Array<{ plain_text: string }> };
|
|
|
|
|
+ };
|
|
|
|
|
+ }>;
|
|
|
|
|
+ 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[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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 fetchAllNotionPages(
|
|
|
|
|
+ databaseId: string,
|
|
|
|
|
+ token: string
|
|
|
|
|
+): Promise<Map<string, string>> {
|
|
|
|
|
+ const currencyMap = new Map<string, string>();
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ currencyMap.set(code, page.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ hasMore = data.has_more;
|
|
|
|
|
+ nextCursor = data.next_cursor;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return currencyMap;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+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: [],
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Validate environment variables
|
|
|
|
|
+ if (!env.OXR_APP_ID || !env.NOTION_TOKEN || !env.NOTION_DATABASE_ID) {
|
|
|
|
|
+ result.errors.push('Missing required environment variables');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Step 1: Fetch all existing pages from Notion database
|
|
|
|
|
+ console.log('Fetching existing pages from Notion...');
|
|
|
|
|
+ const currencyMap = await fetchAllNotionPages(env.NOTION_DATABASE_ID, env.NOTION_TOKEN);
|
|
|
|
|
+ result.currenciesFound = currencyMap.size;
|
|
|
|
|
+ console.log(`Found ${currencyMap.size} currencies in Notion database`);
|
|
|
|
|
+
|
|
|
|
|
+ if (currencyMap.size === 0) {
|
|
|
|
|
+ result.success = true;
|
|
|
|
|
+ console.log('No currencies found in Notion database. Nothing to update.');
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Step 2: Fetch exchange rates from OXR
|
|
|
|
|
+ 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}`);
|
|
|
|
|
+
|
|
|
|
|
+ // Step 3: Update only currencies that exist in Notion
|
|
|
|
|
+ const results: UpdateResult[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const [currencyCode, pageId] of currencyMap) {
|
|
|
|
|
+ const rate = oxrData.rates[currencyCode];
|
|
|
|
|
+
|
|
|
|
|
+ if (rate === undefined) {
|
|
|
|
|
+ console.warn(`No rate found for ${currencyCode} in OXR data`);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ await updateNotionPage(pageId, env.NOTION_TOKEN, rate);
|
|
|
|
|
+ results.push({ currency: currencyCode, success: true });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
|
+ results.push({ currency: currencyCode, success: false, error: errorMessage });
|
|
|
|
|
+ result.errors.push(`${currencyCode}: ${errorMessage}`);
|
|
|
|
|
+ console.error(`Failed to update ${currencyCode}: ${errorMessage}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Summary
|
|
|
|
|
+ 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;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export default {
|
|
export default {
|
|
|
- async fetch(req) {
|
|
|
|
|
|
|
+ async fetch(req, env, ctx): Promise<Response> {
|
|
|
const url = new URL(req.url);
|
|
const url = new URL(req.url);
|
|
|
- url.pathname = '/__scheduled';
|
|
|
|
|
- url.searchParams.append('cron', '* * * * *');
|
|
|
|
|
- return new Response(`To test the scheduled handler, ensure you have used the "--test-scheduled" then try running "curl ${url.href}".`);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Manual trigger endpoint
|
|
|
|
|
+ if (url.pathname === '/sync') {
|
|
|
|
|
+ const result = await syncExchangeRates(env);
|
|
|
|
|
+ return new Response(JSON.stringify(result, null, 2), {
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return new Response('Notion Exchange Rate Worker\n\nUse /sync to manually trigger sync.', {
|
|
|
|
|
+ headers: { 'Content-Type': 'text/plain' },
|
|
|
|
|
+ });
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
- // The scheduled handler is invoked at the interval set in our wrangler.jsonc's
|
|
|
|
|
- // [[triggers]] configuration.
|
|
|
|
|
async scheduled(event, env, ctx): Promise<void> {
|
|
async scheduled(event, env, ctx): Promise<void> {
|
|
|
- // A Cron Trigger can make requests to other endpoints on the Internet,
|
|
|
|
|
- // publish to a Queue, query a D1 Database, and much more.
|
|
|
|
|
- //
|
|
|
|
|
- // We'll keep it simple and make an API call to a Cloudflare API:
|
|
|
|
|
- let resp = await fetch('https://api.cloudflare.com/client/v4/ips');
|
|
|
|
|
- let wasSuccessful = resp.ok ? 'success' : 'fail';
|
|
|
|
|
-
|
|
|
|
|
- // You could store this result in KV, write to a D1 Database, or publish to a Queue.
|
|
|
|
|
- // In this template, we'll just log the result:
|
|
|
|
|
- console.log(`trigger fired at ${event.cron}: ${wasSuccessful}`);
|
|
|
|
|
|
|
+ console.log(`Exchange rate sync started at ${event.cron}`);
|
|
|
|
|
+ await syncExchangeRates(env);
|
|
|
},
|
|
},
|
|
|
} satisfies ExportedHandler<Env>;
|
|
} satisfies ExportedHandler<Env>;
|