this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 178 lines 5.8 kB view raw
1import { JWT } from 'google-auth-library'; 2import { Hono } from 'hono'; 3 4export interface Env { 5 LIST_CACHE: KVNamespace; 6 API_KEY: string; 7 SHEET_ID: string; 8 SA_EMAIL: string; 9 SA_PRIVATE_KEY: string; 10 TX_RANGE: string; 11 PURPOSE_TAB: string; 12 ACCOUNT_TAB: string; 13} 14 15interface AddRequestBody { 16 date: string; 17 amount: number; 18 currency: string; 19 description: string; 20 purpose: string; 21 account: string; 22} 23 24const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; 25const LIST_CACHE_KEY = 'lists-v1'; 26const TOKEN_CACHE_KEY = 'token-v1'; 27 28let jwtClient: JWT | null = null; 29 30async function accessToken(env: Env): Promise<string> { 31 const now = Date.now(); 32 // Try KV cache 33 const cached = await env.LIST_CACHE.get<{ token: string; expiry: number }>(TOKEN_CACHE_KEY, { type: 'json' }); 34 if (cached && now < cached.expiry - 60000) { 35 return cached.token; 36 } 37 // Initialize client if needed 38 jwtClient ??= new JWT({ 39 email: env.SA_EMAIL, 40 key: env.SA_PRIVATE_KEY.replace(/\\n/g, '\n'), 41 scopes: [SCOPE], 42 }); 43 await jwtClient.authorize(); 44 const token = jwtClient.credentials.access_token; 45 const expiry = jwtClient.credentials.expiry_date ?? now + 3600 * 1000; 46 if (!token) throw new Error('Failed to obtain access token'); 47 // Store in KV with TTL 48 const ttl = Math.max(Math.floor((expiry - now) / 1000), 1); 49 await env.LIST_CACHE.put(TOKEN_CACHE_KEY, JSON.stringify({ token, expiry }), { 50 expirationTtl: ttl, 51 }); 52 return token; 53} 54 55async function batchGet(ranges: string[], env: Env): Promise<string[][]> { 56 const token = await accessToken(env); 57 const q = ranges.map((r) => `ranges=${encodeURIComponent(r)}`).join('&'); 58 const res = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values:batchGet?${q}`, { 59 headers: { Authorization: `Bearer ${token}` }, 60 }); 61 if (!res.ok) { 62 const errorText = await res.text(); 63 throw new Error(`Sheets batchGet failed: ${res.status} ${res.statusText} - ${errorText}`); 64 } 65 const { valueRanges } = await res.json<{ valueRanges?: { values?: string[][] }[] }>(); 66 return valueRanges?.map((v) => v.values?.flat().filter((s) => s.trim() !== '') ?? []) ?? ranges.map(() => []); 67} 68 69async function appendRow(cells: (string | number)[], env: Env): Promise<void> { 70 const token = await accessToken(env); 71 const res = await fetch( 72 `https://sheets.googleapis.com/v4/spreadsheets/${env.SHEET_ID}/values/${encodeURIComponent(env.TX_RANGE)}:append?valueInputOption=USER_ENTERED`, 73 { 74 method: 'POST', 75 headers: { 76 Authorization: `Bearer ${token}`, 77 'Content-Type': 'application/json', 78 }, 79 body: JSON.stringify({ values: [cells] }), 80 }, 81 ); 82 if (!res.ok) { 83 const errorText = await res.text(); 84 throw new Error(`Append failed: ${res.status} ${res.statusText} - ${errorText}`); 85 } 86} 87 88const app = new Hono<{ Bindings: Env }>(); 89 90app.use('*', async (c, next) => { 91 const { API_KEY, SHEET_ID, TX_RANGE, PURPOSE_TAB, ACCOUNT_TAB, SA_EMAIL, SA_PRIVATE_KEY } = c.env; 92 if (![API_KEY, SHEET_ID, TX_RANGE, PURPOSE_TAB, ACCOUNT_TAB, SA_EMAIL, SA_PRIVATE_KEY].every(Boolean)) { 93 return c.json({ status: 'ERROR', message: 'Server misconfigured' }, 500); 94 } 95 const { pathname, searchParams } = new URL(c.req.url); 96 if (pathname !== '/' && searchParams.get('key') !== API_KEY) { 97 return c.json({ status: 'ERROR', message: 'Forbidden' }, 403); 98 } 99 await next(); 100}); 101 102app.get('/lists', async (c) => { 103 const env = c.env; 104 try { 105 const cached = await env.LIST_CACHE.get(LIST_CACHE_KEY, { type: 'json' }); 106 if (cached) { 107 return c.json(cached); 108 } 109 110 const [purposesData, accountsData] = await batchGet([`${env.PURPOSE_TAB}!A2:A`, `${env.ACCOUNT_TAB}!A2:A`], env); 111 const payload = { purposes: purposesData, accounts: accountsData }; 112 113 c.executionCtx.waitUntil( 114 env.LIST_CACHE.put(LIST_CACHE_KEY, JSON.stringify(payload), { 115 expirationTtl: 86400, 116 }), 117 ); 118 return c.json(payload); 119 } catch (err) { 120 const message = err instanceof Error ? err.message : String(err); 121 return c.json({ status: 'ERROR', message: 'Failed to fetch lists.', detail: message }, 500); 122 } 123}); 124 125app.post('/add', async (c) => { 126 const env = c.env; 127 try { 128 const body = await c.req.json<AddRequestBody>(); 129 130 const { amount, currency, description, purpose, account } = body; 131 const date = new Date(body.date).toISOString().split('T')[0]; 132 133 if (!date || !amount || !currency || !description || !purpose || !account) { 134 return c.json( 135 { 136 status: 'ERROR', 137 message: 'Missing required fields in request body.', 138 }, 139 400, 140 ); 141 } 142 143 await appendRow([date, amount, currency, description, purpose, account], env); 144 return c.json({ status: 'OK', message: 'Transaction added successfully.' }); 145 } catch (err) { 146 const message = err instanceof Error ? err.message : String(err); 147 return c.json( 148 { 149 status: 'ERROR', 150 message: 'Failed to add transaction.', 151 detail: message, 152 }, 153 500, 154 ); 155 } 156}); 157 158app.post('/flush-cache', async (c) => { 159 const env = c.env; 160 try { 161 await env.LIST_CACHE.delete(LIST_CACHE_KEY); 162 return c.json({ 163 status: 'OK', 164 message: `Cache key '${LIST_CACHE_KEY}' flushed successfully.`, 165 }); 166 } catch (err) { 167 const message = err instanceof Error ? err.message : String(err); 168 return c.json({ status: 'ERROR', message: 'Failed to flush cache.', detail: message }, 500); 169 } 170}); 171 172app.get('/', (c) => c.text('Budget Edge Worker is running!')); 173 174app.notFound((c) => c.json({ status: 'ERROR', message: 'Not Found' }, 404)); 175 176app.onError((err, c) => c.json({ status: 'ERROR', message: 'Internal Server Error', detail: err.message }, 500)); 177 178export default app;