Skip to content

feat: Google Drive tab on upload form#2306

Merged
aalemayhu merged 2 commits into
mainfrom
feat/google-drive-upload-tab
May 15, 2026
Merged

feat: Google Drive tab on upload form#2306
aalemayhu merged 2 commits into
mainfrom
feat/google-drive-upload-tab

Conversation

@aalemayhu
Copy link
Copy Markdown
Contributor

@aalemayhu aalemayhu commented May 15, 2026

What

Adds a third "Google Drive" tab to the upload form alongside "Your computer" and "Dropbox". Clicking "Choose from Google Drive" opens the Google Picker, lets the user pick one file, and POSTs the picked file metadata plus an OAuth bearer token to the existing POST /api/upload/google_drive endpoint. The same conversion + downloads flow that Dropbox uses runs from there.

The tab is gated on REACT_APP_GOOGLE_CLIENT_ID and REACT_APP_GOOGLE_API_KEY. When either is missing, the tab is hidden — deploys without the env vars degrade gracefully to the two-tab layout. The prod build needs both env vars set before users will see the new tab.

Why

The google_drive_uploads table has been around since August 2024 but nothing in the web app has populated it for a while. #2305 added a "From Google Drive" section on Downloads — without this PR that section sits empty forever. Drive is the largest unaddressed file source for student users (Notion + Dropbox + Drive ≈ all of them), so closing the loop is the single biggest conversion-surface gap on the upload form.

How

  • New useGooglePicker hook (web/src/pages/UploadPage/components/UploadForm/hooks/useGooglePicker.ts) — lazy-loads apis.google.com/js/api.js (gapi/Picker) and accounts.google.com/gsi/client (Google Identity Services token client) on demand. No npm package — the official scripts are the only sanctioned path Google supports for Picker, and the equivalent npm packages pull in heavy server-side SDKs that are wrong for the browser.
  • OAuth scope: https://www.googleapis.com/auth/drive.file — Google's narrowest Picker scope. Per-file authorization (only the file the user picked is readable by our OAuth client); cannot enumerate the user's other Drive files. This was an active trio resolution — PM wanted drive.file, engineer initially said drive.readonly. Drive.file wins because Picker authorizes those specific files for us and the existing server-side Authorization: Bearer download call still works.
  • Concrete gotcha: Google Identity Services' requestAccessToken callback is fire-and-forget — it does not return a Promise. The Picker must be opened inside that callback or it races the token init and silently no-ops. The hook does this.
  • Added the third tab to UploadSourceTabs.tsx (new entrant at the end, per designer call).
  • Wired handleGoogleDriveClick + handleGoogleDriveFiles in UploadForm.tsx mirroring the Dropbox handlers. POSTs files (JSON-stringified GoogleDriveFile[]) + googleDriveAuth (bearer) — the exact body shape src/controllers/Upload/helpers/handleGoogleDrive.ts expects.
  • Reused all existing conversion states (converting / success / emptyDeck / error / limitReached). The converting status line now reads "Fetching {filename} from Google Drive" when Drive is the source — same pattern as Dropbox.
  • Designer pass: monochrome currentColor Drive triangle glyph (no full-color Google mark — full-color marks have stricter brand-guideline rules and fight the Stripe-class restraint baseline).

Measuring success

Leading indicator: Drive-sourced conversions per week. Phase-2 gate: 25+ successful Drive uploads in the first 14 days post-launch unlocks investment in folder-pick + multi-file. Below 10 → leave as-is. Secondary: rows in google_drive_uploads start increasing immediately after deploy, which surfaces the "From Google Drive" history section on /downloads for real users.

Testing

  • New useGooglePicker.test.ts — 5 tests (loading-state guards, picked-files outcome with access token, cancelled outcome, token-error rejection, missing-env-var rejection). Mocks window.gapi, window.google.picker, and window.google.accounts.oauth2 to drive the callbacks deterministically without loading the real scripts.
  • Existing UploadForm.test.tsx extended with 2 tests for tab visibility (Drive tab appears iff both env vars are present).
  • /check: server tsc clean, web typecheck clean, 483 Vitest tests pass (was 481 before; +5 hook tests, +2 form tests minus 2 reused existing setup), Biome lint clean.

