Skip to content
Merged
158 changes: 158 additions & 0 deletions docs/analytics/phase7-google-drive-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Phase 7: Google Drive Integration for Analytics Exports

## Overview

This phase adds Google Drive integration so users can save analytics exports (CSV, Google Sheets, JSON) directly to their Google Drive. The integration is **separate from the login OAuth flow** — users connect Google Drive from Settings → Integrations after logging in.

## Architecture

```
User logs in (any method)
→ Settings → Integrations → "Connect Google Drive"
→ Backend generates Google OAuth URL with drive.file scope
→ User grants permission on Google consent screen
→ Google redirects to /api/google-drive/callback
→ Backend exchanges code for tokens, encrypts & stores in MongoDB
→ User redirected back to settings with "Connected" status

User goes to Analytics page
→ Clicks "Save to Drive" button
→ Backend fetches valid access token (refreshes if expired)
→ Uploads CSV/JSON to user's Google Drive via googleapis
→ Returns file link → user sees success banner with "Open" link
```

## Why Separate from Supabase OAuth?

The existing `loginWithGoogleAndCalendar()` uses `supabase.auth.signInWithOAuth` — that's a **login** flow that creates/links a Supabase session. For Google Drive, we need a **separate authorization** that:

- Doesn't touch the user's Supabase auth session
- Only requests `drive.file` scope (create/manage files your app creates)
- Returns tokens the backend can use server-side
- Can be connected/disconnected independently

## New Files

### 1. `server/models/GoogleDriveToken.js`

Mongoose model for storing encrypted Google OAuth tokens per user.

| Field | Type | Description |
|---|---|---|
| `ownerUid` | String (unique, indexed) | Supabase user ID |
| `googleEmail` | String | User's Google email (from userinfo API) |
| `accessToken` | Mixed (encrypted) | Encrypted Google access token |
| `refreshToken` | Mixed (encrypted) | Encrypted Google refresh token |
| `tokenExpiry` | Date | Access token expiration time |
| `connectedAt` | Date | When user connected Drive |

Encryption uses the existing `server/utils/crypto.js` (AES-256-GCM with `ENCRYPTION_KEY` env var).

### 2. `server/utils/googleDriveOAuth.js`

OAuth utility functions:

| Function | Description |
|---|---|
| `getAuthUrl(state)` | Generates Google OAuth URL with `drive.file` scope, `access_type: 'offline'`, `prompt: 'consent'` |
| `exchangeCodeForTokens(code)` | Exchanges authorization code for access/refresh tokens |
| `storeTokens(ownerUid, tokens)` | Encrypts and stores tokens in MongoDB, fetches user email |
| `getValidAccessToken(ownerUid)` | Returns valid access token — auto-refreshes if expired using stored refresh token |
| `revokeTokens(ownerUid)` | Revokes Google tokens server-side + deletes from MongoDB |
| `getDriveStatus(ownerUid)` | Returns `{ connected, email, connectedAt }` |

### 3. `server/utils/googleDriveUpload.js`

Drive upload utility functions:

| Function | Description |
|---|---|
| `uploadCSVToDrive(ownerUid, csvContent, filename, convertToSheet)` | Uploads CSV to Drive, optionally converts to Google Sheets format |
| `uploadJSONToDrive(ownerUid, jsonContent, filename)` | Uploads JSON file to Drive |

Uses `googleapis` drive v3 API with `Readable.from([content])` streams. For Sheets conversion, sets `mimeType: 'application/vnd.google-apps.spreadsheet'` on the file metadata.

## New API Endpoints

### OAuth Flow

| Endpoint | Method | Auth | Description |
|---|---|---|---|
| `/api/google-drive/auth` | GET | verifyToken | Returns `{ authUrl }` — frontend redirects user to Google consent |
| `/api/google-drive/callback` | GET | None (Google redirect) | Exchanges code for tokens, stores them, redirects to frontend |
| `/api/google-drive/status` | GET | verifyToken | Returns `{ connected, email }` for current user |
| `/api/google-drive/disconnect` | DELETE | verifyToken | Revokes tokens + deletes from DB |

