feat: add Gmail API expense loader with Google OAuth#4
Conversation
- Add Google social provider to better-auth with gmail.readonly scope - Store per-user Google refresh token via better-auth account table - Create Gmail API-based loader (replaces IMAP TCP dependency) - Add Google sign-in button alongside GitHub - Wire load expenses button to new Gmail API loader
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
5 issues found across 6 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/routes/login.tsx">
<violation number="1" location="src/routes/login.tsx:31">
P1: No error handling in social sign-in onClick. If authClient.signIn.social rejects (network, popup blocked, etc.), setIsLoading(false) is never called — button stays disabled forever, user must refresh.</violation>
</file>
<file name="src/server-fns/load-expenses-gmail-api.ts">
<violation number="1" location="src/server-fns/load-expenses-gmail-api.ts:270">
P2: Loader only fetches the first page of Gmail message IDs, so matching emails after the first 100 are never processed.</violation>
<violation number="2" location="src/server-fns/load-expenses-gmail-api.ts:353">
P1: When two users share a payee UPI ID, `persistPayees`'s upsert updates the existing row's name but NOT its `user_id`. Then `fetchPayeeIdMap(user.id)` filters by the current user's ID and won't find that payee, so `payeeIdMap.get()` returns undefined → `?? 0` substitutes 0 as payee_id. Since `payee.id` is a serial starting at 1 and `expense.payee_id` has a FK constraint, this will throw a PostgreSQL foreign key violation.</violation>
<violation number="3" location="src/server-fns/load-expenses-gmail-api.ts:380">
P3: Typo: "up-to date" missing hyphen in user-facing message.</violation>
<violation number="4" location="src/server-fns/load-expenses-gmail-api.ts:406">
P0: No full-load code path. When `getLatestExpenseDate` returns null (first-time user with no expenses), the handler returns early with `"Please do a full load"` — but this IS the only load handler. There's no fallback date or alternate path to proceed, so first-time users can never use this API.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| } | ||
|
|
||
| const latestDate = await getLatestExpenseDate(user.id); | ||
| if (!latestDate) { |
There was a problem hiding this comment.
P0: No full-load code path. When getLatestExpenseDate returns null (first-time user with no expenses), the handler returns early with "Please do a full load" — but this IS the only load handler. There's no fallback date or alternate path to proceed, so first-time users can never use this API.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/server-fns/load-expenses-gmail-api.ts, line 406:
<comment>No full-load code path. When `getLatestExpenseDate` returns null (first-time user with no expenses), the handler returns early with `"Please do a full load"` — but this IS the only load handler. There's no fallback date or alternate path to proceed, so first-time users can never use this API.</comment>
<file context>
@@ -0,0 +1,431 @@
+ }
+
+ const latestDate = await getLatestExpenseDate(user.id);
+ if (!latestDate) {
+ return { message: "No expenses added yet. Please do a full load" };
+ }
</file context>
| className="w-60" | ||
| size="lg" | ||
| disabled={isLoading} | ||
| onClick={async () => { |
There was a problem hiding this comment.
P1: No error handling in social sign-in onClick. If authClient.signIn.social rejects (network, popup blocked, etc.), setIsLoading(false) is never called — button stays disabled forever, user must refresh.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/routes/login.tsx, line 31:
<comment>No error handling in social sign-in onClick. If authClient.signIn.social rejects (network, popup blocked, etc.), setIsLoading(false) is never called — button stays disabled forever, user must refresh.</comment>
<file context>
@@ -2,40 +2,74 @@ import { SpinnerIcon } from "@phosphor-icons/react";
+ className="w-60"
+ size="lg"
+ disabled={isLoading}
+ onClick={async () => {
+ setIsLoading(true);
+ await authClient.signIn.social({
</file context>
| sender_upi_id: mail.sender_upi_id, | ||
| amount: mail.amount, | ||
| transaction_date: mail.transaction_date, | ||
| payee_id: payeeIdMap.get(mail.payee_upi_id) ?? 0, |
There was a problem hiding this comment.
P1: When two users share a payee UPI ID, persistPayees's upsert updates the existing row's name but NOT its user_id. Then fetchPayeeIdMap(user.id) filters by the current user's ID and won't find that payee, so payeeIdMap.get() returns undefined → ?? 0 substitutes 0 as payee_id. Since payee.id is a serial starting at 1 and expense.payee_id has a FK constraint, this will throw a PostgreSQL foreign key violation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/server-fns/load-expenses-gmail-api.ts, line 353:
<comment>When two users share a payee UPI ID, `persistPayees`'s upsert updates the existing row's name but NOT its `user_id`. Then `fetchPayeeIdMap(user.id)` filters by the current user's ID and won't find that payee, so `payeeIdMap.get()` returns undefined → `?? 0` substitutes 0 as payee_id. Since `payee.id` is a serial starting at 1 and `expense.payee_id` has a FK constraint, this will throw a PostgreSQL foreign key violation.</comment>
<file context>
@@ -0,0 +1,431 @@
+ sender_upi_id: mail.sender_upi_id,
+ amount: mail.amount,
+ transaction_date: mail.transaction_date,
+ payee_id: payeeIdMap.get(mail.payee_upi_id) ?? 0,
+ user_id: userId,
+ }));
</file context>
| const query = buildGmailSearchQuery(afterSeconds); | ||
| const url = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages"); | ||
| url.searchParams.set("q", query); | ||
| url.searchParams.set("maxResults", "100"); |
There was a problem hiding this comment.
P2: Loader only fetches the first page of Gmail message IDs, so matching emails after the first 100 are never processed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/server-fns/load-expenses-gmail-api.ts, line 270:
<comment>Loader only fetches the first page of Gmail message IDs, so matching emails after the first 100 are never processed.</comment>
<file context>
@@ -0,0 +1,431 @@
+ const query = buildGmailSearchQuery(afterSeconds);
+ const url = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
+ url.searchParams.set("q", query);
+ url.searchParams.set("maxResults", "100");
+
+ const res = await fetch(url, {
</file context>
|
|
||
| function buildLoadResult(insertedRowCount: number): LoadResult { | ||
| if (insertedRowCount === 0) { | ||
| return { message: "Data up-to date" }; |
There was a problem hiding this comment.
P3: Typo: "up-to date" missing hyphen in user-facing message.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/server-fns/load-expenses-gmail-api.ts, line 380:
<comment>Typo: "up-to date" missing hyphen in user-facing message.</comment>
<file context>
@@ -0,0 +1,431 @@
+
+function buildLoadResult(insertedRowCount: number): LoadResult {
+ if (insertedRowCount === 0) {
+ return { message: "Data up-to date" };
+ }
+
</file context>
Summary by cubic
Add a Gmail API–based expense loader with Google OAuth, replacing the IMAP approach. Users can now link Google and load expenses directly from Gmail.
New Features
better-authwith offline access andgmail.readonly; per-user refresh tokens stored.load-expenses-gmail-apiserver fn: fetches mails after last expense, parses UPI notifications, upserts payees, inserts expenses; negates amounts for “paid to me” usingIDS.CHECK_MAIL.Migration
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,CHECK_MAIL(sender),IDS(comma-separated UPI IDs).better-auth; ensure offline access is allowed.Written for commit 6879468. Summary will update on new commits.