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, + })); + } +} 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 }; +} 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' },