### Export to Drive

| Endpoint | Method | Auth | Description |
|---|---|---|---|
| `/api/analytics/export/overview/drive` | POST | verifyToken | Uploads overview analytics as CSV (→ Google Sheets) or JSON |
| `/api/analytics/templates/:draftId/export/drive` | POST | verifyToken | Uploads template detail + submissions as CSV (→ Google Sheets) or JSON |

Request body: `{ format: 'csv' | 'json', convertToSheet: boolean }`

Response: `{ success: true, id, name, webViewLink }`

## Frontend Changes

### `src/settings/IntegrationsSection.jsx`

Added a **Google Drive** integration card (between Google Calendar and Cloudinary):

- "Connect Google Drive" button → calls `/api/google-drive/auth`, redirects to Google OAuth URL
- "Disconnect Drive" button → calls `/api/google-drive/disconnect`
- Status check via `/api/google-drive/status` on mount
- Shows connected Google email when connected

### `src/pages/AnalyticsPage.jsx`

- Added "Save to Drive" button next to "Sync Data" in the header
- `handleDriveExport()` calls `/api/analytics/export/overview/drive`
- Shows success banner with file name and "Open" link to Google Drive
- Shows error banner if Drive not connected or upload fails

### `src/pages/TemplateDetailAnalytics.jsx`

- Added "Save to Drive" button in the detail header
- `handleDriveExport()` calls `/api/analytics/templates/:draftId/export/drive`
- Same success/error banner pattern

### `src/pages/AnalyticsPage.css`

- `.analytics-drive-btn` — green-themed button matching Drive branding
- `.analytics-detail-actions` — flex container for detail header buttons
- `.analytics-drive-link` — inline link for "Open" in result banner

## Environment Variables

| Variable | Required | Description |
|---|---|---|
| `GOOGLE_CLIENT_ID` | Yes | Already exists for Gmail OAuth — reused for Drive |
| `GOOGLE_CLIENT_SECRET` | Yes | Already exists for Gmail OAuth — reused for Drive |
| `GOOGLE_DRIVE_REDIRECT_URI` | No | Defaults to `{SERVER_URL}/api/google-drive/callback`. Set explicitly in production. |
| `FRONTEND_URL` | No | Defaults to `http://localhost:5173`. Set to production URL for OAuth callback redirect. |
| `ENCRYPTION_KEY` | Yes | Already exists — used for encrypting Google Drive tokens |

## Google Cloud Console Setup

1. Go to **Google Cloud Console → APIs & Services → OAuth consent screen**
2. Add scope: `https://www.googleapis.com/auth/drive.file`
3. Go to **Credentials → OAuth 2.0 Client IDs**
4. Add authorized redirect URI: `https://yourdomain.com/api/google-drive/callback`
5. Enable **Google Drive API** in the API library

## RAM Impact

- `googleapis` is already lazy-loaded (Phase 6 optimization) — no additional startup RAM
- Drive API reuses the same `googleapis` package — ~0 additional RAM
- Token storage uses existing MongoDB connection — no new connections
- Upload uses `Readable.from([content])` streams — minimal memory overhead

## Security

- Tokens encrypted with AES-256-GCM before storage
- `drive.file` scope is non-sensitive — only allows app to manage files it creates
- OAuth state parameter prevents CSRF attacks
- Token revocation on disconnect
- No tokens exposed to frontend — only auth URL and status are returned
102 changes: 102 additions & 0 deletions docs/connections/01-auth-login-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 01: Authentication & Login Flow

## Feature Summary

User logs in via Google OAuth, GitHub OAuth, or Email/Password. Supabase issues a JWT that persists in the browser. The JWT is sent as a Bearer token on every API call. The backend verifies it via Supabase admin SDK.

## ASCII Flow Diagram

