index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. /**
  2. * Notion Exchange Rate Worker
  3. *
  4. * Fetches daily exchange rates from Open Exchange Rates API
  5. * and updates a Notion database with the latest rates.
  6. *
  7. * - Run `npm run dev` in your terminal to start a development server
  8. * - Run `curl "http://localhost:8787/__scheduled?cron=0+0+*+*+*"` to test the scheduled handler
  9. * - Run `npm run deploy` to publish your Worker
  10. */
  11. interface OXRResponse {
  12. base: string;
  13. rates: Record<string, number>;
  14. timestamp: number;
  15. }
  16. interface NotionPage {
  17. id: string;
  18. code: string;
  19. rate: number | null;
  20. updatedAt: string | null;
  21. }
  22. interface NotionQueryResponse {
  23. results: Array<{
  24. id: string;
  25. properties: {
  26. Code: { title: Array<{ plain_text: string }> };
  27. Rate: { number: number | null };
  28. 'Updated At': { date: { start: string } | null };
  29. };
  30. }>;
  31. has_more: boolean;
  32. next_cursor: string | null;
  33. }
  34. interface UpdateResult {
  35. currency: string;
  36. success: boolean;
  37. error?: string;
  38. }
  39. interface SyncResult {
  40. success: boolean;
  41. currenciesFound: number;
  42. ratesFetched: number;
  43. succeeded: number;
  44. failed: number;
  45. errors: string[];
  46. }
  47. interface ConvertResult {
  48. success: boolean;
  49. from: string;
  50. to: string;
  51. amount: number;
  52. rate: number;
  53. result: number;
  54. timestamp: string | null;
  55. error?: string;
  56. }
  57. interface RatesResult {
  58. base: string;
  59. timestamp: string | null;
  60. rates: Record<string, number>;
  61. }
  62. async function fetchExchangeRates(appId: string): Promise<OXRResponse> {
  63. const url = `https://openexchangerates.org/api/latest.json?app_id=${appId}`;
  64. const response = await fetch(url);
  65. if (!response.ok) {
  66. throw new Error(`OXR API error: ${response.status} ${response.statusText}`);
  67. }
  68. return response.json() as Promise<OXRResponse>;
  69. }
  70. async function fetchNotionRates(
  71. databaseId: string,
  72. token: string
  73. ): Promise<NotionPage[]> {
  74. const pages: NotionPage[] = [];
  75. let hasMore = true;
  76. let nextCursor: string | null = null;
  77. while (hasMore) {
  78. const body: { page_size: number; start_cursor?: string } = { page_size: 100 };
  79. if (nextCursor) {
  80. body.start_cursor = nextCursor;
  81. }
  82. const response = await fetch(`https://api.notion.com/v1/databases/${databaseId}/query`, {
  83. method: 'POST',
  84. headers: {
  85. 'Authorization': `Bearer ${token}`,
  86. 'Content-Type': 'application/json',
  87. 'Notion-Version': '2022-06-28',
  88. },
  89. body: JSON.stringify(body),
  90. });
  91. if (!response.ok) {
  92. const errorText = await response.text();
  93. throw new Error(`Notion query error: ${response.status} - ${errorText}`);
  94. }
  95. const data = (await response.json()) as NotionQueryResponse;
  96. for (const page of data.results) {
  97. const code = page.properties.Code.title[0]?.plain_text;
  98. if (code) {
  99. pages.push({
  100. id: page.id,
  101. code,
  102. rate: page.properties.Rate.number,
  103. updatedAt: page.properties['Updated At'].date?.start || null,
  104. });
  105. }
  106. }
  107. hasMore = data.has_more;
  108. nextCursor = data.next_cursor;
  109. }
  110. return pages;
  111. }
  112. async function updateNotionPage(
  113. pageId: string,
  114. token: string,
  115. rate: number
  116. ): Promise<void> {
  117. const now = new Date().toISOString();
  118. const response = await fetch(`https://api.notion.com/v1/pages/${pageId}`, {
  119. method: 'PATCH',
  120. headers: {
  121. 'Authorization': `Bearer ${token}`,
  122. 'Content-Type': 'application/json',
  123. 'Notion-Version': '2022-06-28',
  124. },
  125. body: JSON.stringify({
  126. properties: {
  127. Rate: {
  128. number: rate,
  129. },
  130. 'Updated At': {
  131. date: {
  132. start: now,
  133. },
  134. },
  135. },
  136. }),
  137. });
  138. if (!response.ok) {
  139. const errorText = await response.text();
  140. throw new Error(`Notion update error: ${response.status} - ${errorText}`);
  141. }
  142. }
  143. async function syncExchangeRates(env: Env): Promise<SyncResult> {
  144. const result: SyncResult = {
  145. success: false,
  146. currenciesFound: 0,
  147. ratesFetched: 0,
  148. succeeded: 0,
  149. failed: 0,
  150. errors: [],
  151. };
  152. if (!env.OXR_APP_ID || !env.NOTION_TOKEN || !env.NOTION_DATABASE_ID) {
  153. result.errors.push('Missing required environment variables');
  154. return result;
  155. }
  156. try {
  157. console.log('Fetching existing pages from Notion...');
  158. const notionPages = await fetchNotionRates(env.NOTION_DATABASE_ID, env.NOTION_TOKEN);
  159. result.currenciesFound = notionPages.length;
  160. console.log(`Found ${notionPages.length} currencies in Notion database`);
  161. if (notionPages.length === 0) {
  162. result.success = true;
  163. console.log('No currencies found in Notion database. Nothing to update.');
  164. return result;
  165. }
  166. console.log('Fetching exchange rates from OXR...');
  167. const oxrData = await fetchExchangeRates(env.OXR_APP_ID);
  168. result.ratesFetched = Object.keys(oxrData.rates).length;
  169. console.log(`Fetched ${result.ratesFetched} currency rates. Base: ${oxrData.base}`);
  170. const results: UpdateResult[] = [];
  171. for (const page of notionPages) {
  172. const rate = oxrData.rates[page.code];
  173. if (rate === undefined) {
  174. console.warn(`No rate found for ${page.code} in OXR data`);
  175. continue;
  176. }
  177. try {
  178. await updateNotionPage(page.id, env.NOTION_TOKEN, rate);
  179. results.push({ currency: page.code, success: true });
  180. } catch (error) {
  181. const errorMessage = error instanceof Error ? error.message : String(error);
  182. results.push({ currency: page.code, success: false, error: errorMessage });
  183. result.errors.push(`${page.code}: ${errorMessage}`);
  184. console.error(`Failed to update ${page.code}: ${errorMessage}`);
  185. }
  186. }
  187. result.succeeded = results.filter((r) => r.success).length;
  188. result.failed = results.filter((r) => !r.success).length;
  189. result.success = true;
  190. console.log(`Sync completed: ${result.succeeded} succeeded, ${result.failed} failed`);
  191. } catch (error) {
  192. const errorMessage = error instanceof Error ? error.message : String(error);
  193. result.errors.push(errorMessage);
  194. console.error(`Exchange rate sync failed: ${errorMessage}`);
  195. }
  196. return result;
  197. }
  198. async function getRatesFromNotion(env: Env): Promise<RatesResult> {
  199. if (!env.NOTION_TOKEN || !env.NOTION_DATABASE_ID) {
  200. throw new Error('Missing NOTION_TOKEN or NOTION_DATABASE_ID');
  201. }
  202. const pages = await fetchNotionRates(env.NOTION_DATABASE_ID, env.NOTION_TOKEN);
  203. const rates: Record<string, number> = {};
  204. let latestTimestamp: string | null = null;
  205. for (const page of pages) {
  206. if (page.rate !== null) {
  207. rates[page.code] = page.rate;
  208. if (page.updatedAt && (!latestTimestamp || page.updatedAt > latestTimestamp)) {
  209. latestTimestamp = page.updatedAt;
  210. }
  211. }
  212. }
  213. return {
  214. base: 'USD',
  215. timestamp: latestTimestamp,
  216. rates,
  217. };
  218. }
  219. async function convertCurrency(
  220. env: Env,
  221. from: string,
  222. to: string,
  223. amount: number
  224. ): Promise<ConvertResult> {
  225. const result: ConvertResult = {
  226. success: false,
  227. from: from.toUpperCase(),
  228. to: to.toUpperCase(),
  229. amount,
  230. rate: 0,
  231. result: 0,
  232. timestamp: null,
  233. };
  234. try {
  235. const ratesData = await getRatesFromNotion(env);
  236. result.timestamp = ratesData.timestamp;
  237. const fromRate = ratesData.rates[from.toUpperCase()];
  238. const toRate = ratesData.rates[to.toUpperCase()];
  239. if (!fromRate) {
  240. result.error = `Unknown currency: ${from}`;
  241. return result;
  242. }
  243. if (!toRate) {
  244. result.error = `Unknown currency: ${to}`;
  245. return result;
  246. }
  247. // Convert: amount in USD = amount / fromRate
  248. // Then convert to target: result = amountInUSD * toRate
  249. const amountInUSD = amount / fromRate;
  250. const convertedAmount = amountInUSD * toRate;
  251. // Calculate the direct rate (1 from = X to)
  252. const directRate = toRate / fromRate;
  253. result.rate = directRate;
  254. result.result = convertedAmount;
  255. result.success = true;
  256. } catch (error) {
  257. const errorMessage = error instanceof Error ? error.message : String(error);
  258. result.error = errorMessage;
  259. }
  260. return result;
  261. }
  262. export default {
  263. async fetch(req, env, ctx): Promise<Response> {
  264. const url = new URL(req.url);
  265. // Manual sync endpoint
  266. if (url.pathname === '/sync') {
  267. const result = await syncExchangeRates(env);
  268. return new Response(JSON.stringify(result, null, 2), {
  269. headers: { 'Content-Type': 'application/json' },
  270. });
  271. }
  272. // Currency conversion endpoint (data from Notion)
  273. if (url.pathname === '/convert') {
  274. const from = url.searchParams.get('from');
  275. const to = url.searchParams.get('to');
  276. const amountStr = url.searchParams.get('amount');
  277. if (!from || !to || !amountStr) {
  278. return new Response(
  279. JSON.stringify({
  280. error: 'Missing required parameters: from, to, amount',
  281. usage: '/convert?from=USD&to=CNY&amount=100',
  282. }),
  283. {
  284. status: 400,
  285. headers: { 'Content-Type': 'application/json' },
  286. }
  287. );
  288. }
  289. const amount = parseFloat(amountStr);
  290. if (isNaN(amount)) {
  291. return new Response(
  292. JSON.stringify({ error: 'Invalid amount. Must be a number.' }),
  293. {
  294. status: 400,
  295. headers: { 'Content-Type': 'application/json' },
  296. }
  297. );
  298. }
  299. const result = await convertCurrency(env, from, to, amount);
  300. return new Response(JSON.stringify(result, null, 2), {
  301. headers: { 'Content-Type': 'application/json' },
  302. });
  303. }
  304. // Rates endpoint - get all rates from Notion database
  305. if (url.pathname === '/rates') {
  306. const base = url.searchParams.get('base') || 'USD';
  307. try {
  308. const ratesData = await getRatesFromNotion(env);
  309. // If base is USD, return as-is
  310. if (base.toUpperCase() === 'USD') {
  311. return new Response(
  312. JSON.stringify(ratesData, null, 2),
  313. { headers: { 'Content-Type': 'application/json' } }
  314. );
  315. }
  316. // Otherwise, recalculate rates for the new base
  317. const baseRate = ratesData.rates[base.toUpperCase()];
  318. if (!baseRate) {
  319. return new Response(
  320. JSON.stringify({ error: `Unknown currency: ${base}` }),
  321. { status: 400, headers: { 'Content-Type': 'application/json' } }
  322. );
  323. }
  324. const convertedRates: Record<string, number> = {};
  325. for (const [currency, rate] of Object.entries(ratesData.rates)) {
  326. convertedRates[currency] = rate / baseRate;
  327. }
  328. convertedRates['USD'] = 1 / baseRate;
  329. return new Response(
  330. JSON.stringify(
  331. {
  332. base: base.toUpperCase(),
  333. timestamp: ratesData.timestamp,
  334. rates: convertedRates,
  335. },
  336. null,
  337. 2
  338. ),
  339. { headers: { 'Content-Type': 'application/json' } }
  340. );
  341. } catch (error) {
  342. const errorMessage = error instanceof Error ? error.message : String(error);
  343. return new Response(
  344. JSON.stringify({ error: errorMessage }),
  345. { status: 500, headers: { 'Content-Type': 'application/json' } }
  346. );
  347. }
  348. }
  349. // Home page
  350. return new Response(
  351. `Notion Exchange Rate Worker
  352. API Endpoints:
  353. GET /convert?from=USD&to=CNY&amount=100 - Convert currency (data from Notion)
  354. GET /rates?base=USD - Get exchange rates (data from Notion)
  355. GET /sync - Sync rates from OXR to Notion`,
  356. { headers: { 'Content-Type': 'text/plain' } }
  357. );
  358. },
  359. async scheduled(event, env, ctx): Promise<void> {
  360. console.log(`Exchange rate sync started at ${event.cron}`);
  361. await syncExchangeRates(env);
  362. },
  363. } satisfies ExportedHandler<Env>;