this repo has no description
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;