```
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (Login page) │
│ │
│ User clicks "Sign in with Google" / "GitHub" / "Email" │
│ │ │
│ ┌──────┴──────┐──────────────────┐──────────────────┐ │
│ ▼ ▼ ▼ │ │
│ loginWithGoogleAndCalendar() loginWithGitHub() loginWithEmail()│
│ └─ supabase.auth.signInWithOAuth({ provider, redirectTo }) │
│ └─ Browser redirects to Google/GitHub consent screen │
│ └─ After consent, redirects back to /dashboard │
│ └─ Supabase SDK stores session in localStorage │
│ │
│ Email path: supabase.auth.signInWithPassword({ email, password })│
│ └─ Returns { user, session } immediately (no redirect) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ APP.JSX — Auth State Listener │
│ │
│ onAuthChange((event, session) => { ... }) │
│ ├─ SIGNED_IN → setUser(session.user), setAuthReady(true) │
│ ├─ SIGNED_OUT → setUser(null), navigate to / │
│ └─ TOKEN_REFRESHED → session updated automatically │
│ │
│ ProtectedRoute checks: user && authReady │
│ └─ If false → redirect to / (login page) │
└─────────────────────────────────────────────────────────────────┘
▼ (on every API call)
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND API CALL │
│ │
│ const token = await getAuthToken(); │
│ └─ supabase.auth.getSession() → session.access_token │
│ │
│ fetch(`${API_BASE_URL}/api/...`, { │
│ headers: { Authorization: `Bearer ${token}` } │
│ }) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND — verifyToken middleware │
│ server/middleware/auth.js:7 │
│ │
│ ├─ Extract Bearer token from Authorization header │
│ ├─ supabaseAdmin.auth.getUser(token) │
│ │ └─ Validates JWT via Supabase service role │
│ ├─ If valid → req.user = { uid: user.id, email: user.email } │
│ ├─ If invalid → 401 Unauthorized │
│ └─ Dev bypass: if no Supabase env vars, mocks user │
└─────────────────────────────────────────────────────────────────┘
```

## File-by-File Trace

| Step | File | Lines | What Happens |
|------|------|-------|--------------|
| 1. Google login | `src/supabaseAuth.js` | 7-21 | `signInWithOAuth({ provider: 'google' })` |
| 2. GitHub login | `src/supabaseAuth.js` | 27-41 | `signInWithOAuth({ provider: 'github' })` |
| 3. Email login | `src/supabaseAuth.js` | 47-62 | `signInWithPassword({ email, password })` |
| 4. Supabase client | `src/supabaseClient.js` | 10-16 | `createClient` with `persistSession: true` |
| 5. Auth listener | `src/App.jsx` | — | `onAuthChange` sets user state |
| 6. Route guard | `src/components/ProtectedRoute.jsx` | — | Checks `user && authReady` |
| 7. Get JWT | `src/supabaseAuth.js` | 116-119 | `getAuthToken()` → `session.access_token` |
| 8. Verify JWT | `server/middleware/auth.js` | 7-34 | `supabaseAdmin.auth.getUser(token)` |
| 9. Supabase admin | `server/supabaseClient.js` | — | Service role key, bypasses RLS |

## Shared Dependencies

- **Supabase Auth** — issues JWT, manages session
- **localStorage** — persists session across refreshes (`persistSession: true`)
- **Supabase Admin SDK** — server-side JWT verification (service role key)

## Error Paths

| Scenario | What Happens |
|----------|-------------|
| Invalid/expired JWT | `verifyToken` returns 401, frontend gets error, user redirected to login |
| No Supabase env vars (dev) | `verifyToken` mocks user with `DEV_MOCK_UID_` prefix |
| Session expired | `autoRefreshToken: true` auto-refreshes before expiry |
| OAuth redirect fails | Supabase SDK shows error, user stays on login page |

## Environment Variables