Risks

  • Picker UX gate: this depends on users granting drive.file scope inline. If they decline, the token callback gets error: 'access_denied' and we surface "Couldn't reach Google Drive. Sign in again and retry." Worth watching for the first week.
  • Script load failure: the two Google scripts come from apis.google.com and accounts.google.com. Script blockers or corporate proxies will fail to load them and the user sees "Couldn't load Google Drive. Check your connection or disable script blockers and try again." Same behavior as the Dropbox tab on a Dropbox-blocked network.
  • Rollback: removing this PR removes the tab. The server endpoint, repository, and migration stay — they predated this PR and serve the history section in feat: Google Drive upload history #2305.

Out of scope (deferred to phase 2)

  • Multi-file batch upload (Picker supports it behind a flag; defer until Bump websocket-extensions from 0.1.3 to 0.1.4 #25-in-14-days gate clears).
  • Folder pick / recursive (Drive folders).
  • Paywall/quota wiring beyond what /api/upload/google_drive already enforces.

Sonar

Sonar scanner not run locally (token not configured in this environment) — flagging for reviewers so a Sonar bounce on the new hook isn't a surprise. The hook has nested callbacks which can trigger cognitive complexity warnings.

Goal alignment

  • Simpler/faster/more beautiful: users studying from Drive-hosted PDFs or Docs skip the download-then-reupload round trip.
  • Scale-to-300K: Drive is the largest unaddressed file source on the upload form; closing it unblocks an entire student population (Notion / Dropbox / Drive covers ~all of them).

Trio synthesis
  • PM: Ship now. Drive tab parity with Dropbox; logged-in users only; PR-1 = single file, no folder pick. Leading indicator: 25+ Drive uploads / 14 days.
  • Designer: Tab order Your computer / Dropbox / Google Drive (new entrant at end). "Pick a file from your Google Drive to convert it into a deck" + "Choose from Google Drive" + "Fetching {filename} from Google Drive". Monochrome Drive glyph in currentColor — no full-color Google mark.
  • Engineer: S effort. 5 files. Dynamic <script> tags for both Google scripts (no npm pkg). Gotcha: open Picker inside the token callback. Hook unit tests mock both globals.
  • Conflict: PM said drive.file scope; Engineer said drive.readonly. Resolution: drive.file — narrower, Google-recommended for Picker workflows, server-side download still works because Picker authorizes those specific files for the OAuth client.

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

aalemayhu and others added 2 commits May 16, 2026 00:29
Adds a third "Google Drive" tab to the upload form alongside "Your computer"
and "Dropbox". Clicking "Choose from Google Drive" opens the Google Picker
(loaded on demand from Google's CDN — no npm dep), requests an access token
scoped to drive.file (per-file authorization, narrowest possible), and POSTs
the picked file + bearer token to the existing POST /api/upload/google_drive
endpoint. The same conversion + downloads flow as Dropbox runs from there.

The new useGooglePicker hook lazy-loads both apis.google.com/js/api.js
(gapi/Picker) and accounts.google.com/gsi/client (Google Identity Services
token model), then opens the Picker inside the token callback — opening it
outside would race the token initialization and silently fail.

Tab is gated on REACT_APP_GOOGLE_CLIENT_ID and REACT_APP_GOOGLE_API_KEY being
present. When either is missing the tab is hidden, so deploys without the
env vars degrade gracefully to the existing two-tab layout. Server side
needs no changes — the endpoint, repository, and migration shipped in #2300
and #2305.

Closes the empty "From Google Drive" section on Downloads that #2305 added —
without this PR, nothing populates the google_drive_uploads table from the
web app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every user-visible PR ships its What's New line in the same PR so the page
is current the moment the feature lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@blacksmith-sh
Copy link
Copy Markdown

blacksmith-sh Bot commented May 15, 2026

Autofix enabled on this PR. I'll automatically:

  • Fix CI failures
  • Address review comments from bots
  • Resolve merge conflicts

To disable, uncheck the autofix checkbox in the PR description or comment @codesmith !autofix off.

View in Codesmith

@aalemayhu aalemayhu merged commit 9c06af5 into main May 15, 2026
5 checks passed
@aalemayhu aalemayhu deleted the feat/google-drive-upload-tab branch May 15, 2026 22:33
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
7.3% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

aalemayhu added a commit that referenced this pull request May 15, 2026
## What
Adds \`https://apis.google.com\` and \`https://accounts.google.com\` to
the \`script-src\` directive of the CSP meta tag in \`web/index.html\`.
Required for the Google Drive upload tab from #2306 to function.

## Why
Browser console on prod after deploying #2306 showed:

\`\`\`
Loading the script 'https://apis.google.com/js/api.js' violates the
following Content Security Policy directive: "script-src 'self'
'unsafe-inline' https://www.googletagmanager.com
https://www.google-analytics.com https://static.hotjar.com
https://script.hotjar.com https://www.dropbox.com"
\`\`\`

Same violation for \`accounts.google.com/gsi/client\`. Both are required
by the Picker / GIS flow that \`useGooglePicker\` lazy-loads.

## How
One-line addition to the existing CSP meta tag's \`script-src\`. No
other directives needed — frame/connect/img directives are not set on
this site, so the Picker iframe and XHR calls were never blocked.

## Testing
- Local: rebuild + smoke-test the upload form. Tab opens the Picker.
- Prod (after rebuild): browser console shows no CSP violations for the
two Google origins.

## Risks
- Two new third-party script origins in script-src — both are
Google-owned and serve the official Picker SDK; this is the same trust
boundary as the existing Dropbox SDK allowance (\`www.dropbox.com\`).
- Rollback: revert this commit. The CSP returns to its prior shape; the
Drive tab silently fails to load Picker, same as before this fix.

## Goal alignment
Unblocks #2306, which closes the empty "From Google Drive" history
section #2305 introduced.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/2anki/codesmith/server/pr/2307"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-light.svg"><img
alt="View in Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith</code> with what you
need.</sup>

- [ ] Let Codesmith autofix CI failures and bot reviews
<!-- /codesmith:footer -->

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
aalemayhu added a commit that referenced this pull request May 15, 2026
## What
Adds \`.setAppId(projectNumber)\` to the Picker builder chain in
\`useGooglePicker.ts\`. \`projectNumber\` is derived at runtime from the
existing \`REACT_APP_GOOGLE_CLIENT_ID\` (numeric prefix of the client
ID), so no new env var is needed.

## Why
With the \`drive.file\` OAuth scope (the narrowest, picked-files-only
scope we chose in #2306), Google Picker only binds the picked file to
*our* OAuth client when \`setAppId\` is called. Without it, the user can
pick a file in the UI but the server's \`GET
https://www.googleapis.com/drive/v3/files/<id>?alt=media\` with the
bearer token returns **404 File not found**. Every Drive pick on prod
was failing with "Error handling Google Drive files" until this fix.

Verified on prod logs:
\`\`\`
2|server   | google drive upload success
2|server   | POST /api/upload/google_drive 400
...
status: 404, message: "File not found:
1Gxh9TSApii4ErELl0jePIGUVMldsgSRe"
\`\`\`

## How
- One-line change in the builder chain: \`.setAppId(projectNumber)\`
between the constructor and the rest of the chain.
- \`projectNumber\` is derived as \`clientId().split('-')[0]\` — the
OAuth client ID has the shape
\`<projectNumber>-<hash>.apps.googleusercontent.com\`. Engineer trio
call: derive instead of adding a third env var, because two vars that
must stay in sync is a deployment footgun.
- Test mock updated with a \`setAppId\` stub. Added an assertion that
the derived project number reaches \`setAppId\`.
- Changelog entry added for the user-visible fix.

## Testing
- 6/6 \`useGooglePicker\` unit tests pass (was 5; +1 for the setAppId
assertion).
- \`/check\`: server tsc clean, web typecheck clean, 484 Vitest tests
pass, Biome lint clean.

## Risks
- Rollback: revert this commit. The tab returns to the broken state from
before this PR, no other surface affected.
- The \`drive.file\` scope still requires the Picker API enabled on the
GCP project (already enabled — Picker UI opens on prod).

## Trio synthesis
- **PM:** Ship the 1-line fix now. Severity high — 100% of Drive clicks
fail post-#2306. Forward fix is cleaner than rolling back.
- **Designer:** Hide tab if env vars missing. No copy changes (generic
error state is fine for a hotfix).
- **Engineer:** Derive project number from the OAuth client ID; no new
env var. Insert \`setAppId\` in the chain. Mock needs the stub.
- **Conflict:** PM and Designer both assumed a new env var
\`REACT_APP_GOOGLE_PROJECT_NUMBER\`. Engineer pointed out it's
derivable. **Resolution:** derive it, no new env var, no drift risk.

## Goal alignment
Closes the deploy-blocking bug from #2306 so the Drive tab actually
works for the user base that needs it. Critical for the 300K-user goal —
without this fix, the Drive tab is visible but every click fails.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/2anki/codesmith/server/pr/2308"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-light.svg"><img
alt="View in Codesmith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-in-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith</code> with what you
need.</sup>

- [ ] Let Codesmith autofix CI failures and bot reviews
<!-- /codesmith:footer -->

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant