From e398d58f3ed875564b723e06dcee8407b7bd1f9a Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Thu, 14 May 2026 12:58:01 +0200 Subject: [PATCH 1/5] docs: add spec for Google Drive upload history The google_drive_uploads table has been write-only since August 2024. This spec defines a two-phase delivery: PR 1 surfaces the history on the Downloads page (read + delete + last_converted_at migration); PR 2 adds one-click re-convert once the OAuth re-auth story is worked out. --- .../specs/google-drive-upload-history.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 Documentation/specs/google-drive-upload-history.md diff --git a/Documentation/specs/google-drive-upload-history.md b/Documentation/specs/google-drive-upload-history.md new file mode 100644 index 000000000..7066ece6b --- /dev/null +++ b/Documentation/specs/google-drive-upload-history.md @@ -0,0 +1,134 @@ +# Spec: Google Drive Upload History + +### Trio synthesis +- **PM:** Read-only history list with "Open in Drive" link; "Convert again" deferred (OAuth token not stored); gate on ≥15% click-through before building re-convert; `last_converted_at` migration needed. +- **Designer:** Fourth section on `/downloads`, below "From Dropbox", using a table layout (File / Size / Added / Actions) matching FinishedJobs; hide when empty; sanitize `url` to Drive origins only. +- **Engineer:** S+ effort; string PK needs regex validation; upsert semantics make `created_at` alone misleading — `last_converted_at` column is the right recency signal; `sizeBytes` arrives as string from Kanel. +- **Agreement:** "Convert again" out of phase 1; ownership enforcement non-negotiable; migration before ship; section hidden when empty. +- **Conflict:** PM suggested sorting by `lastEditedUtc DESC`; Engineer prefers `last_converted_at`. **Resolution:** `last_converted_at` — Drive's edit time is the file owner's clock, not the user's conversion history. Designer used table layout vs. Dropbox's simpler row layout. **Resolution:** table layout matches the existing FinishedJobs pattern on the same page. +- **Resulting plan:** Add "From Google Drive" table section to Downloads (read + delete + `last_converted_at` migration), `Open in Drive` external link per row, no re-convert in phase 1. + +--- + +## Outcome + +Logged-in users who have uploaded via Google Drive can see those files in a "From Google Drive" section on the Downloads page, with a link to open each file in Drive and an option to remove it from the list. Returning users who want to re-convert can navigate to Drive in one click instead of hunting through folders. Aligns with the mission: fastest path from studying to Anki flashcards — repeat conversions start with one click. + +## Problem statement + +A user uploads a 40-slide Google Slides deck on Monday, gets a `.apkg`, closes the tab. On Wednesday they want to regenerate it after editing the slides. Today they must re-open the Google Picker and navigate three folders deep. Meanwhile, the file's `id`, `name`, `iconUrl`, `mimeType`, and `url` have been in `google_drive_uploads` since the first upload — never shown. + +## Riskiest assumption + smallest test + +**Assumption:** Drive users want to revisit files they've already converted (vs. each upload being a one-shot assignment they never return to). + +**Validation test:** Ship the read-only history section for one week. Instrument clicks on "Open in Drive". If fewer than 15% of users who view the section click any row, kill the feature before building re-convert. No OAuth, no new storage, no irreversible schema changes. + +## Scope + +**In (PR 1 — history + delete):** +- `last_converted_at timestamptz default now()` migration on `google_drive_uploads`, updated on every upsert +- `GET /api/upload/google_drive/mine` — authenticated user's rows, `ORDER BY last_converted_at DESC NULLS LAST`, limit 10, optional `offset` for "Show older" +- `DELETE /api/upload/google_drive/mine/:id` — removes a single row (ownership-checked; string PK validated with `/^[A-Za-z0-9_-]+$/`) +- "From Google Drive" table section on `/downloads` (fourth section, below "From Dropbox") +- Each row: file icon (with CDN fallback), filename, formatted size, relative time, "Open in Drive ↗" link, × remove +- Section hidden when empty; inline error state +- `url` field sanitized to `https://drive.google.com/` and `https://docs.google.com/` origins only before rendering + +**In (PR 2 — re-convert):** +- "Convert again" button that triggers Google re-auth and re-runs the conversion pipeline using the stored `id` + +**Out:** +- Search, filter, pagination beyond "Show older" +- Storing OAuth tokens or refresh tokens +- Dropbox, Notion, or direct-upload history (separate specs) +- Folder entries (`mimeType = 'application/vnd.google-apps.folder'`) — excluded from query +- Thumbnail/preview generation +- Feature flag / staged rollout (ship to all authenticated users at once) + +## User story + acceptance criteria + +As a logged-in user who has uploaded from Google Drive, I want to see those files listed on the Downloads page so I can open the source in Drive without hunting through folders. + +- [ ] Authenticated user with ≥1 `google_drive_uploads` row sees "From Google Drive" section on `/downloads` +- [ ] Section is hidden entirely when the user has no rows +- [ ] Folder entries (`mimeType = 'application/vnd.google-apps.folder'`) are excluded +- [ ] Each row shows: file icon (onError → generic fallback), filename, formatted size (handles `sizeBytes` as string), relative time from `last_converted_at` (em-dash if null), "Open in Drive ↗" link, × remove +- [ ] "Open in Drive" `href` is sanitized — only `drive.google.com` and `docs.google.com` origins; disabled with title "Link unavailable" otherwise +- [ ] List defaults to 10 newest by `last_converted_at DESC NULLS LAST`; "Show older Google Drive files" expands by 20 +- [ ] `DELETE` endpoint enforces ownership; string PK validated against `/^[A-Za-z0-9_-]+$/`; returns 401 without session, 404 if wrong owner +- [ ] `GET` endpoint returns 401 without session; never returns another user's rows +- [ ] `last_converted_at` migration included; upsert path in `saveFiles` updates the column on every UPDATE +- [ ] Jest test: `getByOwner` returns only correct owner's rows, excludes folders, sorts by `last_converted_at DESC NULLS LAST` +- [ ] Jest test: `deleteByIdAndOwner` with mismatched owner deletes 0 rows; test with crafted ID string confirms parameterized query +- [ ] Vitest test: hook covers loading, empty, and populated states; delete uses string key + +## Leading indicator + +Return-upload rate among users with `google_drive_uploads` rows. Target: +5pp within 4 weeks. Secondary: ≥15% of history-section viewers click "Open in Drive" in their first session (validation gate for phase 2). + +## Design notes + +**Location:** Fourth section on `/downloads`, directly below "From Dropbox". + +**Table layout** (matching FinishedJobs, not the simpler Dropbox row layout): + +| File | Size | Added | Actions | +|---|---|---|---| +| [icon] biology-chapter-7.pdf | 2.4 MB | 3 days ago | [Open in Drive ↗] [×] | + +- **File icon:** 20 px `` with `onError` swap to `/icons/file-generic.svg`. Only allow Google CDN origins; fallback on disallowed origin. +- **Filename:** truncated with ellipsis, full name in `title` attr. +- **Size:** humanized string (handles Kanel's `string` type for `sizeBytes`). `—` if null. +- **Added:** relative time from `last_converted_at`. `—` if null (historical rows). +- **Open in Drive:** neutral outline button (`previewButton`), `target="_blank" rel="noreferrer noopener"`. Not primary — this page is a memory and doorway, not a CTA surface. +- **× Remove:** `iconButtonDanger`, hover red only. Removes the history entry; does not touch the file in Drive. + +**Section header copy:** +- Title: `From Google Drive` +- Subtitle: `Files you picked from Google Drive. Open them in Drive or remove them from this list.` + +**States:** +- Empty: hide section entirely (no empty-state card) +- List load error: `We couldn't load your Google Drive history. Refresh the page to try again.` +- Null `created_at`/`last_converted_at` cell: `—` +- Null `sizeBytes` cell: `—` +- Remove in-flight: row dims, × disabled (`aria-label="Removing…"`) + +**Reuse from `DownloadsPage.module.css`:** `styles.section`, `styles.sectionHeader`, `styles.sectionTitle`, `styles.sectionDescription`, `styles.card`, `styles.table`, `styles.fileName`, `styles.timeAgo`, `styles.actions`, `styles.iconButton`, `styles.iconButtonDanger`, `styles.previewButton`. No new CSS file needed. + +## Technical pre-flight + +**Layers touched:** data_layer → usecases → controllers → routes → web + +**Files in play (PR 1):** +- `src/data_layer/GoogleDriveRepository.ts` — add `getByOwner(owner, limit, offset)` and `deleteByIdAndOwner(id: string, owner: number)`; update `saveFiles` upsert to set `last_converted_at = now()` on UPDATE +- `src/usecases/uploads/GetGoogleDriveUploadsUseCase.ts` — new +- `src/controllers/Upload/UploadController.ts` — add `getGoogleDriveUploads` + `deleteGoogleDriveUpload` handlers +- `src/routes/UploadRouter.ts` — add `GET /api/upload/google_drive/mine` and `DELETE /api/upload/google_drive/mine/:id` behind `RequireAuthentication` +- `migrations/_add_last_converted_at_to_google_drive_uploads.js` — additive `last_converted_at timestamptz default now()`, nullable; add index on `owner` if missing +- `web/src/pages/DownloadsPage/DownloadsPage.tsx` — render `` below Dropbox section +- `web/src/pages/DownloadsPage/components/GoogleDriveHistorySection.tsx` — new table component +- `web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.tsx` — new (mirrors `useDropboxUploads.tsx`) +- `web/src/lib/backend/Backend.ts` — add `getGoogleDriveUploads()` and `deleteGoogleDriveUpload(id: string)` +- `web/src/lib/formatBytes.ts` — new helper (handles string input from Kanel's bigint mapping) + +**Key differences from Dropbox implementation:** +1. PK is `string` — `deleteByIdAndOwner` takes `string`, validate with `/^[A-Za-z0-9_-]+$/` before querying +2. No auto-increment — sort by `last_converted_at DESC NULLS LAST`, not `id DESC` +3. Upsert semantics — `saveFiles` UPDATE branch must `SET last_converted_at = now()` +4. `sizeBytes` is Kanel `string` (bigint) — formatting helper must accept string input +5. Folder exclusion — `WHERE mimeType != 'application/vnd.google-apps.folder'` in `getByOwner` +6. `url` field sanitization — only `drive.google.com` / `docs.google.com` origins before rendering + +**Effort:** S+ (slightly larger than Dropbox's S due to upsert/sort complexity and string PK handling) + +**Cross-language:** None. + +**Migration:** Additive `last_converted_at timestamptz default now()` nullable. Historical rows get NULL (sort last). The upsert UPDATE path in `saveFiles` must be updated to touch `last_converted_at` or ordering will be misleading for repeat converters. + +**Open questions before work starts:** +1. **`url` safety:** Is the stored `url` always a stable `https://drive.google.com/file/d//view` form, or can it contain OAuth tokens? Determines whether to include it in phase 1 response. +2. **`owner` index:** Does the migration need to add `CREATE INDEX ON google_drive_uploads(owner)`? Check existing migration for missing index. +3. **Paywall:** Is history gated (free vs. paying) or open to all authenticated users? (Same open question as Dropbox spec — needs resolution before either ships.) +4. **Validation gate:** Run `SELECT owner, id, COUNT(*) FROM google_drive_uploads GROUP BY owner, id HAVING COUNT(*) > 1` on prod to measure re-conversion rate before cutting the branch. From 043d59f8bd7a0099645984f94cc8506c50155d2b Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 00:11:58 +0200 Subject: [PATCH 2/5] feat: server-side Google Drive upload history API Adds GET /api/upload/google_drive/mine and DELETE /api/upload/google_drive/mine/:id guarded by RequireAuthentication. Owner is read from res.locals; controller validates the string PK against /^[A-Za-z0-9_-]+$/ before reaching the repository. Migration adds a nullable last_converted_at timestamptz to google_drive_uploads plus an owner index. The saveFiles upsert UPDATE branch now sets last_converted_at = now() so the recency sort reflects re-conversions, not first upload. GetByOwner excludes mimeType = 'application/vnd.google-apps.folder' and orders by last_converted_at DESC NULLS LAST. The use case maps rows to an explicit typed response shape (no raw DB rows through res.json). Co-Authored-By: Claude Opus 4.7 --- ...st_converted_at_to_google_drive_uploads.js | 15 ++ .../Upload/GoogleDriveController.test.ts | 197 ++++++++++++++++++ src/controllers/Upload/UploadController.ts | 52 ++++- src/data_layer/GoogleDriveRepository.test.ts | 146 +++++++++++++ src/data_layer/GoogleDriveRepository.ts | 64 +++++- src/routes/UploadRouter.ts | 109 +++++++++- .../DeleteGoogleDriveUploadUseCase.test.ts | 29 +++ .../uploads/DeleteGoogleDriveUploadUseCase.ts | 12 ++ .../GetGoogleDriveUploadsUseCase.test.ts | 58 ++++++ .../uploads/GetGoogleDriveUploadsUseCase.ts | 32 +++ 10 files changed, 706 insertions(+), 8 deletions(-) create mode 100644 migrations/20260603000000_add_last_converted_at_to_google_drive_uploads.js create mode 100644 src/controllers/Upload/GoogleDriveController.test.ts create mode 100644 src/data_layer/GoogleDriveRepository.test.ts create mode 100644 src/usecases/uploads/DeleteGoogleDriveUploadUseCase.test.ts create mode 100644 src/usecases/uploads/DeleteGoogleDriveUploadUseCase.ts create mode 100644 src/usecases/uploads/GetGoogleDriveUploadsUseCase.test.ts create mode 100644 src/usecases/uploads/GetGoogleDriveUploadsUseCase.ts diff --git a/migrations/20260603000000_add_last_converted_at_to_google_drive_uploads.js b/migrations/20260603000000_add_last_converted_at_to_google_drive_uploads.js new file mode 100644 index 000000000..38aa81894 --- /dev/null +++ b/migrations/20260603000000_add_last_converted_at_to_google_drive_uploads.js @@ -0,0 +1,15 @@ +exports.up = async (knex) => { + await knex.schema.table('google_drive_uploads', (t) => { + t.timestamp('last_converted_at', { useTz: true }) + .defaultTo(knex.fn.now()) + .nullable(); + t.index('owner'); + }); +}; + +exports.down = async (knex) => { + await knex.schema.table('google_drive_uploads', (t) => { + t.dropIndex('owner'); + t.dropColumn('last_converted_at'); + }); +}; diff --git a/src/controllers/Upload/GoogleDriveController.test.ts b/src/controllers/Upload/GoogleDriveController.test.ts new file mode 100644 index 000000000..20b75f2b8 --- /dev/null +++ b/src/controllers/Upload/GoogleDriveController.test.ts @@ -0,0 +1,197 @@ +import express from 'express'; + +jest.mock('../../lib/integrations/stripe', () => ({ + getStripe: jest.fn().mockReturnValue({ + customers: { retrieve: jest.fn() }, + }), + updateStoreSubscription: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../../services/SubscriptionService', () => ({ + __esModule: true, + default: { findActiveStripeSubscriptions: jest.fn().mockResolvedValue([]) }, +})); + +import { INotionRepository } from '../../data_layer/NotionRespository'; +import { IUploadRepository } from '../../data_layer/UploadRespository'; +import NotionTokens from '../../data_layer/public/NotionTokens'; +import NotionService from '../../services/NotionService'; +import UploadService from '../../services/UploadService'; +import JobRepository from '../../data_layer/JobRepository'; +import UploadController from './UploadController'; +import { GetGoogleDriveUploadsUseCase } from '../../usecases/uploads/GetGoogleDriveUploadsUseCase'; +import { DeleteGoogleDriveUploadUseCase } from '../../usecases/uploads/DeleteGoogleDriveUploadUseCase'; + +function makeController( + getUseCase: GetGoogleDriveUploadsUseCase, + deleteUseCase: DeleteGoogleDriveUploadUseCase +) { + const uploadRepository: IUploadRepository = { + deleteUpload: jest.fn().mockResolvedValue(1), + getUploadsByOwner: jest.fn().mockResolvedValue([]), + findByIdAndOwner: jest.fn().mockResolvedValue(null), + update: jest.fn().mockResolvedValue([]), + }; + const notionRepository: INotionRepository = { + getNotionData: jest.fn().mockResolvedValue({ owner: 1, token: '...' } as NotionTokens), + saveNotionToken: jest.fn().mockResolvedValue(true), + getNotionToken: jest.fn().mockResolvedValue('...'), + deleteBlocksByOwner: jest.fn().mockResolvedValue(1), + deleteNotionData: jest.fn().mockResolvedValue(true), + }; + const uploadService = new UploadService( + uploadRepository, + {} as JobRepository + ); + const notionService = new NotionService(notionRepository); + return new UploadController( + uploadService, + notionService, + undefined, + undefined, + getUseCase, + deleteUseCase + ); +} + +function makeRes(owner: number | null = 42) { + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + return { + res: { locals: { owner }, status, json } as unknown as express.Response, + json, + status, + }; +} + +describe('UploadController.getGoogleDriveUploads', () => { + it('returns 401 when owner is missing', async () => { + const getUseCase = { execute: jest.fn().mockResolvedValue([]) } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, status, json } = makeRes(null); + + await controller.getGoogleDriveUploads({ query: {} } as express.Request, res); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(getUseCase.execute).not.toHaveBeenCalled(); + }); + + it('returns uploads when owner is present', async () => { + const rows = [ + { + id: 'abc', + iconUrl: 'https://drive-thirdparty.googleusercontent.com/16/type/pdf', + mimeType: 'application/pdf', + name: 'file.pdf', + sizeBytes: '1024', + url: 'https://drive.google.com/file/d/abc/view', + last_converted_at: null, + }, + ]; + const getUseCase = { execute: jest.fn().mockResolvedValue(rows) } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, json } = makeRes(42); + + await controller.getGoogleDriveUploads({ query: {} } as express.Request, res); + + expect(json).toHaveBeenCalledWith(rows); + }); + + it('passes parsed offset to use case', async () => { + const getUseCase = { execute: jest.fn().mockResolvedValue([]) } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res } = makeRes(42); + + await controller.getGoogleDriveUploads( + { query: { offset: '20' } } as unknown as express.Request, + res + ); + + expect(getUseCase.execute).toHaveBeenCalledWith(42, 10, 20); + }); +}); + +describe('UploadController.deleteGoogleDriveUpload', () => { + it('returns 401 when owner is missing', async () => { + const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, status, json } = makeRes(null); + + await controller.deleteGoogleDriveUpload( + { params: { id: 'abc' } } as unknown as express.Request, + res + ); + + expect(status).toHaveBeenCalledWith(401); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(deleteUseCase.execute).not.toHaveBeenCalled(); + }); + + it('returns 400 when id param is missing', async () => { + const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, status, json } = makeRes(42); + + await controller.deleteGoogleDriveUpload( + { params: {} } as unknown as express.Request, + res + ); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + }); + + it('returns 400 when id contains characters outside the allowed alphabet', async () => { + const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn() } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, status, json } = makeRes(42); + + await controller.deleteGoogleDriveUpload( + { params: { id: "abc'; DROP TABLE x;--" } } as unknown as express.Request, + res + ); + + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(deleteUseCase.execute).not.toHaveBeenCalled(); + }); + + it('returns 404 when use case throws', async () => { + const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { + execute: jest.fn().mockRejectedValue(new Error('Not found')), + } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, status, json } = makeRes(42); + + await controller.deleteGoogleDriveUpload( + { params: { id: 'xyz' } } as unknown as express.Request, + res + ); + + expect(status).toHaveBeenCalledWith(404); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + }); + + it('returns 200 on successful delete and passes the string id', async () => { + const getUseCase = { execute: jest.fn() } as unknown as GetGoogleDriveUploadsUseCase; + const deleteUseCase = { execute: jest.fn().mockResolvedValue(undefined) } as unknown as DeleteGoogleDriveUploadUseCase; + const controller = makeController(getUseCase, deleteUseCase); + const { res, json } = makeRes(42); + + await controller.deleteGoogleDriveUpload( + { params: { id: 'abc-123_XYZ' } } as unknown as express.Request, + res + ); + + expect(json).toHaveBeenCalledWith({}); + expect(deleteUseCase.execute).toHaveBeenCalledWith('abc-123_XYZ', 42); + }); +}); diff --git a/src/controllers/Upload/UploadController.ts b/src/controllers/Upload/UploadController.ts index 8b3471fc8..9e4bed538 100644 --- a/src/controllers/Upload/UploadController.ts +++ b/src/controllers/Upload/UploadController.ts @@ -10,15 +10,21 @@ import { handleDropbox } from './helpers/handleDropbox'; import { handleGoogleDrive } from './helpers/handleGoogleDrive'; import { GetDropboxUploadsUseCase } from '../../usecases/uploads/GetDropboxUploadsUseCase'; import { DeleteDropboxUploadUseCase } from '../../usecases/uploads/DeleteDropboxUploadUseCase'; +import { GetGoogleDriveUploadsUseCase } from '../../usecases/uploads/GetGoogleDriveUploadsUseCase'; +import { DeleteGoogleDriveUploadUseCase } from '../../usecases/uploads/DeleteGoogleDriveUploadUseCase'; const DROPBOX_PAGE_SIZE = 10; +const GOOGLE_DRIVE_PAGE_SIZE = 10; +const GOOGLE_DRIVE_ID_PATTERN = /^[A-Za-z0-9_-]+$/; class UploadController { constructor( private readonly service: UploadService, private readonly notionService: NotionService, private readonly getDropboxUploadsUseCase?: GetDropboxUploadsUseCase, - private readonly deleteDropboxUploadUseCase?: DeleteDropboxUploadUseCase + private readonly deleteDropboxUploadUseCase?: DeleteDropboxUploadUseCase, + private readonly getGoogleDriveUploadsUseCase?: GetGoogleDriveUploadsUseCase, + private readonly deleteGoogleDriveUploadUseCase?: DeleteGoogleDriveUploadUseCase ) {} async deleteUpload(req: express.Request, res: express.Response) { @@ -149,6 +155,50 @@ class UploadController { return res.status(404).json({ message: 'Upload not found.' }); } } + + async getGoogleDriveUploads(req: express.Request, res: express.Response) { + const owner = getOwner(res); + if (owner == null) { + return res.status(401).json({ message: 'Authentication required.' }); + } + + const rawOffset = (req.query as Record).offset; + const offset = rawOffset != null ? parseInt(rawOffset, 10) : 0; + + try { + const uploads = await this.getGoogleDriveUploadsUseCase!.execute( + owner, + GOOGLE_DRIVE_PAGE_SIZE, + Number.isFinite(offset) ? offset : 0 + ); + return res.json(uploads); + } catch (error) { + console.error('getGoogleDriveUploads failed', error); + return res.status(500).json({ + message: + "Couldn't load your Google Drive history right now. Refresh to try again.", + }); + } + } + + async deleteGoogleDriveUpload(req: express.Request, res: express.Response) { + const owner = getOwner(res); + if (owner == null) { + return res.status(401).json({ message: 'Authentication required.' }); + } + + const rawId = (req.params as Record).id; + if (rawId == null || !GOOGLE_DRIVE_ID_PATTERN.test(rawId)) { + return res.status(400).json({ message: 'Invalid upload id.' }); + } + + try { + await this.deleteGoogleDriveUploadUseCase!.execute(rawId, owner); + return res.json({}); + } catch (error) { + return res.status(404).json({ message: 'Upload not found.' }); + } + } } export default UploadController; diff --git a/src/data_layer/GoogleDriveRepository.test.ts b/src/data_layer/GoogleDriveRepository.test.ts new file mode 100644 index 000000000..405fccb50 --- /dev/null +++ b/src/data_layer/GoogleDriveRepository.test.ts @@ -0,0 +1,146 @@ +import { + GoogleDriveRepository, + GOOGLE_DRIVE_FOLDER_MIME, +} from './GoogleDriveRepository'; + +describe('GoogleDriveRepository.getByOwner owner guards', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + afterEach(() => warn.mockClear()); + + function makeRepo() { + const calls: { method: string; args?: unknown[] }[] = []; + + const limitOffset = { + limit: () => ({ + offset: () => Promise.resolve([]), + }), + }; + + const orderChain = { + orderByRaw: (...args: unknown[]) => { + calls.push({ method: 'orderByRaw', args }); + return limitOffset; + }, + }; + + const whereChain = { + where: (...args: unknown[]) => { + calls.push({ method: 'where', args }); + return { + andWhere: (...andArgs: unknown[]) => { + calls.push({ method: 'andWhere', args: andArgs }); + return orderChain; + }, + }; + }, + }; + + const selectChain = { + select: (...args: string[]) => { + calls.push({ method: 'select', args }); + return whereChain; + }, + }; + + const db = ((_table: string) => { + calls.push({ method: 'db(table)' }); + return selectChain; + }) as unknown as never; + + return { repo: new GoogleDriveRepository(db), calls }; + } + + it('returns empty list and skips query when owner is null', async () => { + const { repo, calls } = makeRepo(); + const result = await repo.getByOwner(null as unknown as number, 10, 0); + expect(result).toEqual([]); + expect(calls).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); + + it('returns empty list and skips query when owner is undefined', async () => { + const { repo, calls } = makeRepo(); + const result = await repo.getByOwner( + undefined as unknown as number, + 10, + 0 + ); + expect(result).toEqual([]); + expect(calls).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); + + it('queries with owner filter, excludes folders, and orders by last_converted_at DESC NULLS LAST', async () => { + const { repo, calls } = makeRepo(); + await repo.getByOwner(42, 10, 0); + + const whereCall = calls.find((c) => c.method === 'where'); + const andWhereCall = calls.find((c) => c.method === 'andWhere'); + const orderCall = calls.find((c) => c.method === 'orderByRaw'); + + expect(whereCall?.args).toEqual([{ owner: 42 }]); + expect(andWhereCall?.args).toEqual(['mimeType', '!=', GOOGLE_DRIVE_FOLDER_MIME]); + expect(orderCall?.args?.[0]).toMatch(/last_converted_at DESC NULLS LAST/); + }); +}); + +describe('GoogleDriveRepository.deleteByIdAndOwner owner guards', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + afterEach(() => warn.mockClear()); + + function makeDeleteRepo() { + const calls: { method: string; args?: unknown[] }[] = []; + + const delChain = { + del: () => { + calls.push({ method: 'del' }); + return Promise.resolve(1); + }, + }; + + const whereChain = { + where: (...args: unknown[]) => { + calls.push({ method: 'where', args }); + return delChain; + }, + }; + + const db = ((_table: string) => { + calls.push({ method: 'db(table)' }); + return whereChain; + }) as unknown as never; + + return { repo: new GoogleDriveRepository(db), calls }; + } + + it('returns 0 and skips query when owner is null', async () => { + const { repo, calls } = makeDeleteRepo(); + const result = await repo.deleteByIdAndOwner( + 'abc', + null as unknown as number + ); + expect(result).toBe(0); + expect(calls).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); + + it('returns 0 and skips query when id is null', async () => { + const { repo, calls } = makeDeleteRepo(); + const result = await repo.deleteByIdAndOwner( + null as unknown as string, + 42 + ); + expect(result).toBe(0); + expect(calls).toEqual([]); + expect(warn).toHaveBeenCalled(); + }); + + it('deletes when both id and owner are valid; passes parameterized id', async () => { + const { repo, calls } = makeDeleteRepo(); + const craftedId = "1' OR '1'='1"; + const result = await repo.deleteByIdAndOwner(craftedId, 42); + expect(result).toBe(1); + const whereCall = calls.find((c) => c.method === 'where'); + expect(whereCall?.args).toEqual([{ id: craftedId, owner: 42 }]); + }); +}); diff --git a/src/data_layer/GoogleDriveRepository.ts b/src/data_layer/GoogleDriveRepository.ts index 905ab72db..070c8052e 100644 --- a/src/data_layer/GoogleDriveRepository.ts +++ b/src/data_layer/GoogleDriveRepository.ts @@ -1,5 +1,7 @@ import { Knex } from 'knex'; +export const GOOGLE_DRIVE_FOLDER_MIME = 'application/vnd.google-apps.folder'; + export type GoogleDriveFile = { downloadUrl?: string; uploadState?: string; @@ -20,7 +22,20 @@ export type GoogleDriveFile = { url: string; }; +export type GoogleDriveUploadRow = { + id: string; + iconUrl: string; + mimeType: string; + name: string; + sizeBytes: string | null; + url: string; + owner: number; + last_converted_at: string | null; +}; + export class GoogleDriveRepository { + private readonly table = 'google_drive_uploads'; + constructor(private readonly database: Knex) {} private generateFileData(file: GoogleDriveFile, owner: number | string) { @@ -32,8 +47,8 @@ export class GoogleDriveRepository { lastEditedUtc: file.lastEditedUtc, mimeType: file.mimeType, name: file.name, - organizationDisplayName: '', // Assuming default value - parentId: '', // Assuming default value + organizationDisplayName: '', + parentId: '', serviceId: file.serviceId, sizeBytes: file.sizeBytes, type: file.type, @@ -46,21 +61,58 @@ export class GoogleDriveRepository { for (const file of files) { const fileData = this.generateFileData(file, owner); try { - await this.database('google_drive_uploads').insert(fileData); + await this.database(this.table).insert(fileData); } catch (error) { if (!(error instanceof Error) || (error as any).code !== '23505') throw error; - const existingFile = await this.database('google_drive_uploads') + const existingFile = await this.database(this.table) .where({ id: file.id, owner: owner }) .first(); if (!existingFile) throw error; - await this.database('google_drive_uploads') + await this.database(this.table) .where({ id: file.id, owner: owner }) - .update(fileData); + .update({ ...fileData, last_converted_at: this.database.fn.now() }); } } } + + getByOwner( + owner: number, + limit: number, + offset: number + ): Promise { + if (owner == null) { + console.warn('[GoogleDriveRepository] getByOwner called with no owner'); + return Promise.resolve([]); + } + return this.database(this.table) + .select( + 'id', + 'iconUrl', + 'mimeType', + 'name', + 'sizeBytes', + 'url', + 'owner', + 'last_converted_at' + ) + .where({ owner }) + .andWhere('mimeType', '!=', GOOGLE_DRIVE_FOLDER_MIME) + .orderByRaw('last_converted_at DESC NULLS LAST') + .limit(limit) + .offset(offset); + } + + deleteByIdAndOwner(id: string, owner: number): Promise { + if (owner == null || id == null) { + console.warn( + '[GoogleDriveRepository] deleteByIdAndOwner called with missing id or owner' + ); + return Promise.resolve(0); + } + return this.database(this.table).where({ id, owner }).del(); + } } diff --git a/src/routes/UploadRouter.ts b/src/routes/UploadRouter.ts index 7e7e59c9f..c1b53397a 100644 --- a/src/routes/UploadRouter.ts +++ b/src/routes/UploadRouter.ts @@ -13,8 +13,11 @@ import UploadRepository from '../data_layer/UploadRespository'; import NotionRepository from '../data_layer/NotionRespository'; import NotionService from '../services/NotionService'; import { DropboxRepository } from '../data_layer/DropboxRepository'; +import { GoogleDriveRepository } from '../data_layer/GoogleDriveRepository'; import { GetDropboxUploadsUseCase } from '../usecases/uploads/GetDropboxUploadsUseCase'; import { DeleteDropboxUploadUseCase } from '../usecases/uploads/DeleteDropboxUploadUseCase'; +import { GetGoogleDriveUploadsUseCase } from '../usecases/uploads/GetGoogleDriveUploadsUseCase'; +import { DeleteGoogleDriveUploadUseCase } from '../usecases/uploads/DeleteGoogleDriveUploadUseCase'; const UploadRouter = () => { const router = express.Router(); @@ -23,11 +26,14 @@ const UploadRouter = () => { new JobService(new JobRepository(database)) ); const dropboxRepository = new DropboxRepository(database); + const googleDriveRepository = new GoogleDriveRepository(database); const uploadController = new UploadController( new UploadService(new UploadRepository(database), new JobRepository(database)), new NotionService(new NotionRepository(database)), new GetDropboxUploadsUseCase(dropboxRepository), - new DeleteDropboxUploadUseCase(dropboxRepository) + new DeleteDropboxUploadUseCase(dropboxRepository), + new GetGoogleDriveUploadsUseCase(googleDriveRepository), + new DeleteGoogleDriveUploadUseCase(googleDriveRepository) ); /** @@ -499,6 +505,107 @@ const UploadRouter = () => { uploadController.deleteDropboxUpload(req, res) ); + /** + * @swagger + * /api/upload/google_drive/mine: + * get: + * summary: List the authenticated user's Google Drive upload history + * description: Returns the most recent Google Drive files the user has converted, ordered by last_converted_at descending. Folder entries are excluded. + * tags: [Upload] + * security: + * - sessionAuth: [] + * parameters: + * - in: query + * name: offset + * required: false + * schema: + * type: integer + * minimum: 0 + * description: Number of rows to skip for paging beyond the first page + * responses: + * 200: + * description: Google Drive upload history retrieved successfully + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * iconUrl: + * type: string + * mimeType: + * type: string + * name: + * type: string + * sizeBytes: + * type: string + * nullable: true + * url: + * type: string + * last_converted_at: + * type: string + * format: date-time + * nullable: true + * 401: + * description: Authentication required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + router.get('/api/upload/google_drive/mine', RequireAuthentication, (req, res) => + uploadController.getGoogleDriveUploads(req, res) + ); + + /** + * @swagger + * /api/upload/google_drive/mine/{id}: + * delete: + * summary: Remove a row from the user's Google Drive upload history + * description: Deletes a single google_drive_uploads row owned by the authenticated user. The underlying file in Drive is not affected. + * tags: [Upload] + * security: + * - sessionAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Google Drive file id (alphanumeric, underscore, hyphen) + * responses: + * 200: + * description: History entry removed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Success' + * 400: + * description: Missing or invalid id + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 401: + * description: Authentication required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 404: + * description: History entry not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ + router.delete('/api/upload/google_drive/mine/:id', RequireAuthentication, (req, res) => + uploadController.deleteGoogleDriveUpload(req, res) + ); + return router; }; diff --git a/src/usecases/uploads/DeleteGoogleDriveUploadUseCase.test.ts b/src/usecases/uploads/DeleteGoogleDriveUploadUseCase.test.ts new file mode 100644 index 000000000..06a061d1b --- /dev/null +++ b/src/usecases/uploads/DeleteGoogleDriveUploadUseCase.test.ts @@ -0,0 +1,29 @@ +import { DeleteGoogleDriveUploadUseCase } from './DeleteGoogleDriveUploadUseCase'; +import { GoogleDriveRepository } from '../../data_layer/GoogleDriveRepository'; + +describe('DeleteGoogleDriveUploadUseCase', () => { + function makeRepo(deleteResult: number): GoogleDriveRepository { + return { + deleteByIdAndOwner: jest.fn().mockResolvedValue(deleteResult), + } as unknown as GoogleDriveRepository; + } + + it('calls repository with id and owner', async () => { + const repo = makeRepo(1); + const useCase = new DeleteGoogleDriveUploadUseCase(repo); + await useCase.execute('abc', 42); + expect(repo.deleteByIdAndOwner).toHaveBeenCalledWith('abc', 42); + }); + + it('throws when no row was deleted (wrong owner or missing id)', async () => { + const repo = makeRepo(0); + const useCase = new DeleteGoogleDriveUploadUseCase(repo); + await expect(useCase.execute('nope', 42)).rejects.toThrow(); + }); + + it('resolves when deletion succeeded', async () => { + const repo = makeRepo(1); + const useCase = new DeleteGoogleDriveUploadUseCase(repo); + await expect(useCase.execute('abc', 42)).resolves.toBeUndefined(); + }); +}); diff --git a/src/usecases/uploads/DeleteGoogleDriveUploadUseCase.ts b/src/usecases/uploads/DeleteGoogleDriveUploadUseCase.ts new file mode 100644 index 000000000..6c02bcc67 --- /dev/null +++ b/src/usecases/uploads/DeleteGoogleDriveUploadUseCase.ts @@ -0,0 +1,12 @@ +import { GoogleDriveRepository } from '../../data_layer/GoogleDriveRepository'; + +export class DeleteGoogleDriveUploadUseCase { + constructor(private readonly repository: GoogleDriveRepository) {} + + async execute(id: string, owner: number): Promise { + const deleted = await this.repository.deleteByIdAndOwner(id, owner); + if (deleted === 0) { + throw new Error('Not found'); + } + } +} diff --git a/src/usecases/uploads/GetGoogleDriveUploadsUseCase.test.ts b/src/usecases/uploads/GetGoogleDriveUploadsUseCase.test.ts new file mode 100644 index 000000000..ec3fe39a3 --- /dev/null +++ b/src/usecases/uploads/GetGoogleDriveUploadsUseCase.test.ts @@ -0,0 +1,58 @@ +import { GetGoogleDriveUploadsUseCase } from './GetGoogleDriveUploadsUseCase'; +import { + GoogleDriveRepository, + GoogleDriveUploadRow, +} from '../../data_layer/GoogleDriveRepository'; + +describe('GetGoogleDriveUploadsUseCase', () => { + function makeRepo(rows: GoogleDriveUploadRow[]): GoogleDriveRepository { + return { + getByOwner: jest.fn().mockResolvedValue(rows), + } as unknown as GoogleDriveRepository; + } + + it('returns mapped rows with description/embedUrl/owner omitted', async () => { + const repo = makeRepo([ + { + id: 'abc123', + iconUrl: 'https://drive-thirdparty.googleusercontent.com/16/type/pdf', + mimeType: 'application/pdf', + name: 'biology-chapter-7.pdf', + sizeBytes: '2457600', + url: 'https://drive.google.com/file/d/abc123/view', + owner: 42, + last_converted_at: '2026-05-14T00:00:00Z', + }, + ]); + const useCase = new GetGoogleDriveUploadsUseCase(repo); + const result = await useCase.execute(42, 10, 0); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'abc123', + iconUrl: 'https://drive-thirdparty.googleusercontent.com/16/type/pdf', + mimeType: 'application/pdf', + name: 'biology-chapter-7.pdf', + sizeBytes: '2457600', + url: 'https://drive.google.com/file/d/abc123/view', + last_converted_at: '2026-05-14T00:00:00Z', + }); + expect(result[0]).not.toHaveProperty('owner'); + expect(result[0]).not.toHaveProperty('description'); + expect(result[0]).not.toHaveProperty('embedUrl'); + }); + + it('passes limit and offset to repository', async () => { + const repo = makeRepo([]); + const useCase = new GetGoogleDriveUploadsUseCase(repo); + await useCase.execute(7, 10, 20); + expect(repo.getByOwner).toHaveBeenCalledWith(7, 10, 20); + }); + + it('returns empty array when repository returns no rows', async () => { + const repo = makeRepo([]); + const useCase = new GetGoogleDriveUploadsUseCase(repo); + const result = await useCase.execute(7, 10, 0); + expect(result).toEqual([]); + }); +}); diff --git a/src/usecases/uploads/GetGoogleDriveUploadsUseCase.ts b/src/usecases/uploads/GetGoogleDriveUploadsUseCase.ts new file mode 100644 index 000000000..6e935d85d --- /dev/null +++ b/src/usecases/uploads/GetGoogleDriveUploadsUseCase.ts @@ -0,0 +1,32 @@ +import { GoogleDriveRepository } from '../../data_layer/GoogleDriveRepository'; + +export type GoogleDriveUploadResponse = { + id: string; + iconUrl: string; + mimeType: string; + name: string; + sizeBytes: string | null; + url: string; + last_converted_at: string | null; +}; + +export class GetGoogleDriveUploadsUseCase { + constructor(private readonly repository: GoogleDriveRepository) {} + + async execute( + owner: number, + limit: number, + offset: number + ): Promise { + const rows = await this.repository.getByOwner(owner, limit, offset); + return rows.map((row) => ({ + id: row.id, + iconUrl: row.iconUrl, + mimeType: row.mimeType, + name: row.name, + sizeBytes: row.sizeBytes, + url: row.url, + last_converted_at: row.last_converted_at, + })); + } +} From a7461af7eec04f82140f5cba72b4602f9d812c87 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 00:12:12 +0200 Subject: [PATCH 3/5] feat: From Google Drive section on Downloads page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a table (File / Size / Added / Actions) below the Dropbox section. Hidden when empty. Each row: file icon with onError fallback to a generic SVG, filename, formatted size (handles Kanel's string bigint), relative time from last_converted_at, "Open in Drive ↗" external link, × remove. Icon and link sources are both sanitized — only Google CDN hosts render inline; only drive.google.com and docs.google.com URLs become live links. Anything else falls back to the generic icon or a disabled "Link unavailable" span. List defaults to 10 newest with "Show older Google Drive files" to expand by 20. Co-Authored-By: Claude Opus 4.7 --- web/public/icons/file-generic.svg | 4 + web/src/lib/backend/Backend.ts | 23 ++++ web/src/lib/backend/index.ts | 2 +- .../DownloadsPage/DownloadsPage.test.tsx | 11 ++ web/src/pages/DownloadsPage/DownloadsPage.tsx | 3 + .../components/GoogleDriveHistoryEntry.tsx | 127 ++++++++++++++++++ .../components/GoogleDriveHistorySection.tsx | 96 +++++++++++++ .../hooks/useGoogleDriveUploads.test.tsx | 84 ++++++++++++ .../hooks/useGoogleDriveUploads.tsx | 64 +++++++++ 9 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 web/public/icons/file-generic.svg create mode 100644 web/src/pages/DownloadsPage/components/GoogleDriveHistoryEntry.tsx create mode 100644 web/src/pages/DownloadsPage/components/GoogleDriveHistorySection.tsx create mode 100644 web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.test.tsx create mode 100644 web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.tsx diff --git a/web/public/icons/file-generic.svg b/web/public/icons/file-generic.svg new file mode 100644 index 000000000..05c25cec5 --- /dev/null +++ b/web/public/icons/file-generic.svg @@ -0,0 +1,4 @@ + diff --git a/web/src/lib/backend/Backend.ts b/web/src/lib/backend/Backend.ts index d084588a6..9fd7d761f 100644 --- a/web/src/lib/backend/Backend.ts +++ b/web/src/lib/backend/Backend.ts @@ -263,6 +263,19 @@ export class Backend { } } + async getGoogleDriveUploads(offset = 0): Promise { + return get(`${this.baseURL}upload/google_drive/mine?offset=${offset}`); + } + + async deleteGoogleDriveUpload(id: string): Promise { + const response = await del( + `${this.baseURL}upload/google_drive/mine/${encodeURIComponent(id)}` + ); + if (!response?.ok) { + throw new Error('Failed to delete Google Drive upload'); + } + } + async getJobs(): Promise { return get(`${this.baseURL}upload/jobs`); } @@ -813,6 +826,16 @@ export interface DropboxUpload { created_at: string | null; } +export interface GoogleDriveUpload { + id: string; + iconUrl: string; + mimeType: string; + name: string; + sizeBytes: string | null; + url: string; + last_converted_at: string | null; +} + export interface ContactMessage { id: number; name: string; diff --git a/web/src/lib/backend/index.ts b/web/src/lib/backend/index.ts index 9120143b9..779e3cbc7 100644 --- a/web/src/lib/backend/index.ts +++ b/web/src/lib/backend/index.ts @@ -1,2 +1,2 @@ export { Backend as default } from './Backend'; -export type { DropboxUpload } from './Backend'; +export type { DropboxUpload, GoogleDriveUpload } from './Backend'; diff --git a/web/src/pages/DownloadsPage/DownloadsPage.test.tsx b/web/src/pages/DownloadsPage/DownloadsPage.test.tsx index 5f3d50d05..ff850814f 100644 --- a/web/src/pages/DownloadsPage/DownloadsPage.test.tsx +++ b/web/src/pages/DownloadsPage/DownloadsPage.test.tsx @@ -46,6 +46,17 @@ vi.mock('./hooks/useDropboxUploads', () => ({ }), })); +vi.mock('./hooks/useGoogleDriveUploads', () => ({ + default: () => ({ + uploads: [], + loading: false, + error: false, + deleteUpload: vi.fn(), + loadMore: vi.fn(), + hasMore: false, + }), +})); + type AnalyticsGlobals = { hj?: ReturnType; gtag?: ReturnType; diff --git a/web/src/pages/DownloadsPage/DownloadsPage.tsx b/web/src/pages/DownloadsPage/DownloadsPage.tsx index 6f9d39fdc..848f873fb 100644 --- a/web/src/pages/DownloadsPage/DownloadsPage.tsx +++ b/web/src/pages/DownloadsPage/DownloadsPage.tsx @@ -7,6 +7,7 @@ import useJobs from './hooks/useJobs'; import { SkeletonList } from '../../components/Skeleton/Skeleton'; import { FinishedJobs } from './components/FinishedJobs'; import { DropboxHistorySection } from './components/DropboxHistorySection'; +import { GoogleDriveHistorySection } from './components/GoogleDriveHistorySection'; import { EmptyDownloadsSection } from './components/EmptyDownloadsSection'; import { redirectOnError } from '../../components/shared/redirectOnError'; import { UnfinishedJobsInfo } from './components/UnfinishedJobsInfo'; @@ -133,6 +134,8 @@ export function DownloadsPage({ setError }: Readonly) { /> + + )} diff --git a/web/src/pages/DownloadsPage/components/GoogleDriveHistoryEntry.tsx b/web/src/pages/DownloadsPage/components/GoogleDriveHistoryEntry.tsx new file mode 100644 index 000000000..b73fcf40c --- /dev/null +++ b/web/src/pages/DownloadsPage/components/GoogleDriveHistoryEntry.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; + +import { GoogleDriveUpload } from '../../../lib/backend'; +import { getDistance } from '../../../lib/getDistance'; +import styles from '../DownloadsPage.module.css'; + +interface Props { + upload: GoogleDriveUpload; + onDelete: (id: string) => Promise; + isDeleting: boolean; +} + +const GENERIC_ICON = '/icons/file-generic.svg'; +const ALLOWED_ICON_HOSTS = new Set([ + 'drive-thirdparty.googleusercontent.com', + 'ssl.gstatic.com', + 'lh3.googleusercontent.com', +]); +const ALLOWED_LINK_HOSTS = new Set(['drive.google.com', 'docs.google.com']); + +function formatSize(sizeBytes: string | null): string { + if (sizeBytes == null) return '—'; + const n = Number(sizeBytes); + if (!Number.isFinite(n) || n <= 0) return '—'; + if (n >= 1024 * 1024) { + return `${(n / (1024 * 1024)).toFixed(1)} MB`; + } + return `${Math.round(n / 1024)} KB`; +} + +function truncateName(name: string, maxLength: number): string { + if (name.length <= maxLength) return name; + return `${name.slice(0, maxLength)}…`; +} + +function safeIconSrc(iconUrl: string): string { + try { + const u = new URL(iconUrl); + if (u.protocol !== 'https:') return GENERIC_ICON; + return ALLOWED_ICON_HOSTS.has(u.host) ? iconUrl : GENERIC_ICON; + } catch { + return GENERIC_ICON; + } +} + +function safeDriveLink(url: string): string | null { + try { + const u = new URL(url); + if (u.protocol !== 'https:') return null; + return ALLOWED_LINK_HOSTS.has(u.host) ? url : null; + } catch { + return null; + } +} + +export function GoogleDriveHistoryEntry({ + upload, + onDelete, + isDeleting, +}: Readonly) { + const [iconSrc, setIconSrc] = useState(safeIconSrc(upload.iconUrl)); + const driveHref = safeDriveLink(upload.url); + + return ( + + +
+ setIconSrc(GENERIC_ICON)} + style={{ flexShrink: 0 }} + /> + + {truncateName(upload.name, 40)} + +
+ + {formatSize(upload.sizeBytes)} + + {upload.last_converted_at == null + ? '—' + : `${getDistance(upload.last_converted_at)} ago`} + + +
+ {driveHref == null ? ( + + Open in Drive ↗ + + ) : ( + + Open in Drive ↗ + + )} + +
+ + + ); +} diff --git a/web/src/pages/DownloadsPage/components/GoogleDriveHistorySection.tsx b/web/src/pages/DownloadsPage/components/GoogleDriveHistorySection.tsx new file mode 100644 index 000000000..1d55e465e --- /dev/null +++ b/web/src/pages/DownloadsPage/components/GoogleDriveHistorySection.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; + +import Backend, { GoogleDriveUpload } from '../../../lib/backend'; +import useGoogleDriveUploads from '../hooks/useGoogleDriveUploads'; +import { GoogleDriveHistoryEntry } from './GoogleDriveHistoryEntry'; +import styles from '../DownloadsPage.module.css'; + +interface Props { + backend: Backend; +} + +export function GoogleDriveHistorySection({ backend }: Readonly) { + const { uploads, loading, error, deleteUpload, loadMore, hasMore } = + useGoogleDriveUploads(backend); + const [deletingId, setDeletingId] = useState(null); + + if (loading) return null; + + if (error) { + return ( +
+
+

From Google Drive

+
+

+ We couldn't load your Google Drive history. Refresh the page to + try again. +

+
+ ); + } + + if (uploads.length === 0) return null; + + const handleDelete = async (id: string) => { + setDeletingId(id); + try { + await deleteUpload(id); + } finally { + setDeletingId(null); + } + }; + + return ( +
+
+

From Google Drive

+
+

+ Files you picked from Google Drive. Open them in Drive or remove them + from this list. +

+
+
+ + + + + + + + + + {uploads.map((upload: GoogleDriveUpload) => ( + + ))} + +
FileSizeAdded +
+
+ {hasMore && ( +
+ +
+ )} +
+
+ ); +} diff --git a/web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.test.tsx b/web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.test.tsx new file mode 100644 index 000000000..c75603c30 --- /dev/null +++ b/web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.test.tsx @@ -0,0 +1,84 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import Backend, { GoogleDriveUpload } from '../../../lib/backend'; +import useGoogleDriveUploads from './useGoogleDriveUploads'; + +function makeRow(overrides: Partial = {}): GoogleDriveUpload { + return { + id: 'abc', + iconUrl: 'https://drive-thirdparty.googleusercontent.com/16/type/pdf', + mimeType: 'application/pdf', + name: 'biology.pdf', + sizeBytes: '2457600', + url: 'https://drive.google.com/file/d/abc/view', + last_converted_at: '2026-05-14T00:00:00Z', + ...overrides, + }; +} + +function makeBackend(overrides: Partial = {}): Backend { + return { + getGoogleDriveUploads: vi.fn().mockResolvedValue([]), + deleteGoogleDriveUpload: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as Backend; +} + +describe('useGoogleDriveUploads', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('starts in loading state, then renders empty list', async () => { + const backend = makeBackend(); + const { result } = renderHook(() => useGoogleDriveUploads(backend)); + + expect(result.current.loading).toBe(true); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.uploads).toEqual([]); + expect(result.current.hasMore).toBe(false); + expect(result.current.error).toBe(false); + }); + + it('renders populated list and exposes hasMore when page is full', async () => { + const rows = Array.from({ length: 10 }, (_, i) => + makeRow({ id: `row-${i}`, name: `file-${i}.pdf` }) + ); + const backend = makeBackend({ + getGoogleDriveUploads: vi.fn().mockResolvedValue(rows), + } as Partial); + const { result } = renderHook(() => useGoogleDriveUploads(backend)); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.uploads).toHaveLength(10); + expect(result.current.hasMore).toBe(true); + }); + + it('sets error when backend throws', async () => { + const backend = makeBackend({ + getGoogleDriveUploads: vi.fn().mockRejectedValue(new Error('boom')), + } as Partial); + const { result } = renderHook(() => useGoogleDriveUploads(backend)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe(true); + }); + + it('deleteUpload removes the row by string id', async () => { + const initial = [makeRow({ id: 'keep' }), makeRow({ id: 'remove-me' })]; + const deleteSpy = vi.fn().mockResolvedValue(undefined); + const backend = makeBackend({ + getGoogleDriveUploads: vi.fn().mockResolvedValue(initial), + deleteGoogleDriveUpload: deleteSpy, + } as Partial); + const { result } = renderHook(() => useGoogleDriveUploads(backend)); + + await waitFor(() => expect(result.current.uploads).toHaveLength(2)); + await act(async () => { + await result.current.deleteUpload('remove-me'); + }); + + expect(deleteSpy).toHaveBeenCalledWith('remove-me'); + expect(result.current.uploads.map((u) => u.id)).toEqual(['keep']); + }); +}); diff --git a/web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.tsx b/web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.tsx new file mode 100644 index 000000000..e4e6fa0ac --- /dev/null +++ b/web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react'; + +import Backend, { GoogleDriveUpload } from '../../../lib/backend'; + +interface UseGoogleDriveUploads { + uploads: GoogleDriveUpload[]; + loading: boolean; + error: boolean; + deleteUpload: (id: string) => Promise; + loadMore: () => Promise; + hasMore: boolean; +} + +const PAGE_SIZE = 10; +const LOAD_MORE_SIZE = 20; + +export default function useGoogleDriveUploads( + backend: Backend +): UseGoogleDriveUploads { + const [uploads, setUploads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + backend + .getGoogleDriveUploads(0) + .then((data) => { + setUploads(data); + setHasMore(data.length >= PAGE_SIZE); + }) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }, [backend]); + + const deleteUpload = async (id: string) => { + if (isDeleting) return; + setIsDeleting(true); + try { + await backend.deleteGoogleDriveUpload(id); + setUploads((prev) => prev.filter((u) => u.id !== id)); + } catch { + setError(true); + } finally { + setIsDeleting(false); + } + }; + + const loadMore = async () => { + const nextOffset = offset + LOAD_MORE_SIZE; + try { + const data = await backend.getGoogleDriveUploads(nextOffset); + setUploads((prev) => [...prev, ...data]); + setOffset(nextOffset); + setHasMore(data.length >= PAGE_SIZE); + } catch { + setError(true); + } + }; + + return { uploads, loading, error, deleteUpload, loadMore, hasMore }; +} From ed20a095ed5e8f842a7f17fa9b348d318cead5b4 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 00:12:24 +0200 Subject: [PATCH 4/5] chore: add changelog entry for Google Drive upload history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every user-visible PR ships its What's New line in the same PR — that way the page is current the moment the feature lands, and the entry doesn't get forgotten in a later backfill. Co-Authored-By: Claude Opus 4.7 --- web/src/pages/WhatsNewPage/changelog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/pages/WhatsNewPage/changelog.ts b/web/src/pages/WhatsNewPage/changelog.ts index 2f40b773b..181d17378 100644 --- a/web/src/pages/WhatsNewPage/changelog.ts +++ b/web/src/pages/WhatsNewPage/changelog.ts @@ -5,6 +5,7 @@ export interface ChangelogEntry { } export const changelog: ChangelogEntry[] = [ + { type: 'feature', title: 'From Google Drive section on Downloads — see the files you picked from Drive and open any of them with one click', date: '2026-05-16' }, { type: 'style', title: 'Upload form has tabs — Your computer and Dropbox sit side by side at the top of the page', date: '2026-05-15' }, { type: 'feature', title: 'Upload form: pick a file from Dropbox in one click', date: '2026-05-15' }, { type: 'feature', title: 'From Dropbox section on Downloads shows the files you\'ve picked from Dropbox', date: '2026-05-15' }, From 01eca1351a79f573f18f37b6ded3f2fabbadbe4a Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 16 May 2026 00:12:24 +0200 Subject: [PATCH 5/5] chore: remove implemented spec for Google Drive upload history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo policy keeps Documentation/specs/ small — specs live only while in flight. The spec text remains recoverable from this branch's history via `git log -p -- Documentation/specs/google-drive-upload-history.md`. Co-Authored-By: Claude Opus 4.7 --- .../specs/google-drive-upload-history.md | 134 ------------------ 1 file changed, 134 deletions(-) delete mode 100644 Documentation/specs/google-drive-upload-history.md diff --git a/Documentation/specs/google-drive-upload-history.md b/Documentation/specs/google-drive-upload-history.md deleted file mode 100644 index 7066ece6b..000000000 --- a/Documentation/specs/google-drive-upload-history.md +++ /dev/null @@ -1,134 +0,0 @@ -# Spec: Google Drive Upload History - -### Trio synthesis -- **PM:** Read-only history list with "Open in Drive" link; "Convert again" deferred (OAuth token not stored); gate on ≥15% click-through before building re-convert; `last_converted_at` migration needed. -- **Designer:** Fourth section on `/downloads`, below "From Dropbox", using a table layout (File / Size / Added / Actions) matching FinishedJobs; hide when empty; sanitize `url` to Drive origins only. -- **Engineer:** S+ effort; string PK needs regex validation; upsert semantics make `created_at` alone misleading — `last_converted_at` column is the right recency signal; `sizeBytes` arrives as string from Kanel. -- **Agreement:** "Convert again" out of phase 1; ownership enforcement non-negotiable; migration before ship; section hidden when empty. -- **Conflict:** PM suggested sorting by `lastEditedUtc DESC`; Engineer prefers `last_converted_at`. **Resolution:** `last_converted_at` — Drive's edit time is the file owner's clock, not the user's conversion history. Designer used table layout vs. Dropbox's simpler row layout. **Resolution:** table layout matches the existing FinishedJobs pattern on the same page. -- **Resulting plan:** Add "From Google Drive" table section to Downloads (read + delete + `last_converted_at` migration), `Open in Drive` external link per row, no re-convert in phase 1. - ---- - -## Outcome - -Logged-in users who have uploaded via Google Drive can see those files in a "From Google Drive" section on the Downloads page, with a link to open each file in Drive and an option to remove it from the list. Returning users who want to re-convert can navigate to Drive in one click instead of hunting through folders. Aligns with the mission: fastest path from studying to Anki flashcards — repeat conversions start with one click. - -## Problem statement - -A user uploads a 40-slide Google Slides deck on Monday, gets a `.apkg`, closes the tab. On Wednesday they want to regenerate it after editing the slides. Today they must re-open the Google Picker and navigate three folders deep. Meanwhile, the file's `id`, `name`, `iconUrl`, `mimeType`, and `url` have been in `google_drive_uploads` since the first upload — never shown. - -## Riskiest assumption + smallest test - -**Assumption:** Drive users want to revisit files they've already converted (vs. each upload being a one-shot assignment they never return to). - -**Validation test:** Ship the read-only history section for one week. Instrument clicks on "Open in Drive". If fewer than 15% of users who view the section click any row, kill the feature before building re-convert. No OAuth, no new storage, no irreversible schema changes. - -## Scope - -**In (PR 1 — history + delete):** -- `last_converted_at timestamptz default now()` migration on `google_drive_uploads`, updated on every upsert -- `GET /api/upload/google_drive/mine` — authenticated user's rows, `ORDER BY last_converted_at DESC NULLS LAST`, limit 10, optional `offset` for "Show older" -- `DELETE /api/upload/google_drive/mine/:id` — removes a single row (ownership-checked; string PK validated with `/^[A-Za-z0-9_-]+$/`) -- "From Google Drive" table section on `/downloads` (fourth section, below "From Dropbox") -- Each row: file icon (with CDN fallback), filename, formatted size, relative time, "Open in Drive ↗" link, × remove -- Section hidden when empty; inline error state -- `url` field sanitized to `https://drive.google.com/` and `https://docs.google.com/` origins only before rendering - -**In (PR 2 — re-convert):** -- "Convert again" button that triggers Google re-auth and re-runs the conversion pipeline using the stored `id` - -**Out:** -- Search, filter, pagination beyond "Show older" -- Storing OAuth tokens or refresh tokens -- Dropbox, Notion, or direct-upload history (separate specs) -- Folder entries (`mimeType = 'application/vnd.google-apps.folder'`) — excluded from query -- Thumbnail/preview generation -- Feature flag / staged rollout (ship to all authenticated users at once) - -## User story + acceptance criteria - -As a logged-in user who has uploaded from Google Drive, I want to see those files listed on the Downloads page so I can open the source in Drive without hunting through folders. - -- [ ] Authenticated user with ≥1 `google_drive_uploads` row sees "From Google Drive" section on `/downloads` -- [ ] Section is hidden entirely when the user has no rows -- [ ] Folder entries (`mimeType = 'application/vnd.google-apps.folder'`) are excluded -- [ ] Each row shows: file icon (onError → generic fallback), filename, formatted size (handles `sizeBytes` as string), relative time from `last_converted_at` (em-dash if null), "Open in Drive ↗" link, × remove -- [ ] "Open in Drive" `href` is sanitized — only `drive.google.com` and `docs.google.com` origins; disabled with title "Link unavailable" otherwise -- [ ] List defaults to 10 newest by `last_converted_at DESC NULLS LAST`; "Show older Google Drive files" expands by 20 -- [ ] `DELETE` endpoint enforces ownership; string PK validated against `/^[A-Za-z0-9_-]+$/`; returns 401 without session, 404 if wrong owner -- [ ] `GET` endpoint returns 401 without session; never returns another user's rows -- [ ] `last_converted_at` migration included; upsert path in `saveFiles` updates the column on every UPDATE -- [ ] Jest test: `getByOwner` returns only correct owner's rows, excludes folders, sorts by `last_converted_at DESC NULLS LAST` -- [ ] Jest test: `deleteByIdAndOwner` with mismatched owner deletes 0 rows; test with crafted ID string confirms parameterized query -- [ ] Vitest test: hook covers loading, empty, and populated states; delete uses string key - -## Leading indicator - -Return-upload rate among users with `google_drive_uploads` rows. Target: +5pp within 4 weeks. Secondary: ≥15% of history-section viewers click "Open in Drive" in their first session (validation gate for phase 2). - -## Design notes - -**Location:** Fourth section on `/downloads`, directly below "From Dropbox". - -**Table layout** (matching FinishedJobs, not the simpler Dropbox row layout): - -| File | Size | Added | Actions | -|---|---|---|---| -| [icon] biology-chapter-7.pdf | 2.4 MB | 3 days ago | [Open in Drive ↗] [×] | - -- **File icon:** 20 px `` with `onError` swap to `/icons/file-generic.svg`. Only allow Google CDN origins; fallback on disallowed origin. -- **Filename:** truncated with ellipsis, full name in `title` attr. -- **Size:** humanized string (handles Kanel's `string` type for `sizeBytes`). `—` if null. -- **Added:** relative time from `last_converted_at`. `—` if null (historical rows). -- **Open in Drive:** neutral outline button (`previewButton`), `target="_blank" rel="noreferrer noopener"`. Not primary — this page is a memory and doorway, not a CTA surface. -- **× Remove:** `iconButtonDanger`, hover red only. Removes the history entry; does not touch the file in Drive. - -**Section header copy:** -- Title: `From Google Drive` -- Subtitle: `Files you picked from Google Drive. Open them in Drive or remove them from this list.` - -**States:** -- Empty: hide section entirely (no empty-state card) -- List load error: `We couldn't load your Google Drive history. Refresh the page to try again.` -- Null `created_at`/`last_converted_at` cell: `—` -- Null `sizeBytes` cell: `—` -- Remove in-flight: row dims, × disabled (`aria-label="Removing…"`) - -**Reuse from `DownloadsPage.module.css`:** `styles.section`, `styles.sectionHeader`, `styles.sectionTitle`, `styles.sectionDescription`, `styles.card`, `styles.table`, `styles.fileName`, `styles.timeAgo`, `styles.actions`, `styles.iconButton`, `styles.iconButtonDanger`, `styles.previewButton`. No new CSS file needed. - -## Technical pre-flight - -**Layers touched:** data_layer → usecases → controllers → routes → web - -**Files in play (PR 1):** -- `src/data_layer/GoogleDriveRepository.ts` — add `getByOwner(owner, limit, offset)` and `deleteByIdAndOwner(id: string, owner: number)`; update `saveFiles` upsert to set `last_converted_at = now()` on UPDATE -- `src/usecases/uploads/GetGoogleDriveUploadsUseCase.ts` — new -- `src/controllers/Upload/UploadController.ts` — add `getGoogleDriveUploads` + `deleteGoogleDriveUpload` handlers -- `src/routes/UploadRouter.ts` — add `GET /api/upload/google_drive/mine` and `DELETE /api/upload/google_drive/mine/:id` behind `RequireAuthentication` -- `migrations/_add_last_converted_at_to_google_drive_uploads.js` — additive `last_converted_at timestamptz default now()`, nullable; add index on `owner` if missing -- `web/src/pages/DownloadsPage/DownloadsPage.tsx` — render `` below Dropbox section -- `web/src/pages/DownloadsPage/components/GoogleDriveHistorySection.tsx` — new table component -- `web/src/pages/DownloadsPage/hooks/useGoogleDriveUploads.tsx` — new (mirrors `useDropboxUploads.tsx`) -- `web/src/lib/backend/Backend.ts` — add `getGoogleDriveUploads()` and `deleteGoogleDriveUpload(id: string)` -- `web/src/lib/formatBytes.ts` — new helper (handles string input from Kanel's bigint mapping) - -**Key differences from Dropbox implementation:** -1. PK is `string` — `deleteByIdAndOwner` takes `string`, validate with `/^[A-Za-z0-9_-]+$/` before querying -2. No auto-increment — sort by `last_converted_at DESC NULLS LAST`, not `id DESC` -3. Upsert semantics — `saveFiles` UPDATE branch must `SET last_converted_at = now()` -4. `sizeBytes` is Kanel `string` (bigint) — formatting helper must accept string input -5. Folder exclusion — `WHERE mimeType != 'application/vnd.google-apps.folder'` in `getByOwner` -6. `url` field sanitization — only `drive.google.com` / `docs.google.com` origins before rendering - -**Effort:** S+ (slightly larger than Dropbox's S due to upsert/sort complexity and string PK handling) - -**Cross-language:** None. - -**Migration:** Additive `last_converted_at timestamptz default now()` nullable. Historical rows get NULL (sort last). The upsert UPDATE path in `saveFiles` must be updated to touch `last_converted_at` or ordering will be misleading for repeat converters. - -**Open questions before work starts:** -1. **`url` safety:** Is the stored `url` always a stable `https://drive.google.com/file/d//view` form, or can it contain OAuth tokens? Determines whether to include it in phase 1 response. -2. **`owner` index:** Does the migration need to add `CREATE INDEX ON google_drive_uploads(owner)`? Check existing migration for missing index. -3. **Paywall:** Is history gated (free vs. paying) or open to all authenticated users? (Same open question as Dropbox spec — needs resolution before either ships.) -4. **Validation gate:** Run `SELECT owner, id, COUNT(*) FROM google_drive_uploads GROUP BY owner, id HAVING COUNT(*) > 1` on prod to measure re-conversion rate before cutting the branch.