| Variable | Where | Purpose |
|----------|-------|---------|
| `VITE_SUPABASE_URL` | Frontend | Supabase project URL |
| `VITE_SUPABASE_ANON_KEY` | Frontend | Supabase anon key (client-side) |
| `SUPABASE_SERVICE_ROLE_KEY` | Backend | Service role key (admin, bypasses RLS) |
| `SUPABASE_URL` | Backend | Supabase project URL |
87 changes: 87 additions & 0 deletions docs/connections/02-github-oauth-connect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# 02: GitHub OAuth Connect

## Feature Summary

User connects GitHub from Settings → Integrations. This uses Supabase's `signInWithOAuth` to link a GitHub identity to the existing Supabase user account. Unlike Google Drive (which uses a separate backend OAuth flow), GitHub connection goes entirely through Supabase's identity linking system.

## ASCII Flow Diagram

```
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (IntegrationsSection.jsx) │
│ │
│ Page loads → fetchIdentities() │
│ ├─ getUserIdentities() → supabase.auth.getUser() │
│ └─ Returns array of linked identities (e.g. ['github','google'])│
│ │
│ hasProvider('github') checks if GitHub identity exists │
│ ├─ YES → Show "Disconnect GitHub" button │
│ └─ NO → Show "Connect GitHub" button │
│ │
│ User clicks "Connect GitHub" │
│ └─ handleConnectGitHub() │
│ └─ loginWithGitHub() → supabase.auth.signInWithOAuth({ │
│ provider: 'github', │
│ options: { redirectTo: /dashboard } │
│ }) │
│ └─ Browser redirects to GitHub consent screen │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ GITHUB CONSENT SCREEN (external) │
│ │
│ User authorizes Leddger-AI app │
│ └─ GitHub redirects back to Supabase callback URL │
│ └─ Supabase links GitHub identity to existing user │
│ └─ Redirects to /dashboard │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ DISCONNECT FLOW │
│ │
│ User clicks "Disconnect GitHub" │
│ └─ handleDisconnect('github') │
│ ├─ confirm("Disconnect GitHub?") │
│ └─ unlinkProvider('github') │
│ └─ supabase.auth.unlinkIdentity({ provider: 'github' }) │
│ └─ Supabase removes GitHub identity from user │
│ └─ fetchIdentities() → refreshes UI │
└─────────────────────────────────────────────────────────────────┘
```

## File-by-File Trace

| Step | File | Lines | What Happens |
|------|------|-------|--------------|
| 1. Check status | `src/settings/IntegrationsSection.jsx` | 28-38 | `getUserIdentities()` on mount |
| 2. Has provider | `src/settings/IntegrationsSection.jsx` | 122 | `hasProvider('github')` |
| 3. Connect | `src/settings/IntegrationsSection.jsx` | 124-131 | `loginWithGitHub()` |
| 4. OAuth call | `src/supabaseAuth.js` | 27-41 | `signInWithOAuth({ provider: 'github' })` |
| 5. Disconnect | `src/settings/IntegrationsSection.jsx` | 80-97 | `unlinkProvider('github')` |
| 6. Unlink | `src/supabaseAuth.js` | 155-161 | `unlinkIdentity({ provider })` |

## Shared Dependencies

- **Supabase Auth** — handles OAuth flow and identity linking
- **No backend involvement** — GitHub connect/disconnect is entirely Supabase-side

## Error Paths

| Scenario | What Happens |
|----------|-------------|
| OAuth redirect fails | `loginWithGitHub()` throws, `showError()` toast displayed |
| Already linked | Supabase handles gracefully, identity already exists |
| Unlink fails | `unlinkProvider()` returns error, `showError()` toast |

## Environment Variables

| Variable | Where | Purpose |
|----------|-------|---------|
| `VITE_SUPABASE_URL` | Frontend | Supabase project URL |
| `VITE_SUPABASE_ANON_KEY` | Frontend | Supabase anon key |

## Key Difference from Google Drive

GitHub connection uses **Supabase's built-in identity linking** — no backend OAuth, no token storage in MongoDB. Google Drive uses a **separate backend OAuth flow** because it needs server-side access to the user's Drive files (see [04-google-drive-oauth-connect.md](./04-google-drive-oauth-connect.md)).
Loading