From 0673658df4d27186b35c97a496cd39f674221286 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:51:58 +0200 Subject: [PATCH 01/76] basis for drive support --- apps/web/package.json | 1 + apps/web/prisma/schema.prisma | 82 +++++++ apps/web/utils/drive/provider.ts | 40 ++++ apps/web/utils/drive/providers/google.ts | 228 +++++++++++++++++++ apps/web/utils/drive/providers/microsoft.ts | 229 ++++++++++++++++++++ apps/web/utils/drive/scopes.ts | 24 ++ apps/web/utils/drive/types.ts | 115 ++++++++++ pnpm-lock.yaml | 13 ++ 8 files changed, 732 insertions(+) create mode 100644 apps/web/utils/drive/provider.ts create mode 100644 apps/web/utils/drive/providers/google.ts create mode 100644 apps/web/utils/drive/providers/microsoft.ts create mode 100644 apps/web/utils/drive/scopes.ts create mode 100644 apps/web/utils/drive/types.ts diff --git a/apps/web/package.json b/apps/web/package.json index b6840d0883..5a15a0e68f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,6 +32,7 @@ "@dub/analytics": "0.0.32", "@formkit/auto-animate": "0.9.0", "@googleapis/calendar": "^14.0.0", + "@googleapis/drive": "20.0.0", "@googleapis/gmail": "16.1.0", "@googleapis/people": "6.0.0", "@headlessui/react": "2.2.9", diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index f5047bcea3..cecc28e4ff 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -175,6 +175,8 @@ model EmailAccount { calendarConnections CalendarConnection[] mcpConnections McpConnection[] meetingBriefings MeetingBriefing[] + driveConnections DriveConnection[] + documentFilings DocumentFiling[] @@index([userId]) @@index([lastSummaryEmailAt]) @@ -951,6 +953,78 @@ model MeetingBriefing { @@index([emailAccountId]) } +// Drive connection for document auto-organization (Google Drive or OneDrive/SharePoint) +// One connection per provider - through that connection, user can access all their folders including shared ones +model DriveConnection { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + provider String // "google" or "microsoft" + email String // Account email used to connect + accessToken String? + refreshToken String? + expiresAt DateTime? + isConnected Boolean @default(true) + + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) + + filingDestinations FilingDestination[] + documentFilings DocumentFiling[] + + @@unique([emailAccountId, provider]) // One Google Drive, one OneDrive per email account + @@index([emailAccountId]) +} + +// Maps document types to specific folders (like Label maps to folder) +model FilingDestination { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + documentType String // "contract", "financial", "due_diligence", "correspondence", etc. + folderId String // Drive folder ID (Google Drive or OneDrive) + folderName String // Display name for the folder + folderPath String? // Full path for display (e.g., "/Projects/Acme Corp/Contracts") + + driveConnectionId String + driveConnection DriveConnection @relation(fields: [driveConnectionId], references: [id], onDelete: Cascade) + + @@unique([driveConnectionId, documentType]) // One folder per document type per connection + @@index([driveConnectionId]) +} + +// Tracks documents that have been filed or are pending confirmation +model DocumentFiling { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + + // Source email attachment + messageId String + attachmentId String + filename String + + // Filing decision + documentType String? // AI-determined document type + confidence Float // 0-1 confidence score + status DocumentFilingStatus @default(PENDING) + + // Result (after filing) + folderId String? // Where it was filed + driveFileId String? // File ID in drive (for undo) + + driveConnectionId String + driveConnection DriveConnection @relation(fields: [driveConnectionId], references: [id], onDelete: Cascade) + + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) + + @@index([emailAccountId, status]) + @@index([driveConnectionId]) + @@index([messageId]) +} + model Referral { id String @id @default(cuid()) createdAt DateTime @default(now()) @@ -1077,6 +1151,14 @@ enum ColdEmailStatus { USER_REJECTED_COLD } +enum DocumentFilingStatus { + PENDING // Awaiting user confirmation (low confidence) + FILED // Auto-filed with high confidence + CONFIRMED // User confirmed a pending filing + REJECTED // User rejected the filing suggestion + ERROR // Filing failed due to an error +} + // @deprecated - No longer used enum ColdEmailSetting { DISABLED diff --git a/apps/web/utils/drive/provider.ts b/apps/web/utils/drive/provider.ts new file mode 100644 index 0000000000..9d4b316187 --- /dev/null +++ b/apps/web/utils/drive/provider.ts @@ -0,0 +1,40 @@ +import type { DriveConnection } from "@/generated/prisma/client"; +import { + isGoogleProvider, + isMicrosoftProvider, +} from "@/utils/email/provider-types"; +import type { DriveProvider } from "@/utils/drive/types"; +import type { Logger } from "@/utils/logger"; +import { OneDriveProvider } from "@/utils/drive/providers/microsoft"; +import { GoogleDriveProvider } from "@/utils/drive/providers/google"; + +/** + * Factory function to create the appropriate DriveProvider based on connection type. + * Follows the same pattern as createEmailProvider. + * + * Note: This creates a provider with the current access token. For long-running + * operations, use createDriveProviderWithRefresh to handle token expiration. + */ +export function createDriveProvider( + connection: Pick, + logger: Logger, +): DriveProvider { + const { provider, accessToken } = connection; + + if (!accessToken) { + throw new Error("No access token available for drive connection"); + } + + if (isMicrosoftProvider(provider)) { + return new OneDriveProvider(accessToken, logger); + } + + if (isGoogleProvider(provider)) { + return new GoogleDriveProvider(accessToken, logger); + } + + throw new Error(`Unsupported drive provider: ${provider}`); +} + +// TODO: Add createDriveProviderWithRefresh for handling token expiration +// This will be similar to getOutlookClientWithRefresh but for drive connections diff --git a/apps/web/utils/drive/providers/google.ts b/apps/web/utils/drive/providers/google.ts new file mode 100644 index 0000000000..4e36f676e8 --- /dev/null +++ b/apps/web/utils/drive/providers/google.ts @@ -0,0 +1,228 @@ +import { auth, drive, type drive_v3 } from "@googleapis/drive"; +import { Readable } from "node:stream"; +import { env } from "@/env"; +import type { Logger } from "@/utils/logger"; +import { createScopedLogger } from "@/utils/logger"; +import type { + DriveProvider, + DriveFolder, + DriveFile, + UploadFileParams, +} from "@/utils/drive/types"; + +/** + * Google Drive provider using Google Drive API v3 + * Implements DriveProvider interface for consistent abstraction + * + * Note: Requires @googleapis/drive package to be installed: + * pnpm add @googleapis/drive --filter web + */ +export class GoogleDriveProvider implements DriveProvider { + readonly name = "google" as const; + private readonly client: drive_v3.Drive; + private readonly accessToken: string; + private readonly logger: Logger; + + constructor(accessToken: string, logger?: Logger) { + this.accessToken = accessToken; + this.logger = (logger || createScopedLogger("google-drive-provider")).with({ + provider: "google", + }); + + const googleAuth = new auth.OAuth2({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + }); + googleAuth.setCredentials({ + access_token: accessToken, + }); + + this.client = drive({ version: "v3", auth: googleAuth }); + } + + toJSON() { + return { name: this.name, type: "GoogleDriveProvider" }; + } + + getAccessToken(): string { + return this.accessToken; + } + + // ------------------------------------------------------------------------- + // Folder Operations + // ------------------------------------------------------------------------- + + async listFolders(parentId?: string): Promise { + this.logger.trace("Listing folders", { parentId }); + + try { + // Query for folders only + // 'root' is the special ID for the root folder + const parent = parentId || "root"; + const query = `'${parent}' in parents and mimeType = 'application/vnd.google-apps.folder' and trashed = false`; + + const response = await this.client.files.list({ + q: query, + fields: "files(id, name, parents, webViewLink)", + pageSize: 100, + orderBy: "name", + }); + + const files = response.data.files || []; + + return files.map((file) => this.convertToFolder(file)); + } catch (error) { + this.logger.error("Error listing folders", { error, parentId }); + throw error; + } + } + + async getFolder(folderId: string): Promise { + this.logger.trace("Getting folder", { folderId }); + + try { + const response = await this.client.files.get({ + fileId: folderId, + fields: "id, name, parents, webViewLink, mimeType", + }); + + const file = response.data; + + // Check if it's actually a folder + if (file.mimeType !== "application/vnd.google-apps.folder") { + this.logger.warn("Item is not a folder", { folderId }); + return null; + } + + return this.convertToFolder(file); + } catch (error) { + if (this.isNotFoundError(error)) { + this.logger.trace("Folder not found", { folderId }); + return null; + } + this.logger.error("Error getting folder", { error, folderId }); + throw error; + } + } + + async createFolder(name: string, parentId?: string): Promise { + this.logger.info("Creating folder", { name, parentId }); + + try { + const response = await this.client.files.create({ + requestBody: { + name, + mimeType: "application/vnd.google-apps.folder", + parents: parentId ? [parentId] : undefined, + }, + fields: "id, name, parents, webViewLink", + }); + + return this.convertToFolder(response.data); + } catch (error) { + this.logger.error("Error creating folder", { error, name, parentId }); + throw error; + } + } + + // ------------------------------------------------------------------------- + // File Operations + // ------------------------------------------------------------------------- + + async uploadFile(params: UploadFileParams): Promise { + const { filename, mimeType, content, folderId } = params; + this.logger.info("Uploading file", { + filename, + mimeType, + folderId, + size: content.length, + }); + + try { + // Convert Buffer to Readable stream for the API + const stream = Readable.from(content); + + const response = await this.client.files.create({ + requestBody: { + name: filename, + parents: [folderId], + }, + media: { + mimeType, + body: stream, + }, + fields: "id, name, mimeType, size, parents, webViewLink, createdTime", + }); + + return this.convertToFile(response.data); + } catch (error) { + this.logger.error("Error uploading file", { error, filename, folderId }); + throw error; + } + } + + async getFile(fileId: string): Promise { + this.logger.trace("Getting file", { fileId }); + + try { + const response = await this.client.files.get({ + fileId, + fields: "id, name, mimeType, size, parents, webViewLink, createdTime", + }); + + const file = response.data; + + // Check it's not a folder + if (file.mimeType === "application/vnd.google-apps.folder") { + this.logger.warn("Item is a folder, not a file", { fileId }); + return null; + } + + return this.convertToFile(file); + } catch (error) { + if (this.isNotFoundError(error)) { + this.logger.trace("File not found", { fileId }); + return null; + } + this.logger.error("Error getting file", { error, fileId }); + throw error; + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private convertToFolder(file: drive_v3.Schema$File): DriveFolder { + return { + id: file.id!, + name: file.name || "Untitled", + parentId: file.parents?.[0] ?? undefined, + // Google Drive doesn't provide full path directly, would need recursive calls + path: undefined, + webUrl: file.webViewLink ?? undefined, + }; + } + + private convertToFile(file: drive_v3.Schema$File): DriveFile { + return { + id: file.id!, + name: file.name || "Untitled", + mimeType: file.mimeType || "application/octet-stream", + size: file.size ? Number.parseInt(file.size) : undefined, + folderId: file.parents?.[0] ?? undefined, + webUrl: file.webViewLink ?? undefined, + createdAt: file.createdTime ? new Date(file.createdTime) : undefined, + }; + } + + private isNotFoundError(error: unknown): boolean { + if (error && typeof error === "object" && "code" in error) { + return (error as { code: number }).code === 404; + } + if (error && typeof error === "object" && "status" in error) { + return (error as { status: number }).status === 404; + } + return false; + } +} diff --git a/apps/web/utils/drive/providers/microsoft.ts b/apps/web/utils/drive/providers/microsoft.ts new file mode 100644 index 0000000000..7c80478404 --- /dev/null +++ b/apps/web/utils/drive/providers/microsoft.ts @@ -0,0 +1,229 @@ +import { Client } from "@microsoft/microsoft-graph-client"; +import type { DriveItem } from "@microsoft/microsoft-graph-types"; +import type { Logger } from "@/utils/logger"; +import { createScopedLogger } from "@/utils/logger"; +import type { + DriveProvider, + DriveFolder, + DriveFile, + UploadFileParams, +} from "@/utils/drive/types"; + +/** + * OneDrive/SharePoint provider using Microsoft Graph API + * Implements DriveProvider interface for consistent abstraction + */ +export class OneDriveProvider implements DriveProvider { + readonly name = "microsoft" as const; + private readonly client: Client; + private readonly accessToken: string; + private readonly logger: Logger; + + constructor(accessToken: string, logger?: Logger) { + this.accessToken = accessToken; + this.logger = (logger || createScopedLogger("onedrive-provider")).with({ + provider: "microsoft", + }); + + this.client = Client.init({ + authProvider: (done) => { + done(null, this.accessToken); + }, + defaultVersion: "v1.0", + }); + } + + toJSON() { + return { name: this.name, type: "OneDriveProvider" }; + } + + getAccessToken(): string { + return this.accessToken; + } + + // ------------------------------------------------------------------------- + // Folder Operations + // ------------------------------------------------------------------------- + + async listFolders(parentId?: string): Promise { + this.logger.trace("Listing folders", { parentId }); + + try { + const endpoint = parentId + ? `/me/drive/items/${parentId}/children` + : "/me/drive/root/children"; + + const response = await this.client + .api(endpoint) + .filter("folder ne null") // Only get folders, not files + .select("id,name,parentReference,webUrl") + .top(100) // Reasonable limit + .get(); + + const items: DriveItem[] = response.value || []; + + return items.map((item) => this.convertToFolder(item)); + } catch (error) { + this.logger.error("Error listing folders", { error, parentId }); + throw error; + } + } + + async getFolder(folderId: string): Promise { + this.logger.trace("Getting folder", { folderId }); + + try { + const item: DriveItem = await this.client + .api(`/me/drive/items/${folderId}`) + .select("id,name,parentReference,webUrl") + .get(); + + if (!item.folder) { + this.logger.warn("Item is not a folder", { folderId }); + return null; + } + + return this.convertToFolder(item); + } catch (error) { + // Handle not found + if (this.isNotFoundError(error)) { + this.logger.trace("Folder not found", { folderId }); + return null; + } + this.logger.error("Error getting folder", { error, folderId }); + throw error; + } + } + + async createFolder(name: string, parentId?: string): Promise { + this.logger.info("Creating folder", { name, parentId }); + + try { + const endpoint = parentId + ? `/me/drive/items/${parentId}/children` + : "/me/drive/root/children"; + + const item: DriveItem = await this.client.api(endpoint).post({ + name, + folder: {}, + "@microsoft.graph.conflictBehavior": "rename", // Rename if exists + }); + + return this.convertToFolder(item); + } catch (error) { + this.logger.error("Error creating folder", { error, name, parentId }); + throw error; + } + } + + // ------------------------------------------------------------------------- + // File Operations + // ------------------------------------------------------------------------- + + async uploadFile(params: UploadFileParams): Promise { + const { filename, mimeType, content, folderId } = params; + this.logger.info("Uploading file", { + filename, + mimeType, + folderId, + size: content.length, + }); + + try { + // For files up to 4MB, use simple upload + // For larger files, would need to use upload session (not implemented yet) + const MAX_SIMPLE_UPLOAD_SIZE = 4 * 1024 * 1024; // 4MB + + if (content.length > MAX_SIMPLE_UPLOAD_SIZE) { + // TODO: Implement resumable upload for large files + this.logger.warn("File exceeds simple upload limit", { + filename, + size: content.length, + limit: MAX_SIMPLE_UPLOAD_SIZE, + }); + throw new Error( + `File size ${content.length} exceeds 4MB limit. Large file upload not yet implemented.`, + ); + } + + // Use the PUT endpoint for simple upload + // Path: /me/drive/items/{parent-id}:/{filename}:/content + const item: DriveItem = await this.client + .api( + `/me/drive/items/${folderId}:/${encodeURIComponent(filename)}:/content`, + ) + .header("Content-Type", mimeType) + .put(content); + + return this.convertToFile(item); + } catch (error) { + this.logger.error("Error uploading file", { error, filename, folderId }); + throw error; + } + } + + async getFile(fileId: string): Promise { + this.logger.trace("Getting file", { fileId }); + + try { + const item: DriveItem = await this.client + .api(`/me/drive/items/${fileId}`) + .select("id,name,file,size,parentReference,webUrl,createdDateTime") + .get(); + + if (!item.file) { + this.logger.warn("Item is not a file", { fileId }); + return null; + } + + return this.convertToFile(item); + } catch (error) { + if (this.isNotFoundError(error)) { + this.logger.trace("File not found", { fileId }); + return null; + } + this.logger.error("Error getting file", { error, fileId }); + throw error; + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private convertToFolder(item: DriveItem): DriveFolder { + return { + id: item.id!, + name: item.name || "Untitled", + parentId: item.parentReference?.id ?? undefined, + path: item.parentReference?.path + ? `${item.parentReference.path}/${item.name}` + : undefined, + webUrl: item.webUrl ?? undefined, + }; + } + + private convertToFile(item: DriveItem): DriveFile { + return { + id: item.id!, + name: item.name || "Untitled", + mimeType: item.file?.mimeType ?? "application/octet-stream", + size: item.size ?? undefined, + folderId: item.parentReference?.id ?? undefined, + webUrl: item.webUrl ?? undefined, + createdAt: item.createdDateTime + ? new Date(item.createdDateTime) + : undefined, + }; + } + + private isNotFoundError(error: unknown): boolean { + if (error && typeof error === "object" && "statusCode" in error) { + return (error as { statusCode: number }).statusCode === 404; + } + if (error && typeof error === "object" && "code" in error) { + return (error as { code: string }).code === "itemNotFound"; + } + return false; + } +} diff --git a/apps/web/utils/drive/scopes.ts b/apps/web/utils/drive/scopes.ts new file mode 100644 index 0000000000..effe291441 --- /dev/null +++ b/apps/web/utils/drive/scopes.ts @@ -0,0 +1,24 @@ +// Microsoft Graph Drive scopes +// https://learn.microsoft.com/en-us/graph/permissions-reference#files-permissions + +export const MICROSOFT_DRIVE_SCOPES = [ + "openid", + "profile", + "email", + "User.Read", + "offline_access", // Required for refresh tokens + "Files.ReadWrite", // Read and write files in user's OneDrive + // Note: We intentionally don't request Files.ReadWrite.All (all files user can access) + // to minimize permissions. Files.ReadWrite covers OneDrive + shared files. +] as const; + +// Google Drive scopes +// https://developers.google.com/drive/api/guides/api-specific-auth + +export const GOOGLE_DRIVE_SCOPES = [ + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/drive.file", // Access files created by or opened with the app + // Note: We use drive.file instead of drive (full access) to minimize permissions + // This allows us to create files and access files the user explicitly opens with our app +] as const; diff --git a/apps/web/utils/drive/types.ts b/apps/web/utils/drive/types.ts new file mode 100644 index 0000000000..5ce6128143 --- /dev/null +++ b/apps/web/utils/drive/types.ts @@ -0,0 +1,115 @@ +// ============================================================================ +// Core Drive Types +// ============================================================================ + +export interface DriveFolder { + id: string; + name: string; + path?: string; // Full path for display (e.g., "/Projects/Acme Corp") + parentId?: string; + webUrl?: string; // Link to open in browser +} + +export interface DriveFile { + id: string; + name: string; + mimeType: string; + size?: number; + folderId?: string; + webUrl?: string; // Link to open in browser + createdAt?: Date; +} + +export interface UploadFileParams { + filename: string; + mimeType: string; + content: Buffer; + folderId: string; +} + +// ============================================================================ +// Drive Provider Interface +// ============================================================================ + +/** + * Abstraction for cloud drive operations (Google Drive / OneDrive) + * Follows the same pattern as EmailProvider. + * + * Note: We intentionally don't include delete operations to minimize + * permissions requested from users. "Undo" is handled by marking + * the filing as rejected in our database - the file stays in their drive. + */ +export interface DriveProvider { + readonly name: "google" | "microsoft"; + + /** + * For serialization/debugging + */ + toJSON(): { name: string; type: string }; + + // ------------------------------------------------------------------------- + // Folder Operations + // ------------------------------------------------------------------------- + + /** + * List folders in a parent folder (or root if no parentId) + */ + listFolders(parentId?: string): Promise; + + /** + * Get a specific folder by ID + */ + getFolder(folderId: string): Promise; + + /** + * Create a new folder + */ + createFolder(name: string, parentId?: string): Promise; + + // ------------------------------------------------------------------------- + // File Operations + // ------------------------------------------------------------------------- + + /** + * Upload a file to a folder + */ + uploadFile(params: UploadFileParams): Promise; + + /** + * Get file metadata by ID + */ + getFile(fileId: string): Promise; + + // ------------------------------------------------------------------------- + // Token Management + // ------------------------------------------------------------------------- + + /** + * Get the current access token (may trigger refresh if expired) + */ + getAccessToken(): string; +} + +// ============================================================================ +// OAuth Types +// ============================================================================ + +/** + * Tokens returned from OAuth code exchange. + * Used in callback routes when setting up a new DriveConnection. + */ +export interface DriveTokens { + accessToken: string; + refreshToken: string; + expiresAt: Date | null; + email: string; +} + +/** + * State passed through OAuth flow to identify the user/account. + */ +export interface DriveOAuthState { + emailAccountId: string; + type: "drive"; + nonce: string; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0077150d9..42b6df19a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: '@googleapis/calendar': specifier: ^14.0.0 version: 14.0.0 + '@googleapis/drive': + specifier: 20.0.0 + version: 20.0.0 '@googleapis/gmail': specifier: 16.1.0 version: 16.1.0 @@ -2785,6 +2788,10 @@ packages: resolution: {integrity: sha512-/5DtvL4jInnJzEOcfloHbDV8s6TwVziwbsf4NTKl55EluDKwRFTm2ymKfMie5dyXsuUf0VKrrTYWufcEjD2xaQ==} engines: {node: '>=12.0.0'} + '@googleapis/drive@20.0.0': + resolution: {integrity: sha512-qLi5ypZn0zYY2FcGjdlHQsv1DAFNRwCWFiE5kq23J0yTdUSZynh/mDph9NBaiQ9ybajrmttySR/rSaNfm8S/bA==} + engines: {node: '>=12.0.0'} + '@googleapis/gmail@16.1.0': resolution: {integrity: sha512-BMyqpAjC1Nj2Fv9DvLtn/tnXy0fHw7QabBNE7aU6SIXPfvSEKdUJ1fV1+iKDcCgEV+uLb5zY2J5sQSW1903lkQ==} engines: {node: '>=12.0.0'} @@ -15273,6 +15280,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@googleapis/drive@20.0.0': + dependencies: + googleapis-common: 8.0.0 + transitivePeerDependencies: + - supports-color + '@googleapis/gmail@16.1.0': dependencies: googleapis-common: 8.0.0 From e7c854cb7d2e6eeef386bfa876c4a79187422d96 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:57:15 +0200 Subject: [PATCH 02/76] refactor oauth for calendar and add drive support --- .../app/api/google/drive/auth-url/route.ts | 40 ++++ .../app/api/google/drive/callback/route.ts | 12 ++ .../app/api/outlook/drive/auth-url/route.ts | 33 ++++ .../app/api/outlook/drive/callback/route.ts | 12 ++ .../calendar/handle-calendar-callback.ts | 8 +- .../utils/calendar/oauth-callback-helpers.ts | 76 +------- apps/web/utils/drive/client.ts | 153 +++++++++++++++ apps/web/utils/drive/constants.ts | 1 + apps/web/utils/drive/handle-drive-callback.ts | 160 ++++++++++++++++ .../web/utils/drive/oauth-callback-helpers.ts | 181 ++++++++++++++++++ apps/web/utils/oauth/redirect.ts | 41 ++++ apps/web/utils/oauth/verify.ts | 41 ++++ 12 files changed, 681 insertions(+), 77 deletions(-) create mode 100644 apps/web/app/api/google/drive/auth-url/route.ts create mode 100644 apps/web/app/api/google/drive/callback/route.ts create mode 100644 apps/web/app/api/outlook/drive/auth-url/route.ts create mode 100644 apps/web/app/api/outlook/drive/callback/route.ts create mode 100644 apps/web/utils/drive/client.ts create mode 100644 apps/web/utils/drive/constants.ts create mode 100644 apps/web/utils/drive/handle-drive-callback.ts create mode 100644 apps/web/utils/drive/oauth-callback-helpers.ts create mode 100644 apps/web/utils/oauth/redirect.ts create mode 100644 apps/web/utils/oauth/verify.ts diff --git a/apps/web/app/api/google/drive/auth-url/route.ts b/apps/web/app/api/google/drive/auth-url/route.ts new file mode 100644 index 0000000000..afa302779a --- /dev/null +++ b/apps/web/app/api/google/drive/auth-url/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import { getGoogleDriveOAuth2Url } from "@/utils/drive/client"; +import { DRIVE_STATE_COOKIE_NAME } from "@/utils/drive/constants"; +import { + generateOAuthState, + oauthStateCookieOptions, +} from "@/utils/oauth/state"; + +export type GetDriveAuthUrlResponse = { url: string }; + +const getAuthUrl = ({ emailAccountId }: { emailAccountId: string }) => { + const state = generateOAuthState({ + emailAccountId, + type: "drive", + }); + + const url = getGoogleDriveOAuth2Url(state); + + return { url, state }; +}; + +export const GET = withEmailAccount( + "google/drive/auth-url", + async (request) => { + const { emailAccountId } = request.auth; + const { url, state } = getAuthUrl({ emailAccountId }); + + const res: GetDriveAuthUrlResponse = { url }; + const response = NextResponse.json(res); + + response.cookies.set( + DRIVE_STATE_COOKIE_NAME, + state, + oauthStateCookieOptions, + ); + + return response; + }, +); diff --git a/apps/web/app/api/google/drive/callback/route.ts b/apps/web/app/api/google/drive/callback/route.ts new file mode 100644 index 0000000000..4249365ef6 --- /dev/null +++ b/apps/web/app/api/google/drive/callback/route.ts @@ -0,0 +1,12 @@ +import { withError } from "@/utils/middleware"; +import { handleDriveCallback } from "@/utils/drive/handle-drive-callback"; +import { exchangeGoogleDriveCode } from "@/utils/drive/client"; + +const googleDriveProvider = { + name: "google" as const, + exchangeCodeForTokens: exchangeGoogleDriveCode, +}; + +export const GET = withError("google/drive/callback", async (request) => { + return handleDriveCallback(request, googleDriveProvider, request.logger); +}); diff --git a/apps/web/app/api/outlook/drive/auth-url/route.ts b/apps/web/app/api/outlook/drive/auth-url/route.ts new file mode 100644 index 0000000000..79224443bc --- /dev/null +++ b/apps/web/app/api/outlook/drive/auth-url/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { withEmailAccount } from "@/utils/middleware"; +import { getMicrosoftDriveOAuth2Url } from "@/utils/drive/client"; +import { DRIVE_STATE_COOKIE_NAME } from "@/utils/drive/constants"; +import { + generateOAuthState, + oauthStateCookieOptions, +} from "@/utils/oauth/state"; + +export type GetDriveAuthUrlResponse = { url: string }; + +const getAuthUrl = ({ emailAccountId }: { emailAccountId: string }) => { + const state = generateOAuthState({ + emailAccountId, + type: "drive", + }); + + const url = getMicrosoftDriveOAuth2Url(state); + + return { url, state }; +}; + +export const GET = withEmailAccount(async (request) => { + const { emailAccountId } = request.auth; + const { url, state } = getAuthUrl({ emailAccountId }); + + const res: GetDriveAuthUrlResponse = { url }; + const response = NextResponse.json(res); + + response.cookies.set(DRIVE_STATE_COOKIE_NAME, state, oauthStateCookieOptions); + + return response; +}); diff --git a/apps/web/app/api/outlook/drive/callback/route.ts b/apps/web/app/api/outlook/drive/callback/route.ts new file mode 100644 index 0000000000..f658044f8b --- /dev/null +++ b/apps/web/app/api/outlook/drive/callback/route.ts @@ -0,0 +1,12 @@ +import { withError } from "@/utils/middleware"; +import { handleDriveCallback } from "@/utils/drive/handle-drive-callback"; +import { exchangeMicrosoftDriveCode } from "@/utils/drive/client"; + +const microsoftDriveProvider = { + name: "microsoft" as const, + exchangeCodeForTokens: exchangeMicrosoftDriveCode, +}; + +export const GET = withError("outlook/drive/callback", async (request) => { + return handleDriveCallback(request, microsoftDriveProvider, request.logger); +}); diff --git a/apps/web/utils/calendar/handle-calendar-callback.ts b/apps/web/utils/calendar/handle-calendar-callback.ts index 89dbb41f33..ae48acedb4 100644 --- a/apps/web/utils/calendar/handle-calendar-callback.ts +++ b/apps/web/utils/calendar/handle-calendar-callback.ts @@ -6,13 +6,15 @@ import { validateOAuthCallback, parseAndValidateCalendarState, buildCalendarRedirectUrl, - verifyEmailAccountAccess, checkExistingConnection, createCalendarConnection, +} from "./oauth-callback-helpers"; +import { + RedirectError, redirectWithMessage, redirectWithError, - RedirectError, -} from "./oauth-callback-helpers"; +} from "@/utils/oauth/redirect"; +import { verifyEmailAccountAccess } from "@/utils/oauth/verify"; import { acquireOAuthCodeLock, getOAuthCodeResult, diff --git a/apps/web/utils/calendar/oauth-callback-helpers.ts b/apps/web/utils/calendar/oauth-callback-helpers.ts index a8de76f939..407c9f567b 100644 --- a/apps/web/utils/calendar/oauth-callback-helpers.ts +++ b/apps/web/utils/calendar/oauth-callback-helpers.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import prisma from "@/utils/prisma"; import { CALENDAR_STATE_COOKIE_NAME } from "@/utils/calendar/constants"; import { parseOAuthState } from "@/utils/oauth/state"; -import { auth } from "@/utils/auth"; import { prefixPath } from "@/utils/path"; import { env } from "@/env"; import type { Logger } from "@/utils/logger"; @@ -13,6 +12,8 @@ import type { CalendarOAuthState, } from "./oauth-types"; +import { RedirectError } from "@/utils/oauth/redirect"; + const calendarOAuthStateSchema = z.object({ emailAccountId: z.string().min(1).max(64), type: z.literal("calendar"), @@ -94,40 +95,6 @@ export function buildCalendarRedirectUrl(emailAccountId: string): URL { ); } -/** - * Verify user owns the email account - */ -export async function verifyEmailAccountAccess( - emailAccountId: string, - logger: Logger, - redirectUrl: URL, - responseHeaders: Headers, -): Promise { - const session = await auth(); - if (!session?.user?.id) { - logger.warn("Unauthorized calendar callback - no session"); - redirectUrl.searchParams.set("error", "unauthorized"); - throw new RedirectError(redirectUrl, responseHeaders); - } - - const emailAccount = await prisma.emailAccount.findFirst({ - where: { - id: emailAccountId, - userId: session.user.id, - }, - select: { id: true }, - }); - - if (!emailAccount) { - logger.warn("Unauthorized calendar callback - invalid email account", { - emailAccountId, - userId: session.user.id, - }); - redirectUrl.searchParams.set("error", "forbidden"); - throw new RedirectError(redirectUrl, responseHeaders); - } -} - /** * Check if calendar connection already exists */ @@ -168,42 +135,3 @@ export async function createCalendarConnection(params: { }, }); } - -/** - * Redirect with success message - */ -export function redirectWithMessage( - redirectUrl: URL, - message: string, - responseHeaders: Headers, -): NextResponse { - redirectUrl.searchParams.set("message", message); - return NextResponse.redirect(redirectUrl, { headers: responseHeaders }); -} - -/** - * Redirect with error message - */ -export function redirectWithError( - redirectUrl: URL, - error: string, - responseHeaders: Headers, -): NextResponse { - redirectUrl.searchParams.set("error", error); - return NextResponse.redirect(redirectUrl, { headers: responseHeaders }); -} - -/** - * Custom error class for redirect responses - */ -export class RedirectError extends Error { - redirectUrl: URL; - responseHeaders: Headers; - - constructor(redirectUrl: URL, responseHeaders: Headers) { - super("Redirect required"); - this.name = "RedirectError"; - this.redirectUrl = redirectUrl; - this.responseHeaders = responseHeaders; - } -} diff --git a/apps/web/utils/drive/client.ts b/apps/web/utils/drive/client.ts new file mode 100644 index 0000000000..913b8559bb --- /dev/null +++ b/apps/web/utils/drive/client.ts @@ -0,0 +1,153 @@ +import { auth } from "@googleapis/drive"; +import { env } from "@/env"; +import { GOOGLE_DRIVE_SCOPES, MICROSOFT_DRIVE_SCOPES } from "./scopes"; + +// ============================================================================ +// Google Drive OAuth +// ============================================================================ + +/** + * Creates an OAuth2 client for Google Drive authentication + */ +export function getGoogleDriveOAuth2Client() { + return new auth.OAuth2({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + redirectUri: `${env.NEXT_PUBLIC_BASE_URL}/api/google/drive/callback`, + }); +} + +/** + * Generates the OAuth2 URL for Google Drive + */ +export function getGoogleDriveOAuth2Url(state: string): string { + const oauth2Client = getGoogleDriveOAuth2Client(); + return oauth2Client.generateAuthUrl({ + access_type: "offline", + scope: [...GOOGLE_DRIVE_SCOPES], + state, + prompt: "consent", + }); +} + +/** + * Exchange Google OAuth code for tokens + */ +export async function exchangeGoogleDriveCode(code: string) { + const oauth2Client = getGoogleDriveOAuth2Client(); + const { tokens } = await oauth2Client.getToken(code); + + if (!tokens.access_token || !tokens.refresh_token) { + throw new Error("No access or refresh token returned from Google"); + } + + // Get user email from ID token + if (!tokens.id_token) { + throw new Error("No ID token returned from Google"); + } + + const ticket = await oauth2Client.verifyIdToken({ + idToken: tokens.id_token, + audience: env.GOOGLE_CLIENT_ID, + }); + const payload = ticket.getPayload(); + + if (!payload?.email) { + throw new Error("Could not get email from Google ID token"); + } + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null, + email: payload.email, + }; +} + +// ============================================================================ +// Microsoft OneDrive OAuth +// ============================================================================ + +/** + * Generates the OAuth2 URL for Microsoft OneDrive/SharePoint + */ +export function getMicrosoftDriveOAuth2Url(state: string): string { + if (!env.MICROSOFT_CLIENT_ID) { + throw new Error("Microsoft login not enabled - missing client ID"); + } + + const baseUrl = `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize`; + const params = new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + response_type: "code", + redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/drive/callback`, + scope: MICROSOFT_DRIVE_SCOPES.join(" "), + state, + }); + + return `${baseUrl}?${params.toString()}`; +} + +/** + * Exchange Microsoft OAuth code for tokens + */ +export async function exchangeMicrosoftDriveCode(code: string) { + if (!env.MICROSOFT_CLIENT_ID || !env.MICROSOFT_CLIENT_SECRET) { + throw new Error("Microsoft login not enabled - missing credentials"); + } + + const response = await fetch( + `https://login.microsoftonline.com/${env.MICROSOFT_TENANT_ID}/oauth2/v2.0/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: env.MICROSOFT_CLIENT_ID, + client_secret: env.MICROSOFT_CLIENT_SECRET, + code, + redirect_uri: `${env.NEXT_PUBLIC_BASE_URL}/api/outlook/drive/callback`, + grant_type: "authorization_code", + scope: MICROSOFT_DRIVE_SCOPES.join(" "), + }), + }, + ); + + const tokens = await response.json(); + + if (!response.ok) { + throw new Error(tokens.error_description || "Failed to exchange code"); + } + + if (!tokens.access_token || !tokens.refresh_token) { + throw new Error("No access or refresh token returned from Microsoft"); + } + + // Get user email from Microsoft Graph + const profileResponse = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }); + + if (!profileResponse.ok) { + throw new Error("Failed to get user profile from Microsoft"); + } + + const profile = await profileResponse.json(); + const email = profile.mail || profile.userPrincipalName; + + if (!email) { + throw new Error("Could not get email from Microsoft profile"); + } + + return { + accessToken: tokens.access_token as string, + refreshToken: tokens.refresh_token as string, + expiresAt: tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000) + : null, + email: email as string, + }; +} diff --git a/apps/web/utils/drive/constants.ts b/apps/web/utils/drive/constants.ts new file mode 100644 index 0000000000..9f3ddf5136 --- /dev/null +++ b/apps/web/utils/drive/constants.ts @@ -0,0 +1 @@ +export const DRIVE_STATE_COOKIE_NAME = "drive_state"; diff --git a/apps/web/utils/drive/handle-drive-callback.ts b/apps/web/utils/drive/handle-drive-callback.ts new file mode 100644 index 0000000000..4be84ac7e8 --- /dev/null +++ b/apps/web/utils/drive/handle-drive-callback.ts @@ -0,0 +1,160 @@ +import type { NextRequest, NextResponse } from "next/server"; +import { env } from "@/env"; +import type { Logger } from "@/utils/logger"; +import type { DriveTokens } from "./types"; +import { + validateOAuthCallback, + parseAndValidateDriveState, + buildDriveRedirectUrl, + upsertDriveConnection, +} from "./oauth-callback-helpers"; +import { + RedirectError, + redirectWithMessage, + redirectWithError, +} from "@/utils/oauth/redirect"; +import { verifyEmailAccountAccess } from "@/utils/oauth/verify"; +import { + acquireOAuthCodeLock, + getOAuthCodeResult, + setOAuthCodeResult, + clearOAuthCode, +} from "@/utils/redis/oauth-code"; +import { DRIVE_STATE_COOKIE_NAME } from "./constants"; + +export interface DriveOAuthProvider { + name: "google" | "microsoft"; + exchangeCodeForTokens(code: string): Promise; +} + +/** + * Unified handler for drive OAuth callbacks + */ +export async function handleDriveCallback( + request: NextRequest, + provider: DriveOAuthProvider, + logger: Logger, +): Promise { + let redirectHeaders = new Headers(); + + try { + // Step 1: Validate OAuth callback parameters + const { code, redirectUrl, response } = await validateOAuthCallback( + request, + logger, + ); + redirectHeaders = response.headers; + + // Step 1.5: Check for duplicate OAuth code processing + const cachedResult = await getOAuthCodeResult(code); + if (cachedResult) { + logger.info("OAuth code already processed, returning cached result"); + const cachedRedirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL); + for (const [key, value] of Object.entries(cachedResult.params)) { + cachedRedirectUrl.searchParams.set(key, value); + } + response.cookies.delete(DRIVE_STATE_COOKIE_NAME); + return redirectWithMessage( + cachedRedirectUrl, + cachedResult.params.message || "drive_connected", + redirectHeaders, + ); + } + + const acquiredLock = await acquireOAuthCodeLock(code); + if (!acquiredLock) { + logger.info("OAuth code is being processed by another request"); + const lockRedirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL); + response.cookies.delete(DRIVE_STATE_COOKIE_NAME); + return redirectWithMessage( + lockRedirectUrl, + "processing", + redirectHeaders, + ); + } + + // The validated state is in the request query params + const receivedState = request.nextUrl.searchParams.get("state"); + if (!receivedState) { + throw new Error("Missing validated state"); + } + + // Step 2: Parse and validate the OAuth state + const decodedState = parseAndValidateDriveState( + receivedState, + logger, + redirectUrl, + response.headers, + ); + + const { emailAccountId } = decodedState; + + // Step 3: Update redirect URL to include emailAccountId + const finalRedirectUrl = buildDriveRedirectUrl(emailAccountId); + + // Step 4: Verify user owns this email account + await verifyEmailAccountAccess( + emailAccountId, + logger, + finalRedirectUrl, + response.headers, + ); + + // Step 5: Exchange code for tokens and get email + const { accessToken, refreshToken, expiresAt, email } = + await provider.exchangeCodeForTokens(code); + + // Step 6: Create or update drive connection + const connection = await upsertDriveConnection({ + provider: provider.name, + email, + emailAccountId, + accessToken, + refreshToken, + expiresAt, + }); + + logger.info("Drive connected successfully", { + emailAccountId, + email, + provider: provider.name, + connectionId: connection.id, + }); + + // Cache the successful result + await setOAuthCodeResult(code, { message: "drive_connected" }); + + return redirectWithMessage( + finalRedirectUrl, + "drive_connected", + redirectHeaders, + ); + } catch (error) { + // Clear the OAuth code lock on error + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + if (code) { + await clearOAuthCode(code); + } + + // Handle redirect errors + if (error instanceof RedirectError) { + return redirectWithError( + error.redirectUrl, + "connection_failed", + error.responseHeaders, + ); + } + + // Handle all other errors + logger.error("Error in drive callback", { error }); + + // Try to build a redirect URL, fallback to /drive + const errorRedirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL); + return redirectWithError( + errorRedirectUrl, + "connection_failed", + redirectHeaders, + ); + } +} diff --git a/apps/web/utils/drive/oauth-callback-helpers.ts b/apps/web/utils/drive/oauth-callback-helpers.ts new file mode 100644 index 0000000000..c9718adba3 --- /dev/null +++ b/apps/web/utils/drive/oauth-callback-helpers.ts @@ -0,0 +1,181 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import prisma from "@/utils/prisma"; +import { DRIVE_STATE_COOKIE_NAME } from "@/utils/drive/constants"; +import { parseOAuthState } from "@/utils/oauth/state"; +import { prefixPath } from "@/utils/path"; +import { env } from "@/env"; +import type { Logger } from "@/utils/logger"; +import { RedirectError } from "@/utils/oauth/redirect"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface DriveOAuthState { + emailAccountId: string; + type: "drive"; + nonce: string; +} + +export interface OAuthCallbackValidation { + code: string; + redirectUrl: URL; + response: NextResponse; +} + +// ============================================================================ +// State Validation +// ============================================================================ + +const driveOAuthStateSchema = z.object({ + emailAccountId: z.string().min(1).max(64), + type: z.literal("drive"), + nonce: z.string().min(8).max(128), +}); + +/** + * Validate OAuth callback parameters and setup redirect + */ +export async function validateOAuthCallback( + request: NextRequest, + logger: Logger, +): Promise { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get("code"); + const receivedState = searchParams.get("state"); + const storedState = request.cookies.get(DRIVE_STATE_COOKIE_NAME)?.value; + + const redirectUrl = new URL("/drive", env.NEXT_PUBLIC_BASE_URL); + const response = NextResponse.redirect(redirectUrl); + + response.cookies.delete(DRIVE_STATE_COOKIE_NAME); + + if (!code || code.length < 10) { + logger.warn("Missing or invalid code in drive callback"); + redirectUrl.searchParams.set("error", "missing_code"); + throw new RedirectError(redirectUrl, response.headers); + } + + if (!storedState || !receivedState || storedState !== receivedState) { + logger.warn("Invalid state during drive callback", { + receivedState, + hasStoredState: !!storedState, + }); + redirectUrl.searchParams.set("error", "invalid_state"); + throw new RedirectError(redirectUrl, response.headers); + } + + return { code, redirectUrl, response }; +} + +/** + * Parse and validate the OAuth state + */ +export function parseAndValidateDriveState( + storedState: string, + logger: Logger, + redirectUrl: URL, + responseHeaders: Headers, +): DriveOAuthState { + let rawState: unknown; + try { + rawState = parseOAuthState>(storedState); + } catch (error) { + logger.error("Failed to decode state", { error }); + redirectUrl.searchParams.set("error", "invalid_state_format"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + const validationResult = driveOAuthStateSchema.safeParse(rawState); + if (!validationResult.success) { + logger.error("State validation failed", { + errors: validationResult.error.errors, + }); + redirectUrl.searchParams.set("error", "invalid_state_format"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + return validationResult.data; +} + +// ============================================================================ +// Redirect URL Building +// ============================================================================ + +/** + * Build redirect URL with emailAccountId + */ +export function buildDriveRedirectUrl(emailAccountId: string): URL { + return new URL( + prefixPath(emailAccountId, "/drive"), + env.NEXT_PUBLIC_BASE_URL, + ); +} + +// ============================================================================ +// Database Operations +// ============================================================================ + +/** + * Check if drive connection already exists for this provider + */ +export async function checkExistingConnection( + emailAccountId: string, + provider: "google" | "microsoft", +) { + return await prisma.driveConnection.findFirst({ + where: { + emailAccountId, + provider, + }, + }); +} + +/** + * Create or update a drive connection record + */ +export async function upsertDriveConnection(params: { + provider: "google" | "microsoft"; + email: string; + emailAccountId: string; + accessToken: string; + refreshToken: string; + expiresAt: Date | null; +}) { + // Check if connection exists for this provider + const existing = await prisma.driveConnection.findFirst({ + where: { + emailAccountId: params.emailAccountId, + provider: params.provider, + }, + }); + + if (existing) { + // Update existing connection + return await prisma.driveConnection.update({ + where: { id: existing.id }, + data: { + email: params.email, + accessToken: params.accessToken, + refreshToken: params.refreshToken, + expiresAt: params.expiresAt, + isConnected: true, + }, + }); + } + + // Create new connection + return await prisma.driveConnection.create({ + data: { + provider: params.provider, + email: params.email, + emailAccountId: params.emailAccountId, + accessToken: params.accessToken, + refreshToken: params.refreshToken, + expiresAt: params.expiresAt, + isConnected: true, + }, + }); +} diff --git a/apps/web/utils/oauth/redirect.ts b/apps/web/utils/oauth/redirect.ts new file mode 100644 index 0000000000..009793f584 --- /dev/null +++ b/apps/web/utils/oauth/redirect.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +/** + * Custom error class for OAuth redirect responses. + * Thrown when we need to redirect with an error during OAuth flow. + */ +export class RedirectError extends Error { + redirectUrl: URL; + responseHeaders: Headers; + + constructor(redirectUrl: URL, responseHeaders: Headers) { + super("Redirect required"); + this.name = "RedirectError"; + this.redirectUrl = redirectUrl; + this.responseHeaders = responseHeaders; + } +} + +/** + * Redirect with a success message query param + */ +export function redirectWithMessage( + redirectUrl: URL, + message: string, + responseHeaders: Headers, +): NextResponse { + redirectUrl.searchParams.set("message", message); + return NextResponse.redirect(redirectUrl, { headers: responseHeaders }); +} + +/** + * Redirect with an error query param + */ +export function redirectWithError( + redirectUrl: URL, + error: string, + responseHeaders: Headers, +): NextResponse { + redirectUrl.searchParams.set("error", error); + return NextResponse.redirect(redirectUrl, { headers: responseHeaders }); +} diff --git a/apps/web/utils/oauth/verify.ts b/apps/web/utils/oauth/verify.ts new file mode 100644 index 0000000000..f39061ebb1 --- /dev/null +++ b/apps/web/utils/oauth/verify.ts @@ -0,0 +1,41 @@ +import prisma from "@/utils/prisma"; +import { auth } from "@/utils/auth"; +import type { Logger } from "@/utils/logger"; +import { RedirectError } from "./redirect"; + +/** + * Verify the current user owns the specified email account. + * Throws RedirectError if unauthorized. + */ +export async function verifyEmailAccountAccess( + emailAccountId: string, + logger: Logger, + redirectUrl: URL, + responseHeaders: Headers, +): Promise<{ userId: string }> { + const session = await auth(); + if (!session?.user?.id) { + logger.warn("Unauthorized OAuth callback - no session"); + redirectUrl.searchParams.set("error", "unauthorized"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + const emailAccount = await prisma.emailAccount.findFirst({ + where: { + id: emailAccountId, + userId: session.user.id, + }, + select: { id: true }, + }); + + if (!emailAccount) { + logger.warn("Unauthorized OAuth callback - invalid email account", { + emailAccountId, + userId: session.user.id, + }); + redirectUrl.searchParams.set("error", "forbidden"); + throw new RedirectError(redirectUrl, responseHeaders); + } + + return { userId: session.user.id }; +} From 82941fd843593b80ab06d849cbb213e7cd9cf1d4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:57:36 +0200 Subject: [PATCH 03/76] add connect drive ui --- .../[emailAccountId]/drive/ConnectDrive.tsx | 111 +++++++++++++++ .../drive/DriveConnectionCard.tsx | 132 ++++++++++++++++++ .../drive/DriveConnections.tsx | 32 +++++ .../app/(app)/[emailAccountId]/drive/page.tsx | 18 +++ .../app/api/user/drive/connections/route.ts | 42 ++++++ apps/web/hooks/useDriveConnections.ts | 6 + apps/web/utils/actions/drive.ts | 30 ++++ apps/web/utils/actions/drive.validation.ts | 6 + 8 files changed, 377 insertions(+) create mode 100644 apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx create mode 100644 apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx create mode 100644 apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx create mode 100644 apps/web/app/(app)/[emailAccountId]/drive/page.tsx create mode 100644 apps/web/app/api/user/drive/connections/route.ts create mode 100644 apps/web/hooks/useDriveConnections.ts create mode 100644 apps/web/utils/actions/drive.ts create mode 100644 apps/web/utils/actions/drive.validation.ts diff --git a/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx new file mode 100644 index 0000000000..966954eb3d --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { toastError } from "@/components/Toast"; +import type { GetDriveAuthUrlResponse } from "@/app/api/google/drive/auth-url/route"; +import { fetchWithAccount } from "@/utils/fetch"; +import { createScopedLogger } from "@/utils/logger"; +import Image from "next/image"; + +export function ConnectDrive() { + const { emailAccountId } = useAccount(); + const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); + const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); + const logger = createScopedLogger("drive-connection"); + + const handleConnectGoogle = async () => { + setIsConnectingGoogle(true); + try { + const response = await fetchWithAccount({ + url: "/api/google/drive/auth-url", + emailAccountId, + init: { headers: { "Content-Type": "application/json" } }, + }); + + if (!response.ok) { + throw new Error("Failed to initiate Google Drive connection"); + } + + const data: GetDriveAuthUrlResponse = await response.json(); + window.location.href = data.url; + } catch (error) { + logger.error("Error initiating Google Drive connection", { + error, + emailAccountId, + provider: "google", + }); + toastError({ + title: "Error initiating Google Drive connection", + description: "Please try again or contact support", + }); + setIsConnectingGoogle(false); + } + }; + + const handleConnectMicrosoft = async () => { + setIsConnectingMicrosoft(true); + try { + const response = await fetchWithAccount({ + url: "/api/outlook/drive/auth-url", + emailAccountId, + init: { headers: { "Content-Type": "application/json" } }, + }); + + if (!response.ok) { + throw new Error("Failed to initiate OneDrive connection"); + } + + const data: GetDriveAuthUrlResponse = await response.json(); + window.location.href = data.url; + } catch (error) { + logger.error("Error initiating OneDrive connection", { + error, + emailAccountId, + provider: "microsoft", + }); + toastError({ + title: "Error initiating OneDrive connection", + description: "Please try again or contact support", + }); + setIsConnectingMicrosoft(false); + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx new file mode 100644 index 0000000000..2a105093a5 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Trash2, XCircle, FolderIcon } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { disconnectDriveAction } from "@/utils/actions/drive"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import type { GetDriveConnectionsResponse } from "@/app/api/user/drive/connections/route"; +import Image from "next/image"; +import { TypographyP } from "@/components/Typography"; + +type DriveConnection = GetDriveConnectionsResponse["connections"][0]; + +interface DriveConnectionCardProps { + connection: DriveConnection; +} + +const getProviderInfo = (provider: string) => { + const providers = { + microsoft: { + name: "OneDrive", + icon: "/images/microsoft.svg", + alt: "OneDrive", + }, + google: { + name: "Google Drive", + icon: "/images/google.svg", + alt: "Google Drive", + }, + }; + + return providers[provider as keyof typeof providers] || providers.google; +}; + +export function DriveConnectionCard({ connection }: DriveConnectionCardProps) { + const { emailAccountId } = useAccount(); + const { mutate } = useDriveConnections(); + + const providerInfo = getProviderInfo(connection.provider); + + const { execute: executeDisconnect, isExecuting: isDisconnecting } = + useAction(disconnectDriveAction.bind(null, emailAccountId)); + + const handleDisconnect = async () => { + if ( + confirm( + "Are you sure you want to disconnect this drive? This will remove all filing destinations.", + ) + ) { + executeDisconnect({ connectionId: connection.id }); + mutate(); + } + }; + + return ( + + +
+
+ {providerInfo.alt} +
+ {providerInfo.name} + + {connection.email} + {!connection.isConnected && ( +
+ + Disconnected +
+ )} +
+
+
+
+ +
+
+
+ +
+ + Configure where different document types should be filed. + + + {connection.filingDestinations && + connection.filingDestinations.length > 0 ? ( +
+ {connection.filingDestinations.map((dest) => ( +
+ + {dest.documentType}: + {dest.folderPath || dest.folderName} +
+ ))} +
+ ) : ( +

+ No filing destinations configured yet. Set up document type to + folder mappings to enable auto-organization. +

+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx new file mode 100644 index 0000000000..9850ec293a --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnections.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { LoadingContent } from "@/components/LoadingContent"; +import { useDriveConnections } from "@/hooks/useDriveConnections"; +import { DriveConnectionCard } from "./DriveConnectionCard"; + +export function DriveConnections() { + const { data, isLoading, error } = useDriveConnections(); + const connections = data?.connections || []; + + return ( + +
+ {connections.length === 0 ? ( +
+

No drive connections found.

+

Connect your Google Drive or OneDrive to get started.

+
+ ) : ( +
+ {connections.map((connection) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/drive/page.tsx b/apps/web/app/(app)/[emailAccountId]/drive/page.tsx new file mode 100644 index 0000000000..775698d786 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/page.tsx @@ -0,0 +1,18 @@ +import { PageWrapper } from "@/components/PageWrapper"; +import { PageHeader } from "@/components/PageHeader"; +import { DriveConnections } from "./DriveConnections"; +import { ConnectDrive } from "./ConnectDrive"; + +export default function DrivePage() { + return ( + +
+ + +
+
+ +
+
+ ); +} diff --git a/apps/web/app/api/user/drive/connections/route.ts b/apps/web/app/api/user/drive/connections/route.ts new file mode 100644 index 0000000000..d76cb77722 --- /dev/null +++ b/apps/web/app/api/user/drive/connections/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; + +export type GetDriveConnectionsResponse = Awaited>; + +export const GET = withEmailAccount( + "user/drive/connections", + async (request) => { + const { emailAccountId } = request.auth; + + const result = await getData({ emailAccountId }); + return NextResponse.json(result); + }, +); + +async function getData({ emailAccountId }: { emailAccountId: string }) { + const driveConnections = await prisma.driveConnection.findMany({ + where: { emailAccountId }, + select: { + id: true, + email: true, + provider: true, + isConnected: true, + createdAt: true, + filingDestinations: { + select: { + id: true, + documentType: true, + folderName: true, + folderPath: true, + }, + orderBy: { documentType: "asc" }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return { + connections: driveConnections, + }; +} diff --git a/apps/web/hooks/useDriveConnections.ts b/apps/web/hooks/useDriveConnections.ts new file mode 100644 index 0000000000..cf5edeed27 --- /dev/null +++ b/apps/web/hooks/useDriveConnections.ts @@ -0,0 +1,6 @@ +import useSWR from "swr"; +import type { GetDriveConnectionsResponse } from "@/app/api/user/drive/connections/route"; + +export function useDriveConnections() { + return useSWR("/api/user/drive/connections"); +} diff --git a/apps/web/utils/actions/drive.ts b/apps/web/utils/actions/drive.ts new file mode 100644 index 0000000000..33fb67a1b9 --- /dev/null +++ b/apps/web/utils/actions/drive.ts @@ -0,0 +1,30 @@ +"use server"; + +import { actionClient } from "@/utils/actions/safe-action"; +import { disconnectDriveBody } from "@/utils/actions/drive.validation"; +import prisma from "@/utils/prisma"; +import { SafeError } from "@/utils/error"; + +export const disconnectDriveAction = actionClient + .metadata({ name: "disconnectDrive" }) + .inputSchema(disconnectDriveBody) + .action( + async ({ ctx: { emailAccountId }, parsedInput: { connectionId } }) => { + const connection = await prisma.driveConnection.findFirst({ + where: { + id: connectionId, + emailAccountId, + }, + }); + + if (!connection) { + throw new SafeError("Drive connection not found"); + } + + await prisma.driveConnection.delete({ + where: { id: connectionId }, + }); + + return { success: true }; + }, + ); diff --git a/apps/web/utils/actions/drive.validation.ts b/apps/web/utils/actions/drive.validation.ts new file mode 100644 index 0000000000..220c404f81 --- /dev/null +++ b/apps/web/utils/actions/drive.validation.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const disconnectDriveBody = z.object({ + connectionId: z.string(), +}); +export type DisconnectDriveBody = z.infer; From 2bffca1533a10fd292e987165f204abe16632ae5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:59:34 +0200 Subject: [PATCH 04/76] remove frontend logs --- .../[emailAccountId]/calendars/ConnectCalendar.tsx | 14 ++------------ .../(app)/[emailAccountId]/drive/ConnectDrive.tsx | 14 ++------------ 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx index e129926b5e..c8392fce65 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx @@ -6,14 +6,12 @@ import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError } from "@/components/Toast"; import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route"; import { fetchWithAccount } from "@/utils/fetch"; -import { createScopedLogger } from "@/utils/logger"; import Image from "next/image"; export function ConnectCalendar() { const { emailAccountId } = useAccount(); const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); - const logger = createScopedLogger("calendar-connection"); const handleConnectGoogle = async () => { setIsConnectingGoogle(true); @@ -31,11 +29,7 @@ export function ConnectCalendar() { const data: GetCalendarAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - logger.error("Error initiating Google calendar connection", { - error, - emailAccountId, - provider: "google", - }); + console.error("Error initiating Google calendar connection", error); toastError({ title: "Error initiating Google calendar connection", description: "Please try again or contact support", @@ -60,11 +54,7 @@ export function ConnectCalendar() { const data: GetCalendarAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - logger.error("Error initiating Microsoft calendar connection", { - error, - emailAccountId, - provider: "microsoft", - }); + console.error("Error initiating Microsoft calendar connection", error); toastError({ title: "Error initiating Microsoft calendar connection", description: "Please try again or contact support", diff --git a/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx index 966954eb3d..874c757ae9 100644 --- a/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx +++ b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx @@ -6,14 +6,12 @@ import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError } from "@/components/Toast"; import type { GetDriveAuthUrlResponse } from "@/app/api/google/drive/auth-url/route"; import { fetchWithAccount } from "@/utils/fetch"; -import { createScopedLogger } from "@/utils/logger"; import Image from "next/image"; export function ConnectDrive() { const { emailAccountId } = useAccount(); const [isConnectingGoogle, setIsConnectingGoogle] = useState(false); const [isConnectingMicrosoft, setIsConnectingMicrosoft] = useState(false); - const logger = createScopedLogger("drive-connection"); const handleConnectGoogle = async () => { setIsConnectingGoogle(true); @@ -31,11 +29,7 @@ export function ConnectDrive() { const data: GetDriveAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - logger.error("Error initiating Google Drive connection", { - error, - emailAccountId, - provider: "google", - }); + console.error("Error initiating Google Drive connection", error); toastError({ title: "Error initiating Google Drive connection", description: "Please try again or contact support", @@ -60,11 +54,7 @@ export function ConnectDrive() { const data: GetDriveAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - logger.error("Error initiating OneDrive connection", { - error, - emailAccountId, - provider: "microsoft", - }); + console.error("Error initiating OneDrive connection", error); toastError({ title: "Error initiating OneDrive connection", description: "Please try again or contact support", From e5fe3b33ff42af498090a3293e4c20d6bc23e724 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sat, 20 Dec 2025 01:02:44 +0200 Subject: [PATCH 05/76] capture exceptions --- .../(app)/[emailAccountId]/calendars/ConnectCalendar.tsx | 9 +++++++-- .../app/(app)/[emailAccountId]/drive/ConnectDrive.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx index c8392fce65..72f7c04bf8 100644 --- a/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx +++ b/apps/web/app/(app)/[emailAccountId]/calendars/ConnectCalendar.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError } from "@/components/Toast"; +import { captureException } from "@/utils/error"; import type { GetCalendarAuthUrlResponse } from "@/app/api/google/calendar/auth-url/route"; import { fetchWithAccount } from "@/utils/fetch"; import Image from "next/image"; @@ -29,7 +30,9 @@ export function ConnectCalendar() { const data: GetCalendarAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - console.error("Error initiating Google calendar connection", error); + captureException(error, { + extra: { context: "Google Calendar OAuth initiation" }, + }); toastError({ title: "Error initiating Google calendar connection", description: "Please try again or contact support", @@ -54,7 +57,9 @@ export function ConnectCalendar() { const data: GetCalendarAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - console.error("Error initiating Microsoft calendar connection", error); + captureException(error, { + extra: { context: "Microsoft Calendar OAuth initiation" }, + }); toastError({ title: "Error initiating Microsoft calendar connection", description: "Please try again or contact support", diff --git a/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx index 874c757ae9..c4ed002fd7 100644 --- a/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx +++ b/apps/web/app/(app)/[emailAccountId]/drive/ConnectDrive.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { useAccount } from "@/providers/EmailAccountProvider"; import { toastError } from "@/components/Toast"; +import { captureException } from "@/utils/error"; import type { GetDriveAuthUrlResponse } from "@/app/api/google/drive/auth-url/route"; import { fetchWithAccount } from "@/utils/fetch"; import Image from "next/image"; @@ -29,7 +30,9 @@ export function ConnectDrive() { const data: GetDriveAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - console.error("Error initiating Google Drive connection", error); + captureException(error, { + extra: { context: "Google Drive OAuth initiation" }, + }); toastError({ title: "Error initiating Google Drive connection", description: "Please try again or contact support", @@ -54,7 +57,9 @@ export function ConnectDrive() { const data: GetDriveAuthUrlResponse = await response.json(); window.location.href = data.url; } catch (error) { - console.error("Error initiating OneDrive connection", error); + captureException(error, { + extra: { context: "OneDrive OAuth initiation" }, + }); toastError({ title: "Error initiating OneDrive connection", description: "Please try again or contact support", From 84d5df557cafffbdd8757e69aebcf4764a42c99f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:33:11 +0200 Subject: [PATCH 06/76] update schema and add prompt --- .../drive/DriveConnectionCard.tsx | 60 ++------ .../app/api/user/drive/connections/route.ts | 9 -- apps/web/prisma/schema.prisma | 71 +++++----- .../ai/document-filing/analyze-document.ts | 131 ++++++++++++++++++ 4 files changed, 174 insertions(+), 97 deletions(-) create mode 100644 apps/web/utils/ai/document-filing/analyze-document.ts diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx index 2a105093a5..dbeb487a20 100644 --- a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx @@ -2,20 +2,18 @@ import { Card, - CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Trash2, XCircle, FolderIcon } from "lucide-react"; +import { Trash2, XCircle } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { disconnectDriveAction } from "@/utils/actions/drive"; import { useAccount } from "@/providers/EmailAccountProvider"; import { useDriveConnections } from "@/hooks/useDriveConnections"; import type { GetDriveConnectionsResponse } from "@/app/api/user/drive/connections/route"; import Image from "next/image"; -import { TypographyP } from "@/components/Typography"; type DriveConnection = GetDriveConnectionsResponse["connections"][0]; @@ -50,11 +48,7 @@ export function DriveConnectionCard({ connection }: DriveConnectionCardProps) { useAction(disconnectDriveAction.bind(null, emailAccountId)); const handleDisconnect = async () => { - if ( - confirm( - "Are you sure you want to disconnect this drive? This will remove all filing destinations.", - ) - ) { + if (confirm("Are you sure you want to disconnect this drive?")) { executeDisconnect({ connectionId: connection.id }); mutate(); } @@ -85,48 +79,18 @@ export function DriveConnectionCard({ connection }: DriveConnectionCardProps) { -
- -
+ - -
- - Configure where different document types should be filed. - - - {connection.filingDestinations && - connection.filingDestinations.length > 0 ? ( -
- {connection.filingDestinations.map((dest) => ( -
- - {dest.documentType}: - {dest.folderPath || dest.folderName} -
- ))} -
- ) : ( -

- No filing destinations configured yet. Set up document type to - folder mappings to enable auto-organization. -

- )} -
-
); } diff --git a/apps/web/app/api/user/drive/connections/route.ts b/apps/web/app/api/user/drive/connections/route.ts index d76cb77722..938bb5fcf9 100644 --- a/apps/web/app/api/user/drive/connections/route.ts +++ b/apps/web/app/api/user/drive/connections/route.ts @@ -23,15 +23,6 @@ async function getData({ emailAccountId }: { emailAccountId: string }) { provider: true, isConnected: true, createdAt: true, - filingDestinations: { - select: { - id: true, - documentType: true, - folderName: true, - folderPath: true, - }, - orderBy: { documentType: "asc" }, - }, }, orderBy: { createdAt: "desc" }, }); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index cecc28e4ff..5f77a91380 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -144,6 +144,9 @@ model EmailAccount { meetingBriefingsEnabled Boolean @default(false) meetingBriefingsMinutesBefore Int @default(240) // 4 hours in minutes + filingEnabled Boolean @default(false) + filingPrompt String? + digestSchedule Schedule? userId String @@ -954,75 +957,64 @@ model MeetingBriefing { } // Drive connection for document auto-organization (Google Drive or OneDrive/SharePoint) -// One connection per provider - through that connection, user can access all their folders including shared ones +// One connection per provider. But we can remove unique constraint in the future if we want model DriveConnection { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt provider String // "google" or "microsoft" - email String // Account email used to connect + email String // can differ from emailAccount - e.g. connect work Drive to personal email accessToken String? refreshToken String? expiresAt DateTime? - isConnected Boolean @default(true) + isConnected Boolean @default(true) emailAccountId String emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) - filingDestinations FilingDestination[] - documentFilings DocumentFiling[] + documentFilings DocumentFiling[] - @@unique([emailAccountId, provider]) // One Google Drive, one OneDrive per email account + @@unique([emailAccountId, provider]) @@index([emailAccountId]) } -// Maps document types to specific folders (like Label maps to folder) -model FilingDestination { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - documentType String // "contract", "financial", "due_diligence", "correspondence", etc. - folderId String // Drive folder ID (Google Drive or OneDrive) - folderName String // Display name for the folder - folderPath String? // Full path for display (e.g., "/Projects/Acme Corp/Contracts") - - driveConnectionId String - driveConnection DriveConnection @relation(fields: [driveConnectionId], references: [id], onDelete: Cascade) - - @@unique([driveConnectionId, documentType]) // One folder per document type per connection - @@index([driveConnectionId]) -} - -// Tracks documents that have been filed or are pending confirmation model DocumentFiling { id String @id @default(cuid()) createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Source email attachment messageId String attachmentId String filename String - // Filing decision - documentType String? // AI-determined document type - confidence Float // 0-1 confidence score - status DocumentFilingStatus @default(PENDING) - // Result (after filing) - folderId String? // Where it was filed - driveFileId String? // File ID in drive (for undo) + folderId String + folderPath String + fileId String? + reasoning String? + confidence Float? + + status DocumentFilingStatus @default(FILED) + + wasAsked Boolean @default(false) + wasCorrected Boolean @default(false) + originalPath String? + correctedAt DateTime? + + notificationToken String @unique + notificationSentAt DateTime? driveConnectionId String driveConnection DriveConnection @relation(fields: [driveConnectionId], references: [id], onDelete: Cascade) - - emailAccountId String - emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) @@index([emailAccountId, status]) @@index([driveConnectionId]) @@index([messageId]) + @@index([notificationToken]) } model Referral { @@ -1152,11 +1144,10 @@ enum ColdEmailStatus { } enum DocumentFilingStatus { - PENDING // Awaiting user confirmation (low confidence) - FILED // Auto-filed with high confidence - CONFIRMED // User confirmed a pending filing - REJECTED // User rejected the filing suggestion - ERROR // Filing failed due to an error + PENDING // Waiting for user input + FILED // Document is filed (auto or after user confirmation) + REJECTED // User said skip + ERROR // Filing failed } // @deprecated - No longer used diff --git a/apps/web/utils/ai/document-filing/analyze-document.ts b/apps/web/utils/ai/document-filing/analyze-document.ts new file mode 100644 index 0000000000..0819b42c5a --- /dev/null +++ b/apps/web/utils/ai/document-filing/analyze-document.ts @@ -0,0 +1,131 @@ +import { z } from "zod"; +import type { EmailAccountWithAI } from "@/utils/llms/types"; +import { getModel } from "@/utils/llms/model"; +import { createGenerateObject } from "@/utils/llms"; +import { cleanExtractedText } from "@/utils/drive/document-extraction"; + +const documentAnalysisSchema = z.object({ + action: z + .enum(["use_existing", "create_new"]) + .describe("Whether to use an existing folder or create a new one."), + folderId: z + .string() + .optional() + .describe( + "Required if action is 'use_existing'. The ID of the existing folder from the provided list.", + ), + folderPath: z + .string() + .optional() + .describe( + "Required if action is 'create_new'. The path for the new folder to create.", + ), + confidence: z + .number() + .min(0) + .max(1) + .describe("Confidence score from 0 to 1. Use 0.9+ only when very certain."), + reasoning: z + .string() + .describe("Brief explanation for why this folder was chosen."), +}); +export type DocumentAnalysisResult = z.infer; + +type EmailContext = { subject: string; sender: string }; +type AttachmentContext = { filename: string; content: string }; +type DriveFolder = { + id: string; + name: string; + path: string; + driveProvider: string; +}; + +export async function analyzeDocument({ + emailAccount, + email, + attachment, + folders, +}: { + emailAccount: EmailAccountWithAI & { filingPrompt: string }; + email: EmailContext; + attachment: AttachmentContext; + folders: DriveFolder[]; +}): Promise { + const modelOptions = getModel(emailAccount.user, "economy"); + + const generateObject = createGenerateObject({ + emailAccount, + label: "Document filing", + modelOptions, + }); + + const result = await generateObject({ + ...modelOptions, + system: buildSystem(emailAccount.filingPrompt), + prompt: buildPrompt({ email, attachment, folders }), + schema: documentAnalysisSchema, + }); + + return result.object; +} + +function buildSystem(filingPrompt: string): string { + return `You are a document filing assistant. Your job is to decide where to file documents based on the user's preferences. + + +${filingPrompt} + + +Follow these preferences to decide where each document should go. + +IMPORTANT - You must choose one of: +1. action: "use_existing" + folderId - Pick an existing folder from the provided list (use the folder's ID) +2. action: "create_new" + folderPath - Suggest a new folder path if no existing folder fits + +Prefer existing folders when possible. Only suggest creating new folders when necessary. +Be conservative with confidence scores - only use 0.9+ when very certain.`; +} + +function buildPrompt({ + email, + attachment, + folders, +}: { + email: EmailContext; + attachment: AttachmentContext; + folders: DriveFolder[]; +}): string { + const cleanedText = cleanExtractedText(attachment.content); + const truncatedText = + cleanedText.length > 8000 + ? `${cleanedText.slice(0, 8000)}\n\n[... document truncated ...]` + : cleanedText; + + const foldersText = + folders.length > 0 + ? folders + .map( + (f) => + ``, + ) + .join("\n") + : "No existing folders found."; + + return `Decide where to file this document: + + +${attachment.filename} +${email.subject} +${email.sender} + + + +${truncatedText} + + + +${foldersText} + + +Based on the user's filing preferences and the document content, decide where this document should be filed.`; +} From bad9af74f99d1e5f47d0d61d63f32acae95f8f1d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:34:22 +0200 Subject: [PATCH 07/76] filebot utils --- .../utils/filebot/is-filebot-email.test.ts | 132 ++++++++++++++++++ apps/web/utils/filebot/is-filebot-email.ts | 68 +++++++++ 2 files changed, 200 insertions(+) create mode 100644 apps/web/utils/filebot/is-filebot-email.test.ts create mode 100644 apps/web/utils/filebot/is-filebot-email.ts diff --git a/apps/web/utils/filebot/is-filebot-email.test.ts b/apps/web/utils/filebot/is-filebot-email.test.ts new file mode 100644 index 0000000000..796a07155d --- /dev/null +++ b/apps/web/utils/filebot/is-filebot-email.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest"; +import { + isFilebotEmail, + getFilebotEmail, + extractFilebotToken, +} from "./is-filebot-email"; + +describe("isFilebotEmail", () => { + it("should return true for valid filebot email with token", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "john+filebot-abc123@example.com", + }); + expect(result).toBe(true); + }); + + it("should return false when recipient is different user", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "jane+filebot-abc123@example.com", + }); + expect(result).toBe(false); + }); + + it("should return false for plain email without filebot suffix", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "john@example.com", + }); + expect(result).toBe(false); + }); + + it("should return false for filebot without token", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "john+filebot@example.com", + }); + expect(result).toBe(false); + }); + + it("should handle email addresses with dots", () => { + const result = isFilebotEmail({ + userEmail: "john.doe@sub.example.com", + emailToCheck: "john.doe+filebot-token123@sub.example.com", + }); + expect(result).toBe(true); + }); + + it("should handle display name with angle brackets", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "John Doe ", + }); + expect(result).toBe(true); + }); + + it("should reject malicious domain injection", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "john+filebot-abc@evil.com+filebot-abc@example.com", + }); + expect(result).toBe(false); + }); + + it("should reject case manipulation", () => { + const result = isFilebotEmail({ + userEmail: "john@example.com", + emailToCheck: "john+FILEBOT-abc123@example.com", + }); + expect(result).toBe(false); + }); +}); + +describe("getFilebotEmail", () => { + it("should generate correct filebot email with token", () => { + const result = getFilebotEmail({ + userEmail: "john@example.com", + token: "abc123", + }); + expect(result).toBe("john+filebot-abc123@example.com"); + }); + + it("should handle email with dots", () => { + const result = getFilebotEmail({ + userEmail: "john.doe@sub.example.com", + token: "xyz789", + }); + expect(result).toBe("john.doe+filebot-xyz789@sub.example.com"); + }); +}); + +describe("extractFilebotToken", () => { + it("should extract token from valid filebot email", () => { + const result = extractFilebotToken({ + userEmail: "john@example.com", + emailToCheck: "john+filebot-abc123@example.com", + }); + expect(result).toBe("abc123"); + }); + + it("should return null for non-filebot email", () => { + const result = extractFilebotToken({ + userEmail: "john@example.com", + emailToCheck: "john@example.com", + }); + expect(result).toBeNull(); + }); + + it("should return null for different user", () => { + const result = extractFilebotToken({ + userEmail: "john@example.com", + emailToCheck: "jane+filebot-abc123@example.com", + }); + expect(result).toBeNull(); + }); + + it("should extract token from email with display name", () => { + const result = extractFilebotToken({ + userEmail: "john@example.com", + emailToCheck: "John ", + }); + expect(result).toBe("mytoken"); + }); + + it("should return null for empty email", () => { + const result = extractFilebotToken({ + userEmail: "john@example.com", + emailToCheck: "", + }); + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/utils/filebot/is-filebot-email.ts b/apps/web/utils/filebot/is-filebot-email.ts new file mode 100644 index 0000000000..0a3a85697d --- /dev/null +++ b/apps/web/utils/filebot/is-filebot-email.ts @@ -0,0 +1,68 @@ +import { env } from "@/env"; +import { extractEmailAddress } from "@/utils/email"; + +// In prod: hello+filebot-abc123@example.com +// In dev: hello+filebot-test-abc123@example.com +const FILEBOT_PREFIX = `filebot${env.NODE_ENV === "development" ? "-test" : ""}`; + +/** + * Check if an email address is a filebot reply address. + * Pattern: user+filebot-{token}@domain.com + */ +export function isFilebotEmail({ + userEmail, + emailToCheck, +}: { + userEmail: string; + emailToCheck: string; +}): boolean { + if (!emailToCheck) return false; + + const [localPart, domain] = userEmail.split("@"); + const extractedEmailToCheck = extractEmailAddress(emailToCheck); + const pattern = new RegExp( + `^${escapeRegex(localPart)}\\+${FILEBOT_PREFIX}-[a-zA-Z0-9]+@${escapeRegex(domain)}$`, + ); + return pattern.test(extractedEmailToCheck); +} + +/** + * Generate a filebot reply-to email address with a token. + * Returns: user+filebot-{token}@domain.com + */ +export function getFilebotEmail({ + userEmail, + token, +}: { + userEmail: string; + token: string; +}): string { + const [localPart, domain] = userEmail.split("@"); + return `${localPart}+${FILEBOT_PREFIX}-${token}@${domain}`; +} + +/** + * Extract the token from a filebot email address. + * Returns null if not a valid filebot email. + */ +export function extractFilebotToken({ + userEmail, + emailToCheck, +}: { + userEmail: string; + emailToCheck: string; +}): string | null { + if (!emailToCheck) return null; + + const [localPart, domain] = userEmail.split("@"); + const extractedEmailToCheck = extractEmailAddress(emailToCheck); + const pattern = new RegExp( + `^${escapeRegex(localPart)}\\+${FILEBOT_PREFIX}-([a-zA-Z0-9]+)@${escapeRegex(domain)}$`, + ); + const match = extractedEmailToCheck.match(pattern); + return match ? match[1] : null; +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} From c9589b43532fd094024542fda31dda9aaec54576 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 21 Dec 2025 01:45:58 +0200 Subject: [PATCH 08/76] doc extraction util --- apps/web/package.json | 2 + apps/web/utils/drive/document-extraction.ts | 240 ++++++++++++++++++++ pnpm-lock.yaml | 103 +++++++++ 3 files changed, 345 insertions(+) create mode 100644 apps/web/utils/drive/document-extraction.ts diff --git a/apps/web/package.json b/apps/web/package.json index 5a15a0e68f..d4bd59d6de 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -127,6 +127,7 @@ "linkifyjs": "4.3.2", "lodash": "4.17.21", "lucide-react": "0.555.0", + "mammoth": "1.11.0", "motion": "12.23.25", "next": "16.0.10", "next-axiom": "1.9.3", @@ -170,6 +171,7 @@ "tiptap-markdown": "0.8.10", "tldts": "^7.0.19", "typescript": "5.9.3", + "unpdf": "1.4.0", "use-stick-to-bottom": "1.1.1", "usehooks-ts": "3.1.1", "zod": "3.25.46" diff --git a/apps/web/utils/drive/document-extraction.ts b/apps/web/utils/drive/document-extraction.ts new file mode 100644 index 0000000000..188117160c --- /dev/null +++ b/apps/web/utils/drive/document-extraction.ts @@ -0,0 +1,240 @@ +/** + * Document text extraction utilities for PDF and DOCX files. + * + * Used to extract text content from email attachments before + * sending to AI for document classification. + * + * Uses `unpdf` for PDF extraction - serverless/edge compatible. + * Uses `mammoth` for DOCX extraction. + * + * Architecture note (from CRE document research): + * - Hybrid approach (OCR/extraction → LLM reasoning) outperforms vision-only + * - For small PDFs (<10 pages), consider using Claude's native PDF support + */ + +import type { Logger } from "@/utils/logger"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ExtractionResult { + text: string; + pageCount?: number; + truncated: boolean; +} + +export interface ExtractionOptions { + /** Maximum characters to extract (default: 10000) */ + maxLength?: number; + /** Maximum pages to process for PDFs (default: 50) */ + maxPages?: number; + /** Logger for debugging */ + logger?: Logger; +} + +// Supported MIME types for extraction +export const EXTRACTABLE_MIME_TYPES = [ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + "application/msword", // .doc (limited support) + "text/plain", +] as const; + +export type ExtractableMimeType = (typeof EXTRACTABLE_MIME_TYPES)[number]; + +// ============================================================================ +// Main Extraction Function +// ============================================================================ + +/** + * Extract text from a document buffer based on MIME type. + * Returns null if the MIME type is not supported. + */ +export async function extractTextFromDocument( + buffer: Buffer, + mimeType: string, + options: ExtractionOptions = {}, +): Promise { + const { maxLength = 10_000, maxPages = 50, logger } = options; + + try { + switch (mimeType) { + case "application/pdf": + return await extractFromPdf(buffer, maxLength, maxPages, logger); + + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return await extractFromDocx(buffer, maxLength, logger); + + case "text/plain": + return extractFromPlainText(buffer, maxLength); + + default: + logger?.info("Unsupported MIME type for extraction", { mimeType }); + return null; + } + } catch (error) { + logger?.error("Error extracting text from document", { error, mimeType }); + return null; + } +} + +/** + * Check if a MIME type is supported for extraction. + */ +export function isExtractableMimeType(mimeType: string): boolean { + return EXTRACTABLE_MIME_TYPES.includes(mimeType as ExtractableMimeType); +} + +/** + * Check if a PDF is small enough for Claude's native PDF support. + * Claude can process PDFs natively up to 100 pages / 32MB. + * For small documents, this can be more accurate than text extraction. + */ +export function canUseNativePdfSupport( + buffer: Buffer, + pageCount?: number, +): boolean { + const MAX_SIZE_MB = 32; + const MAX_PAGES = 100; + + const sizeOk = buffer.length < MAX_SIZE_MB * 1024 * 1024; + const pagesOk = !pageCount || pageCount <= MAX_PAGES; + + return sizeOk && pagesOk; +} + +// ============================================================================ +// PDF Extraction (using unpdf - serverless compatible) +// ============================================================================ + +async function extractFromPdf( + buffer: Buffer, + maxLength: number, + maxPages: number, + logger?: Logger, +): Promise { + // Dynamic import for unpdf (serverless/edge compatible) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error - unpdf types available after install: pnpm add unpdf --filter web + const { getDocumentProxy } = await import("unpdf"); + + const pdf = await getDocumentProxy(new Uint8Array(buffer)); + const pageCount = pdf.numPages; + const pagesToProcess = Math.min(pageCount, maxPages); + + const textParts: string[] = []; + let totalLength = 0; + let truncated = false; + + for (let i = 1; i <= pagesToProcess && !truncated; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + + // Extract text items and join them + const pageText = (textContent.items as Array<{ str?: string }>) + .map((item) => item.str ?? "") + .join(" "); + + if (totalLength + pageText.length > maxLength) { + // Truncate to fit within maxLength + const remaining = maxLength - totalLength; + textParts.push(pageText.slice(0, remaining)); + truncated = true; + } else { + textParts.push(pageText); + totalLength += pageText.length; + } + } + + // Check if we hit page limit + if (pagesToProcess < pageCount) { + truncated = true; + } + + const text = textParts.join("\n\n"); + + logger?.info("PDF extraction complete", { + pageCount, + pagesProcessed: pagesToProcess, + textLength: text.length, + truncated, + }); + + return { + text, + pageCount, + truncated, + }; +} + +// ============================================================================ +// DOCX Extraction +// ============================================================================ + +async function extractFromDocx( + buffer: Buffer, + maxLength: number, + logger?: Logger, +): Promise { + // Dynamic import to avoid loading the library if not needed + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error - mammoth types available after install: pnpm add mammoth --filter web + const mammoth = await import("mammoth"); + + const result = await mammoth.extractRawText({ buffer }); + const text = result.value || ""; + const truncated = text.length > maxLength; + + logger?.info("DOCX extraction complete", { + textLength: text.length, + truncated, + messages: result.messages, + }); + + return { + text: truncated ? text.slice(0, maxLength) : text, + truncated, + }; +} + +// ============================================================================ +// Plain Text Extraction +// ============================================================================ + +function extractFromPlainText( + buffer: Buffer, + maxLength: number, +): ExtractionResult { + const text = buffer.toString("utf-8"); + const truncated = text.length > maxLength; + + return { + text: truncated ? text.slice(0, maxLength) : text, + truncated, + }; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get a preview of the document (first N characters). + * Useful for logging without exposing full content. + */ +export function getDocumentPreview(text: string, length = 200): string { + if (text.length <= length) return text; + return `${text.slice(0, length)}...`; +} + +/** + * Clean extracted text by removing excessive whitespace. + */ +export function cleanExtractedText(text: string): string { + return text + .replace(/\r\n/g, "\n") // Normalize line endings + .replace(/\n{3,}/g, "\n\n") // Max 2 consecutive newlines + .replace(/[ \t]+/g, " ") // Collapse horizontal whitespace + .trim(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42b6df19a4..3b0d45841a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -430,6 +430,9 @@ importers: lucide-react: specifier: 0.555.0 version: 0.555.0(react@19.2.3) + mammoth: + specifier: 1.11.0 + version: 1.11.0 motion: specifier: 12.23.25 version: 12.23.25(@emotion/is-prop-valid@1.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -559,6 +562,9 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 + unpdf: + specifier: 1.4.0 + version: 1.4.0 use-stick-to-bottom: specifier: 1.1.1 version: 1.1.1(react@19.2.3) @@ -6851,6 +6857,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -7741,6 +7750,9 @@ packages: resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} engines: {node: '>=0.3.1'} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -7802,6 +7814,9 @@ packages: dub@0.67.0: resolution: {integrity: sha512-QVqbCOQrr3BU+w9hF0VXr7gJZ2z/B9v3QCi2CuLEZl6FlwFgEEUnMNUsSbZv08COWZ2Xixv3tLfgc4EIiQO21Q==} + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -8763,6 +8778,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.1.3: resolution: {integrity: sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==} @@ -9185,6 +9203,9 @@ packages: resolution: {integrity: sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==} hasBin: true + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -9238,6 +9259,9 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -9367,6 +9391,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -9439,6 +9466,11 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + mammoth@1.11.0: + resolution: {integrity: sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==} + engines: {node: '>=12.0.0'} + hasBin: true + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -10175,6 +10207,9 @@ packages: openapi3-ts@4.5.0: resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + ora@4.1.1: resolution: {integrity: sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==} engines: {node: '>=8'} @@ -11484,6 +11519,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -12288,6 +12326,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -12363,6 +12404,14 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpdf@1.4.0: + resolution: {integrity: sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA==} + peerDependencies: + '@napi-rs/canvas': ^0.1.69 + peerDependenciesMeta: + '@napi-rs/canvas': + optional: true + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -12920,6 +12969,10 @@ packages: xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -20357,6 +20410,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -21291,6 +21346,8 @@ snapshots: diff@8.0.2: {} + dingbat-to-unicode@1.0.1: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -21352,6 +21409,10 @@ snapshots: dependencies: zod: 3.25.46 + duck@0.1.12: + dependencies: + underscore: 1.13.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -22710,6 +22771,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + immer@10.1.3: {} immer@11.0.1: {} @@ -23128,6 +23191,13 @@ snapshots: jsonrepair@3.13.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -23175,6 +23245,10 @@ snapshots: leven@3.1.0: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + light-my-request@6.6.0: dependencies: cookie: 1.0.2 @@ -23309,6 +23383,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.7 + loupe@3.2.1: {} lower-case-first@1.0.2: @@ -23382,6 +23462,19 @@ snapshots: make-error@1.3.6: {} + mammoth@1.11.0: + dependencies: + '@xmldom/xmldom': 0.8.11 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.1 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.7 + xmlbuilder: 10.1.1 + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -24393,6 +24486,8 @@ snapshots: dependencies: yaml: 2.8.1 + option@0.2.4: {} + ora@4.1.1: dependencies: chalk: 3.0.0 @@ -26151,6 +26246,8 @@ snapshots: set-cookie-parser@2.7.2: {} + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} sha256-uint8array@0.10.7: {} @@ -27132,6 +27229,8 @@ snapshots: uncrypto@0.1.3: {} + underscore@1.13.7: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} @@ -27216,6 +27315,8 @@ snapshots: universalify@2.0.1: {} + unpdf@1.4.0: {} + unpipe@1.0.0: {} unplugin@1.0.1: @@ -27871,6 +27972,8 @@ snapshots: xml@1.0.1: {} + xmlbuilder@10.1.1: {} + xmlchars@2.2.0: {} xmlhttprequest-ssl@2.1.2: {} From 9fe0ac1734588453a9d115eaf934021ffd0b4543 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 21 Dec 2025 03:15:54 +0200 Subject: [PATCH 09/76] address comments --- .../drive/DriveConnectionCard.tsx | 2 +- .../ai/document-filing/analyze-document.ts | 64 +++++++++++-------- apps/web/utils/drive/client.ts | 7 +- apps/web/utils/drive/document-extraction.ts | 1 - .../web/utils/drive/oauth-callback-helpers.ts | 36 ++++------- .../utils/filebot/is-filebot-email.test.ts | 25 ++++++++ apps/web/utils/filebot/is-filebot-email.ts | 11 ++++ apps/web/utils/redis/oauth-code.ts | 11 +++- 8 files changed, 103 insertions(+), 54 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx index dbeb487a20..1b6865cc51 100644 --- a/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx +++ b/apps/web/app/(app)/[emailAccountId]/drive/DriveConnectionCard.tsx @@ -49,7 +49,7 @@ export function DriveConnectionCard({ connection }: DriveConnectionCardProps) { const handleDisconnect = async () => { if (confirm("Are you sure you want to disconnect this drive?")) { - executeDisconnect({ connectionId: connection.id }); + await executeDisconnect({ connectionId: connection.id }); mutate(); } }; diff --git a/apps/web/utils/ai/document-filing/analyze-document.ts b/apps/web/utils/ai/document-filing/analyze-document.ts index 0819b42c5a..c4c0b7be19 100644 --- a/apps/web/utils/ai/document-filing/analyze-document.ts +++ b/apps/web/utils/ai/document-filing/analyze-document.ts @@ -4,31 +4,45 @@ import { getModel } from "@/utils/llms/model"; import { createGenerateObject } from "@/utils/llms"; import { cleanExtractedText } from "@/utils/drive/document-extraction"; -const documentAnalysisSchema = z.object({ - action: z - .enum(["use_existing", "create_new"]) - .describe("Whether to use an existing folder or create a new one."), - folderId: z - .string() - .optional() - .describe( - "Required if action is 'use_existing'. The ID of the existing folder from the provided list.", - ), - folderPath: z - .string() - .optional() - .describe( - "Required if action is 'create_new'. The path for the new folder to create.", - ), - confidence: z - .number() - .min(0) - .max(1) - .describe("Confidence score from 0 to 1. Use 0.9+ only when very certain."), - reasoning: z - .string() - .describe("Brief explanation for why this folder was chosen."), -}); +const documentAnalysisSchema = z + .object({ + action: z + .enum(["use_existing", "create_new"]) + .describe("Whether to use an existing folder or create a new one."), + folderId: z + .string() + .optional() + .describe( + "Required if action is 'use_existing'. The ID of the existing folder from the provided list.", + ), + folderPath: z + .string() + .optional() + .describe( + "Required if action is 'create_new'. The path for the new folder to create.", + ), + confidence: z + .number() + .min(0) + .max(1) + .describe( + "Confidence score from 0 to 1. Use 0.9+ only when very certain.", + ), + reasoning: z + .string() + .describe("Brief explanation for why this folder was chosen."), + }) + .refine( + (data) => { + if (data.action === "use_existing") return !!data.folderId; + if (data.action === "create_new") return !!data.folderPath; + return true; + }, + { + message: + "folderId required for 'use_existing', folderPath required for 'create_new'", + }, + ); export type DocumentAnalysisResult = z.infer; type EmailContext = { subject: string; sender: string }; diff --git a/apps/web/utils/drive/client.ts b/apps/web/utils/drive/client.ts index 913b8559bb..228e5f5ce6 100644 --- a/apps/web/utils/drive/client.ts +++ b/apps/web/utils/drive/client.ts @@ -114,12 +114,13 @@ export async function exchangeMicrosoftDriveCode(code: string) { }, ); - const tokens = await response.json(); - if (!response.ok) { - throw new Error(tokens.error_description || "Failed to exchange code"); + const errorBody = await response.json().catch(() => ({})); + throw new Error(errorBody.error_description || "Failed to exchange code"); } + const tokens = await response.json(); + if (!tokens.access_token || !tokens.refresh_token) { throw new Error("No access or refresh token returned from Microsoft"); } diff --git a/apps/web/utils/drive/document-extraction.ts b/apps/web/utils/drive/document-extraction.ts index 188117160c..e867f28425 100644 --- a/apps/web/utils/drive/document-extraction.ts +++ b/apps/web/utils/drive/document-extraction.ts @@ -37,7 +37,6 @@ export interface ExtractionOptions { export const EXTRACTABLE_MIME_TYPES = [ "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx - "application/msword", // .doc (limited support) "text/plain", ] as const; diff --git a/apps/web/utils/drive/oauth-callback-helpers.ts b/apps/web/utils/drive/oauth-callback-helpers.ts index c9718adba3..eef675e2f6 100644 --- a/apps/web/utils/drive/oauth-callback-helpers.ts +++ b/apps/web/utils/drive/oauth-callback-helpers.ts @@ -144,31 +144,21 @@ export async function upsertDriveConnection(params: { refreshToken: string; expiresAt: Date | null; }) { - // Check if connection exists for this provider - const existing = await prisma.driveConnection.findFirst({ + return await prisma.driveConnection.upsert({ where: { - emailAccountId: params.emailAccountId, - provider: params.provider, - }, - }); - - if (existing) { - // Update existing connection - return await prisma.driveConnection.update({ - where: { id: existing.id }, - data: { - email: params.email, - accessToken: params.accessToken, - refreshToken: params.refreshToken, - expiresAt: params.expiresAt, - isConnected: true, + emailAccountId_provider: { + emailAccountId: params.emailAccountId, + provider: params.provider, }, - }); - } - - // Create new connection - return await prisma.driveConnection.create({ - data: { + }, + update: { + email: params.email, + accessToken: params.accessToken, + refreshToken: params.refreshToken, + expiresAt: params.expiresAt, + isConnected: true, + }, + create: { provider: params.provider, email: params.email, emailAccountId: params.emailAccountId, diff --git a/apps/web/utils/filebot/is-filebot-email.test.ts b/apps/web/utils/filebot/is-filebot-email.test.ts index 796a07155d..66a8f6205c 100644 --- a/apps/web/utils/filebot/is-filebot-email.test.ts +++ b/apps/web/utils/filebot/is-filebot-email.test.ts @@ -69,6 +69,14 @@ describe("isFilebotEmail", () => { }); expect(result).toBe(false); }); + + it("should handle invalid userEmail format gracefully", () => { + const result = isFilebotEmail({ + userEmail: "notanemail", + emailToCheck: "john+filebot-abc123@example.com", + }); + expect(result).toBe(false); + }); }); describe("getFilebotEmail", () => { @@ -87,6 +95,15 @@ describe("getFilebotEmail", () => { }); expect(result).toBe("john.doe+filebot-xyz789@sub.example.com"); }); + + it("should throw for invalid userEmail format", () => { + expect(() => + getFilebotEmail({ + userEmail: "notanemail", + token: "abc123", + }), + ).toThrow("Invalid email format"); + }); }); describe("extractFilebotToken", () => { @@ -129,4 +146,12 @@ describe("extractFilebotToken", () => { }); expect(result).toBeNull(); }); + + it("should return null for invalid userEmail format", () => { + const result = extractFilebotToken({ + userEmail: "notanemail", + emailToCheck: "john+filebot-abc123@example.com", + }); + expect(result).toBeNull(); + }); }); diff --git a/apps/web/utils/filebot/is-filebot-email.ts b/apps/web/utils/filebot/is-filebot-email.ts index 0a3a85697d..4a568bef12 100644 --- a/apps/web/utils/filebot/is-filebot-email.ts +++ b/apps/web/utils/filebot/is-filebot-email.ts @@ -19,7 +19,11 @@ export function isFilebotEmail({ if (!emailToCheck) return false; const [localPart, domain] = userEmail.split("@"); + if (!localPart || !domain) return false; + const extractedEmailToCheck = extractEmailAddress(emailToCheck); + if (!extractedEmailToCheck) return false; + const pattern = new RegExp( `^${escapeRegex(localPart)}\\+${FILEBOT_PREFIX}-[a-zA-Z0-9]+@${escapeRegex(domain)}$`, ); @@ -38,6 +42,9 @@ export function getFilebotEmail({ token: string; }): string { const [localPart, domain] = userEmail.split("@"); + if (!localPart || !domain) { + throw new Error("Invalid email format"); + } return `${localPart}+${FILEBOT_PREFIX}-${token}@${domain}`; } @@ -55,7 +62,11 @@ export function extractFilebotToken({ if (!emailToCheck) return null; const [localPart, domain] = userEmail.split("@"); + if (!localPart || !domain) return null; + const extractedEmailToCheck = extractEmailAddress(emailToCheck); + if (!extractedEmailToCheck) return null; + const pattern = new RegExp( `^${escapeRegex(localPart)}\\+${FILEBOT_PREFIX}-([a-zA-Z0-9]+)@${escapeRegex(domain)}$`, ); diff --git a/apps/web/utils/redis/oauth-code.ts b/apps/web/utils/redis/oauth-code.ts index 3f68286972..9fd81d113d 100644 --- a/apps/web/utils/redis/oauth-code.ts +++ b/apps/web/utils/redis/oauth-code.ts @@ -52,6 +52,15 @@ export async function setOAuthCodeResult( await redis.set(getCodeKey(code), result, { ex: 60 }); } +/** + * Clear the OAuth code from Redis. + * Fails silently - cleanup errors should never mask the original error in catch blocks. + */ export async function clearOAuthCode(code: string): Promise { - await redis.del(getCodeKey(code)); + try { + await redis.del(getCodeKey(code)); + } catch { + // Silently ignore - this is called in error handlers where we don't want + // cleanup failures to mask the original error + } } From 8f883800bd8c35eaf3f086d37d2334e477bceda9 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 21 Dec 2025 11:50:18 +0200 Subject: [PATCH 10/76] implemented more of drive support --- .../drive/FilingPreferences.tsx | 353 ++++++++++++++++++ .../app/(app)/[emailAccountId]/drive/page.tsx | 4 +- apps/web/app/api/user/drive/folders/route.ts | 88 +++++ apps/web/app/api/user/email-account/route.ts | 2 + .../api/user/unsubscribe-one-click/route.ts | 150 ++++++++ apps/web/hooks/useDriveFolders.ts | 6 + apps/web/prisma/schema.prisma | 20 + apps/web/utils/actions/drive.ts | 87 ++++- apps/web/utils/actions/drive.validation.ts | 21 ++ apps/web/utils/drive/filing-engine.ts | 338 +++++++++++++++++ apps/web/utils/drive/filing-notifications.ts | 272 ++++++++++++++ apps/web/utils/drive/folder-utils.ts | 38 ++ .../utils/drive/handle-filing-reply.test.ts | 134 +++++++ apps/web/utils/drive/handle-filing-reply.ts | 224 +++++++++++ .../web/utils/webhook/process-history-item.ts | 18 + 15 files changed, 1752 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx create mode 100644 apps/web/app/api/user/drive/folders/route.ts create mode 100644 apps/web/app/api/user/unsubscribe-one-click/route.ts create mode 100644 apps/web/hooks/useDriveFolders.ts create mode 100644 apps/web/utils/drive/filing-engine.ts create mode 100644 apps/web/utils/drive/filing-notifications.ts create mode 100644 apps/web/utils/drive/folder-utils.ts create mode 100644 apps/web/utils/drive/handle-filing-reply.test.ts create mode 100644 apps/web/utils/drive/handle-filing-reply.ts diff --git a/apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx b/apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx new file mode 100644 index 0000000000..7e19731530 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/drive/FilingPreferences.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useForm, type SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useAction } from "next-safe-action/hooks"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { LoadingContent } from "@/components/LoadingContent"; +import { toastSuccess, toastError } from "@/components/Toast"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; +import { useDriveFolders } from "@/hooks/useDriveFolders"; +import { + updateFilingPreferencesAction, + addFilingFolderAction, + removeFilingFolderAction, +} from "@/utils/actions/drive"; +import { + updateFilingPreferencesBody, + type UpdateFilingPreferencesBody, +} from "@/utils/actions/drive.validation"; + +export function FilingPreferences() { + const { emailAccountId } = useAccount(); + + const { + data: emailAccount, + isLoading: emailLoading, + error: emailError, + mutate: mutateEmail, + } = useEmailAccountFull(); + + const { + data: foldersData, + isLoading: foldersLoading, + error: foldersError, + mutate: mutateFolders, + } = useDriveFolders(); + + const isLoading = emailLoading || foldersLoading; + const error = emailError || foldersError; + + return ( + + {emailAccount && foldersData && ( + + )} + + ); +} + +interface FolderItem { + id: string; + name: string; + path: string; + driveConnectionId: string; + provider: string; +} + +interface SavedFolder { + id: string; + folderId: string; + folderName: string; + folderPath: string; + driveConnectionId: string; + provider: string; +} + +function FilingPreferencesForm({ + emailAccountId, + initialEnabled, + initialPrompt, + savedFolders, + availableFolders, + hasConnectedDrives, + mutateEmail, + mutateFolders, +}: { + emailAccountId: string; + initialEnabled: boolean; + initialPrompt: string; + savedFolders: SavedFolder[]; + availableFolders: FolderItem[]; + hasConnectedDrives: boolean; + mutateEmail: () => void; + mutateFolders: () => void; +}) { + const [isEditingPrompt, setIsEditingPrompt] = useState(!initialPrompt); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(updateFilingPreferencesBody), + defaultValues: { + filingEnabled: initialEnabled, + filingPrompt: initialPrompt, + }, + }); + + const filingEnabled = watch("filingEnabled"); + const filingPrompt = watch("filingPrompt"); + + const { execute: savePreferences } = useAction( + updateFilingPreferencesAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ description: "Filing preferences saved" }); + setIsEditingPrompt(false); + mutateEmail(); + }, + onError: (error) => { + toastError({ + title: "Error saving preferences", + description: error.error.serverError || "Failed to save preferences", + }); + }, + }, + ); + + const { execute: addFolder, isExecuting: isAddingFolder } = useAction( + addFilingFolderAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ description: "Folder added" }); + mutateFolders(); + }, + onError: (error) => { + toastError({ + title: "Error adding folder", + description: error.error.serverError || "Failed to add folder", + }); + }, + }, + ); + + const { execute: removeFolder, isExecuting: isRemovingFolder } = useAction( + removeFilingFolderAction.bind(null, emailAccountId), + { + onSuccess: () => { + toastSuccess({ description: "Folder removed" }); + mutateFolders(); + }, + onError: (error) => { + toastError({ + title: "Error removing folder", + description: error.error.serverError || "Failed to remove folder", + }); + }, + }, + ); + + const onSubmit: SubmitHandler = useCallback( + async (data) => { + savePreferences(data); + }, + [savePreferences], + ); + + const savedFolderIds = new Set(savedFolders.map((f) => f.folderId)); + + const handleFolderToggle = (folder: FolderItem, isChecked: boolean) => { + if (isChecked) { + addFolder({ + folderId: folder.id, + folderName: folder.name, + folderPath: folder.path, + driveConnectionId: folder.driveConnectionId, + }); + } else { + const saved = savedFolders.find((f) => f.folderId === folder.id); + if (saved) { + removeFolder({ id: saved.id }); + } + } + }; + + const hasPromptChanges = filingPrompt !== initialPrompt; + const hasEnabledChanges = filingEnabled !== initialEnabled; + + return ( + +
+
+
+

Document Auto-Filing

+

+ Automatically organize email attachments in your connected drives +

+
+ { + setValue("filingEnabled", checked); + if (checked && !filingPrompt) { + setIsEditingPrompt(true); + } + }} + /> +
+ + {filingEnabled && ( + <> + {!hasConnectedDrives && ( +
+ Connect a drive above to start auto-filing documents. +
+ )} + +
+
+
+ + {!isEditingPrompt && initialPrompt && ( + + )} +
+ {isEditingPrompt ? ( +