diff --git a/docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md b/docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md new file mode 100755 index 0000000..643aba7 --- /dev/null +++ b/docs/google-drive/GOOGLE-DRIVE-INTEGRATION.md @@ -0,0 +1,203 @@ +Google Drive Integration +=== + +Compass connects to a Google Workspace via the Drive API v3, replacing the mock file data with real cloud storage. The integration uses domain-wide delegation, which means a single service account impersonates each Compass user by their Google Workspace email. This is worth understanding because it determines how permissions work, how authentication flows, and why the setup requires Google Workspace admin access. + + +Why domain-wide delegation +--- + +There were two realistic options for connecting Compass to Google Drive: OAuth per-user or domain-wide delegation via a service account. + +Per-user OAuth would require every Compass user to individually authorize the app through a Google consent screen, creating friction on a construction site where field workers need immediate file access. It also requires managing refresh tokens per user and handling re-authorization when tokens expire. + +Domain-wide delegation avoids this entirely. A Google Workspace admin grants the service account access to Drive scopes once, and from that point, every Workspace user is accessible without individual consent flows. The service account impersonates each user by setting the `sub` claim in its JWT, so every API call runs as that specific user with their own Drive permissions. This means Google's native sharing model is preserved - if a user can't access a file in Google Workspace, the API returns 403 to Compass as well. + +The tradeoff is that setup requires access to both the Google Cloud Console (to create the service account and enable the Drive API) and the Google Workspace Admin Console (to grant domain-wide delegation scopes). This is a one-time administrative action, but it does require someone with admin access to both systems. + + +How the two permission layers work +--- + +Every file operation passes through two gates, and this is the most important architectural decision in the integration. + +**Compass RBAC** runs first. The role-based permissions defined in `src/lib/permissions.ts` determine what *type* of operation a user can attempt. Admin users get full CRUD plus approve. Office staff get create, read, update. Field workers get create and read. Client users get read only. This check happens before any Google API call is made, so a field worker can never trigger a delete operation regardless of their Google permissions. + +**Google Workspace permissions** run second, implicitly. Because the API call is made as the user (via impersonation), Google enforces whatever sharing and access rules exist in the Workspace. If a file is in a Shared Drive that the user doesn't have access to, Google returns 404. If the user has view-only access to a file, Google rejects a PATCH request. No mapping logic is needed - the impersonation itself is the enforcement mechanism. + +This means Compass can restrict operations *beyond* what Google allows (a field worker with Google editor access still can't delete through Compass), but it cannot grant access *beyond* what Google allows (an admin who doesn't have a Google Workspace account can't browse files). + + +User-to-Google email mapping +--- + +Most Compass users will have the same email in both systems. For cases where they don't (someone whose Compass email is `nicholai@biohazardvfx.com` but whose Workspace email is different), the `users` table has a `googleEmail` column that overrides the default. + +The resolution logic is straightforward: use `googleEmail` if set, otherwise fall back to `email`. If the resolved email doesn't exist in the Workspace, the Google API call fails and Compass returns a user-facing error. An admin can set override emails through the settings UI. + + +Architecture +--- + +### Library structure + +``` +src/lib/google/ + config.ts - config types, API URLs, scopes, crypto salt + auth/ + service-account.ts - JWT creation and token exchange (RS256 via Web Crypto) + token-cache.ts - per-request in-memory cache (see note below) + client/ + drive-client.ts - REST wrapper with retry, rate limiting, impersonation + types.ts - Google Drive API v3 response types + mapper.ts - DriveFile -> FileItem type mapping +``` + +The library uses the Web Crypto API exclusively for JWT signing, which is the only option on Cloudflare Workers (no Node.js crypto module). The service account's private key (RSA, PEM-encoded) is imported as a CryptoKey and used for RS256 signatures. This works identically in Workers, Node, and Deno. + +### Token caching + +A note on the token cache: in Cloudflare Workers, each request runs in its own isolate, so an in-memory `Map` resets on every request. The cache is effectively a no-op in production. Each request generates a fresh JWT and exchanges it for an access token. This adds roughly 100ms of latency per request but avoids the complexity of KV-backed caching. If this becomes a measurable problem, the cache can be swapped to Workers KV without changing any calling code. + +### Rate limiting and retry + +The `DriveClient` uses the same `ConcurrencyLimiter` from the NetSuite integration, defaulting to 10 concurrent requests. On 429 responses, it reduces concurrency automatically. On 401 responses, it clears the cached token and retries (the service account token may have expired). On 5xx responses, it retries with exponential backoff up to 3 attempts. + +### Encryption at rest + +The service account JSON key is encrypted with AES-256-GCM before storage in D1. The encryption module in `src/lib/crypto.ts` is shared between the Google and NetSuite integrations, parameterized by a salt string so the same encryption key can be used for both without deriving identical keys. + +The encryption key itself lives in the Cloudflare Workers environment (`GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY`), not in D1. This means a database export alone doesn't expose the service account credentials. + + +Database schema +--- + +Migration `0015_busy_photon.sql` adds two tables and one column: + +**google_auth** stores one record per organization containing the encrypted service account key, workspace domain, and optional shared drive selection. The `connectedBy` column tracks which admin set up the connection. + +**google_starred_files** stores per-user file stars. These are local to Compass rather than using Google's native starring, because the service account impersonation means each user's stars would actually be the service account's stars. This table solves that by keeping star state in D1. + +**users.google_email** is a nullable text column for overriding the default email used for impersonation. + + +Server actions +--- + +All 16+ server actions live in `src/app/actions/google-drive.ts` and follow the standard Compass pattern: `"use server"`, authenticate with `requireAuth()`, check permissions with `requirePermission()`, return a discriminated union. + +### Connection management (admin only) + +- `connectGoogleDrive(keyJson, domain)` - encrypts and stores the service account key, validates by making a test API call +- `disconnectGoogleDrive()` - deletes the google_auth record +- `listAvailableSharedDrives()` - lists drives visible to the service account +- `selectSharedDrive(id, name)` - sets which shared drive to browse by default + +### File operations (RBAC-gated) + +- `listDriveFiles(folderId?, pageToken?)` - browse a folder +- `listDriveFilesForView(view, pageToken?)` - shared, recent, starred, trash views +- `searchDriveFiles(query)` - fulltext search via Google's `fullText contains` query +- `createDriveFolder(name, parentId?)` - requires `document:create` +- `renameDriveFile(fileId, newName)` - requires `document:update` +- `moveDriveFile(fileId, newParentId, oldParentId)` - requires `document:update` +- `trashDriveFile(fileId)` - requires `document:delete` +- `restoreDriveFile(fileId)` - requires `document:update` +- `getUploadSessionUrl(fileName, mimeType, parentId?)` - initiates a resumable upload session; returns the session URL for client-side upload directly to Google +- `getDriveStorageQuota()` - returns used and total bytes +- `getDriveFileInfo(fileId)` - single file metadata +- `listDriveFolders(parentId?)` - folders only, for the move dialog + +### Local operations + +- `toggleStarFile(googleFileId)` - D1 operation, no Google API call +- `getStarredFileIds()` - returns starred IDs for current user +- `updateUserGoogleEmail(userId, email)` - admin sets override email + + +Upload flow +--- + +Uploads use Google's resumable upload protocol. The server creates an upload session (which returns a time-limited, single-use URL), and the client uploads directly to Google via XHR with progress tracking. This avoids proxying file bytes through the Cloudflare Worker, which has request body size limits and would double the bandwidth cost. The upload URL is scoped to a single file and expires, so exposure is limited. + + +Download flow +--- + +Downloads are proxied through the worker at `/api/google/download/[fileId]`. The route authenticates the user, checks permissions, and streams the file content from Google through the response. For Google-native files (Docs, Sheets, Slides), it exports them as PDF or xlsx before streaming. This proxy is necessary because the Google API requires authentication that the browser doesn't have. + + +File type mapping +--- + +The mapper in `src/lib/google/mapper.ts` converts Google Drive's MIME types to Compass's `FileType` union. Google-native apps types (`application/vnd.google-apps.*`) get special treatment: + +- `apps.folder` -> `"folder"` +- `apps.document` -> `"document"` (opens in Google Docs via `webViewLink`) +- `apps.spreadsheet` -> `"spreadsheet"` (opens in Google Sheets) +- `apps.presentation` -> `"document"` (opens in Google Slides) + +Standard MIME types are mapped by prefix (`image/*`, `video/*`, `audio/*`) or by keyword matching for archives, spreadsheets, documents, and code files. Anything unrecognized becomes `"unknown"`. + + +UI changes +--- + +### File browser + +The `use-files.tsx` hook was rewritten from a pure client-side reducer with mock data to a server-action-backed fetcher. Local state (view mode, sort order, selection, search query) still lives in a reducer. File data comes from async server action calls. When Google Drive isn't connected, the hook falls back to mock data and the browser shows a "Demo Mode" banner prompting the admin to connect. + +### Components + +Every file operation component (upload, rename, move, new folder, context menu) was updated to call server actions when connected and fall back to the mock dispatch when not. The breadcrumb resolves folder ancestry by walking up the `parents` chain via `getDriveFileInfo()` calls. + +### Routes + +- `/dashboard/files` - root file browser (shows drive root or shared drive root) +- `/dashboard/files?view=shared|recent|starred|trash` - view filters +- `/dashboard/files/folder/[folderId]` - browse a specific folder by Google Drive ID + +### Settings + +The settings modal's Integrations tab now includes a Google Drive section with connection status, service account upload dialog, shared drive picker, and user email mapping. + + +Setup +--- + +### Google Cloud Console + +1. Create a project (or use an existing one) +2. Enable the Google Drive API +3. Create a service account +4. Enable domain-wide delegation on the service account +5. Download the service account JSON key + +### Google Workspace Admin Console + +1. Go to Security -> API Controls -> Domain-wide Delegation +2. Add the service account's client ID +3. Grant scope: `https://www.googleapis.com/auth/drive` + +### Compass + +1. Set `GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY` in `.dev.vars` (local) or via `wrangler secret put` (production). This can be any random string - it's used to derive an AES-256 key for encrypting the service account JSON at rest. +2. Run `bun run db:migrate:local` (or `bun run db:migrate:prod` for production) +3. Start the app, go to Settings -> Integrations -> Google Drive +4. Upload the service account JSON key and enter the workspace domain +5. Optionally select a shared drive to scope the file browser + + +Known limitations +--- + +**Token caching is per-request.** Each request generates a new service account JWT and exchanges it for an access token. For typical usage this adds negligible latency, but high-traffic deployments might want KV-backed caching. + +**Breadcrumb resolution is sequential.** Resolving a folder's ancestry requires one API call per parent level. Most folders are 2-4 levels deep, so this is 200-400ms of async resolution. The UI renders immediately and fills in breadcrumb segments as they resolve. + +**Stars are local.** Because impersonation shares a single service account identity, Google's native star feature can't be used per-user. Stars are stored in D1 instead, which means they don't appear in the Google Drive web UI. + +**No real-time sync.** Files are fetched on navigation. If someone adds a file through the Google Drive web UI, it appears in Compass on the next folder load. There's no push notification or polling. + +**Single-organization model.** The `getOrgGoogleAuth()` helper grabs the first google_auth record without filtering by organization ID. This is correct for the current single-tenant-per-D1-instance architecture but would need a WHERE clause if multi-tenancy is added. diff --git a/drizzle.config.ts b/drizzle.config.ts index afd33e7..3c64ce2 100755 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ "./src/db/schema-plugins.ts", "./src/db/schema-agent.ts", "./src/db/schema-ai-config.ts", + "./src/db/schema-google.ts", ], out: "./drizzle", dialect: "sqlite", diff --git a/drizzle/0015_busy_photon.sql b/drizzle/0015_busy_photon.sql new file mode 100755 index 0000000..2c75261 --- /dev/null +++ b/drizzle/0015_busy_photon.sql @@ -0,0 +1,23 @@ +CREATE TABLE `google_auth` ( + `id` text PRIMARY KEY NOT NULL, + `organization_id` text NOT NULL, + `service_account_key_encrypted` text NOT NULL, + `workspace_domain` text NOT NULL, + `shared_drive_id` text, + `shared_drive_name` text, + `connected_by` text NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`connected_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `google_starred_files` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `google_file_id` text NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `users` ADD `google_email` text; \ No newline at end of file diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json new file mode 100755 index 0000000..33902cd --- /dev/null +++ b/drizzle/meta/0015_snapshot.json @@ -0,0 +1,3331 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f4c54926-b492-453b-bc4a-88eb2a6d63ee", + "prevId": "08b14bdc-7015-48bd-9ec9-10a0b977c08f", + "tables": { + "agent_conversations": { + "name": "agent_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_conversations_user_id_users_id_fk": { + "name": "agent_conversations_user_id_users_id_fk", + "tableFrom": "agent_conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_memories": { + "name": "agent_memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embedding": { + "name": "embedding", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_memories_conversation_id_agent_conversations_id_fk": { + "name": "agent_memories_conversation_id_agent_conversations_id_fk", + "tableFrom": "agent_memories", + "tableTo": "agent_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_memories_user_id_users_id_fk": { + "name": "agent_memories_user_id_users_id_fk", + "tableFrom": "agent_memories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customers": { + "name": "customers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewport_width": { + "name": "viewport_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewport_height": { + "name": "viewport_height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback_interviews": { + "name": "feedback_interviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_role": { + "name": "user_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pain_points": { + "name": "pain_points", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feature_requests": { + "name": "feature_requests", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overall_sentiment": { + "name": "overall_sentiment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "feedback_interviews_user_id_users_id_fk": { + "name": "feedback_interviews_user_id_users_id_fk", + "tableFrom": "feedback_interviews", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group_members": { + "name": "group_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "group_members_group_id_groups_id_fk": { + "name": "group_members_group_id_groups_id_fk", + "tableFrom": "group_members", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "group_members_user_id_users_id_fk": { + "name": "group_members_user_id_users_id_fk", + "tableFrom": "group_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "groups": { + "name": "groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "groups_organization_id_organizations_id_fk": { + "name": "groups_organization_id_organizations_id_fk", + "tableFrom": "groups", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_members": { + "name": "project_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'OPEN'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_manager": { + "name": "project_manager", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "netsuite_job_id": { + "name": "netsuite_job_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_baselines": { + "name": "schedule_baselines", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_baselines_project_id_projects_id_fk": { + "name": "schedule_baselines_project_id_projects_id_fk", + "tableFrom": "schedule_baselines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_tasks": { + "name": "schedule_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workdays": { + "name": "workdays", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date_calculated": { + "name": "end_date_calculated", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'PENDING'" + }, + "is_critical_path": { + "name": "is_critical_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_milestone": { + "name": "is_milestone", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "percent_complete": { + "name": "percent_complete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "assigned_to": { + "name": "assigned_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "schedule_tasks_project_id_projects_id_fk": { + "name": "schedule_tasks_project_id_projects_id_fk", + "tableFrom": "schedule_tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slab_memories": { + "name": "slab_memories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_type": { + "name": "memory_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importance": { + "name": "importance", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.7 + }, + "pinned": { + "name": "pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "access_count": { + "name": "access_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "slab_memories_user_id_users_id_fk": { + "name": "slab_memories_user_id_users_id_fk", + "tableFrom": "slab_memories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_dependencies": { + "name": "task_dependencies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "predecessor_id": { + "name": "predecessor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "successor_id": { + "name": "successor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'FS'" + }, + "lag_days": { + "name": "lag_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "task_dependencies_predecessor_id_schedule_tasks_id_fk": { + "name": "task_dependencies_predecessor_id_schedule_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "schedule_tasks", + "columnsFrom": [ + "predecessor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_dependencies_successor_id_schedule_tasks_id_fk": { + "name": "task_dependencies_successor_id_schedule_tasks_id_fk", + "tableFrom": "task_dependencies", + "tableTo": "schedule_tasks", + "columnsFrom": [ + "successor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_members": { + "name": "team_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'office'" + }, + "google_email": { + "name": "google_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_login_at": { + "name": "last_login_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendors": { + "name": "vendors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Subcontractor'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workday_exceptions": { + "name": "workday_exceptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_date": { + "name": "start_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_date": { + "name": "end_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'non_working'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company_holiday'" + }, + "recurrence": { + "name": "recurrence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'one_time'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "workday_exceptions_project_id_projects_id_fk": { + "name": "workday_exceptions_project_id_projects_id_fk", + "tableFrom": "workday_exceptions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "credit_memos": { + "name": "credit_memos", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memo_number": { + "name": "memo_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "total": { + "name": "total", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_applied": { + "name": "amount_applied", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_remaining": { + "name": "amount_remaining", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "credit_memos_customer_id_customers_id_fk": { + "name": "credit_memos_customer_id_customers_id_fk", + "tableFrom": "credit_memos", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "credit_memos_project_id_projects_id_fk": { + "name": "credit_memos_project_id_projects_id_fk", + "tableFrom": "credit_memos", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtotal": { + "name": "subtotal", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax": { + "name": "tax", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total": { + "name": "total", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_due": { + "name": "amount_due", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "invoices_customer_id_customers_id_fk": { + "name": "invoices_customer_id_customers_id_fk", + "tableFrom": "invoices", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_project_id_projects_id_fk": { + "name": "invoices_project_id_projects_id_fk", + "tableFrom": "invoices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "netsuite_auth": { + "name": "netsuite_auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issued_at": { + "name": "issued_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "netsuite_sync_log": { + "name": "netsuite_sync_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sync_type": { + "name": "sync_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "record_type": { + "name": "record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "records_processed": { + "name": "records_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "records_failed": { + "name": "records_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "error_summary": { + "name": "error_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "netsuite_sync_metadata": { + "name": "netsuite_sync_metadata", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "local_table": { + "name": "local_table", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_record_id": { + "name": "local_record_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "netsuite_record_type": { + "name": "netsuite_record_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "netsuite_internal_id": { + "name": "netsuite_internal_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified_local": { + "name": "last_modified_local", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_modified_remote": { + "name": "last_modified_remote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'synced'" + }, + "conflict_data": { + "name": "conflict_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "payments": { + "name": "payments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_type": { + "name": "payment_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_date": { + "name": "payment_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "payments_customer_id_customers_id_fk": { + "name": "payments_customer_id_customers_id_fk", + "tableFrom": "payments", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payments_vendor_id_vendors_id_fk": { + "name": "payments_vendor_id_vendors_id_fk", + "tableFrom": "payments", + "tableTo": "vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payments_project_id_projects_id_fk": { + "name": "payments_project_id_projects_id_fk", + "tableFrom": "payments", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vendor_bills": { + "name": "vendor_bills", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "netsuite_id": { + "name": "netsuite_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bill_number": { + "name": "bill_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "bill_date": { + "name": "bill_date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtotal": { + "name": "subtotal", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tax": { + "name": "tax", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total": { + "name": "total", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_paid": { + "name": "amount_paid", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount_due": { + "name": "amount_due", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "vendor_bills_vendor_id_vendors_id_fk": { + "name": "vendor_bills_vendor_id_vendors_id_fk", + "tableFrom": "vendor_bills", + "tableTo": "vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vendor_bills_project_id_projects_id_fk": { + "name": "vendor_bills_project_id_projects_id_fk", + "tableFrom": "vendor_bills", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_config": { + "name": "plugin_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_encrypted": { + "name": "is_encrypted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_config_plugin_id_plugins_id_fk": { + "name": "plugin_config_plugin_id_plugins_id_fk", + "tableFrom": "plugin_config", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_events": { + "name": "plugin_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugin_events_plugin_id_plugins_id_fk": { + "name": "plugin_events_plugin_id_plugins_id_fk", + "tableFrom": "plugin_events", + "tableTo": "plugins", + "columnsFrom": [ + "plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_events_user_id_users_id_fk": { + "name": "plugin_events_user_id_users_id_fk", + "tableFrom": "plugin_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "required_env_vars": { + "name": "required_env_vars", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'disabled'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_by": { + "name": "enabled_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_enabled_by_users_id_fk": { + "name": "plugins_enabled_by_users_id_fk", + "tableFrom": "plugins", + "tableTo": "users", + "columnsFrom": [ + "enabled_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_items": { + "name": "agent_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_items_user_id_users_id_fk": { + "name": "agent_items_user_id_users_id_fk", + "tableFrom": "agent_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_config": { + "name": "agent_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_cost": { + "name": "prompt_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completion_cost": { + "name": "completion_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_cost_per_million": { + "name": "max_cost_per_million", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_user_selection": { + "name": "allow_user_selection", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_config_updated_by_users_id_fk": { + "name": "agent_config_updated_by_users_id_fk", + "tableFrom": "agent_config", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_usage": { + "name": "agent_usage", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_usage_conversation_id_agent_conversations_id_fk": { + "name": "agent_usage_conversation_id_agent_conversations_id_fk", + "tableFrom": "agent_usage", + "tableTo": "agent_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_usage_user_id_users_id_fk": { + "name": "agent_usage_user_id_users_id_fk", + "tableFrom": "agent_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_model_preference": { + "name": "user_model_preference", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt_cost": { + "name": "prompt_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completion_cost": { + "name": "completion_cost", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_preference_user_id_users_id_fk": { + "name": "user_model_preference_user_id_users_id_fk", + "tableFrom": "user_model_preference", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_auth": { + "name": "google_auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_account_key_encrypted": { + "name": "service_account_key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_domain": { + "name": "workspace_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared_drive_id": { + "name": "shared_drive_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shared_drive_name": { + "name": "shared_drive_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connected_by": { + "name": "connected_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "google_auth_organization_id_organizations_id_fk": { + "name": "google_auth_organization_id_organizations_id_fk", + "tableFrom": "google_auth", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "google_auth_connected_by_users_id_fk": { + "name": "google_auth_connected_by_users_id_fk", + "tableFrom": "google_auth", + "tableTo": "users", + "columnsFrom": [ + "connected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "google_starred_files": { + "name": "google_starred_files", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "google_file_id": { + "name": "google_file_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "google_starred_files_user_id_users_id_fk": { + "name": "google_starred_files_user_id_users_id_fk", + "tableFrom": "google_starred_files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3d504a0..c69ecfd 100755 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1770431392946, "tag": "0014_new_giant_girl", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1770439304946, + "tag": "0015_busy_photon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/actions/google-drive.ts b/src/app/actions/google-drive.ts new file mode 100755 index 0000000..249caa8 --- /dev/null +++ b/src/app/actions/google-drive.ts @@ -0,0 +1,1029 @@ +"use server" + +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { eq, and } from "drizzle-orm" +import { revalidatePath } from "next/cache" +import { getDb } from "@/db" +import { users } from "@/db/schema" +import { googleAuth, googleStarredFiles } from "@/db/schema-google" +import { getCurrentUser, requireAuth } from "@/lib/auth" +import type { AuthUser } from "@/lib/auth" +import { requirePermission } from "@/lib/permissions" +import { encrypt, decrypt } from "@/lib/crypto" +import { + getGoogleConfig, + getGoogleCryptoSalt, + parseServiceAccountKey, + type ServiceAccountKey, +} from "@/lib/google/config" +import { DriveClient } from "@/lib/google/client/drive-client" +import { mapDriveFileToFileItem } from "@/lib/google/mapper" +import type { FileItem } from "@/lib/files-data" + +// helpers + +function resolveGoogleEmail(user: AuthUser): string { + return user.googleEmail ?? user.email +} + +async function getOrgGoogleAuth(db: ReturnType) { + const rows = await db.select().from(googleAuth).limit(1) + return rows[0] ?? null +} + +async function getDecryptedServiceAccountKey( + encryptedKey: string, + encryptionKey: string +): Promise { + const json = await decrypt( + encryptedKey, + encryptionKey, + getGoogleCryptoSalt() + ) + return parseServiceAccountKey(json) +} + +async function buildDriveClient( + encryptedKey: string, + encryptionKey: string +): Promise { + const serviceAccountKey = await getDecryptedServiceAccountKey( + encryptedKey, + encryptionKey + ) + return new DriveClient({ serviceAccountKey }) +} + +async function getStarredIds( + db: ReturnType, + userId: string +): Promise> { + const rows = await db + .select({ googleFileId: googleStarredFiles.googleFileId }) + .from(googleStarredFiles) + .where(eq(googleStarredFiles.userId, userId)) + return new Set(rows.map(r => r.googleFileId)) +} + +// connection management + +export async function getGoogleDriveConnectionStatus(): Promise<{ + connected: boolean + workspaceDomain: string | null + sharedDriveName: string | null +}> { + try { + const user = await getCurrentUser() // keep nullable - graceful fallback + requirePermission(user, "document", "read") + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + + if (!auth) { + return { + connected: false, + workspaceDomain: null, + sharedDriveName: null, + } + } + + return { + connected: true, + workspaceDomain: auth.workspaceDomain, + sharedDriveName: auth.sharedDriveName, + } + } catch { + return { + connected: false, + workspaceDomain: null, + sharedDriveName: null, + } + } +} + +export async function connectGoogleDrive( + serviceAccountKeyJson: string, + workspaceDomain: string +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "organization", "update") + + const parsed = parseServiceAccountKey(serviceAccountKeyJson) + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + + // validate by making a test call + const client = new DriveClient({ serviceAccountKey: parsed }) + const testEmail = user.email + try { + await client.getStorageQuota(testEmail) + } catch (testErr) { + return { + success: false, + error: `Failed to connect: ${testErr instanceof Error ? testErr.message : "Unknown error"}. Check domain-wide delegation.`, + } + } + + const encryptedKey = await encrypt( + serviceAccountKeyJson, + config.encryptionKey, + getGoogleCryptoSalt() + ) + + // upsert: delete existing then insert + await db.delete(googleAuth).run() + await db + .insert(googleAuth) + .values({ + id: crypto.randomUUID(), + organizationId: "default", + serviceAccountKeyEncrypted: encryptedKey, + workspaceDomain, + connectedBy: user.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + .run() + + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to connect Google Drive", + } + } +} + +export async function disconnectGoogleDrive(): Promise< + { success: true } | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "organization", "delete") + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + await db.delete(googleAuth).run() + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to disconnect", + } + } +} + +export async function listAvailableSharedDrives(): Promise< + | { + success: true + drives: ReadonlyArray<{ id: string; name: string }> + } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "organization", "update") + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google email" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + const result = await client.listSharedDrives(googleEmail) + + return { + success: true, + drives: result.drives.map(d => ({ + id: d.id, + name: d.name, + })), + } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed", + } + } +} + +export async function selectSharedDrive( + driveId: string | null, + driveName: string | null +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "organization", "update") + + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + await db + .update(googleAuth) + .set({ + sharedDriveId: driveId, + sharedDriveName: driveName, + updatedAt: new Date().toISOString(), + }) + .where(eq(googleAuth.id, auth.id)) + .run() + + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed", + } + } +} + +// file operations + +export async function listDriveFiles( + folderId?: string, + pageToken?: string +): Promise< + | { + success: true + files: FileItem[] + nextPageToken: string | null + } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + const starredIds = await getStarredIds(db, user.id) + + const targetFolder = + folderId ?? auth.sharedDriveId ?? undefined + const result = await client.listFiles(googleEmail, { + folderId: targetFolder, + driveId: auth.sharedDriveId ?? undefined, + pageToken, + orderBy: "folder,name", + }) + + const files = result.files.map(f => + mapDriveFileToFileItem(f, starredIds, folderId ?? null) + ) + + return { + success: true, + files, + nextPageToken: result.nextPageToken ?? null, + } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to list files", + } + } +} + +export async function listDriveFilesForView( + view: string, + pageToken?: string +): Promise< + | { + success: true + files: FileItem[] + nextPageToken: string | null + } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + const starredIds = await getStarredIds(db, user.id) + + let result + + switch (view) { + case "shared": + result = await client.listFiles(googleEmail, { + sharedWithMe: true, + driveId: auth.sharedDriveId ?? undefined, + pageToken, + }) + break + + case "recent": { + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) + result = await client.listFiles(googleEmail, { + query: `modifiedTime > '${thirtyDaysAgo.toISOString()}'`, + orderBy: "modifiedTime desc", + driveId: auth.sharedDriveId ?? undefined, + pageToken, + }) + break + } + + case "starred": { + if (starredIds.size === 0) { + return { + success: true, + files: [], + nextPageToken: null, + } + } + const fileIds = [...starredIds] + const files: FileItem[] = [] + for (const fileId of fileIds) { + try { + const file = await client.getFile( + googleEmail, + fileId + ) + files.push( + mapDriveFileToFileItem(file, starredIds, null) + ) + } catch { + // file may have been deleted + } + } + return { success: true, files, nextPageToken: null } + } + + case "trash": + result = await client.listFiles(googleEmail, { + trashed: true, + driveId: auth.sharedDriveId ?? undefined, + pageToken, + }) + break + + default: + return { success: false, error: `Unknown view: ${view}` } + } + + const files = result.files.map(f => + mapDriveFileToFileItem(f, starredIds, null) + ) + + return { + success: true, + files, + nextPageToken: result.nextPageToken ?? null, + } + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Failed to list files", + } + } +} + +export async function searchDriveFiles( + query: string +): Promise< + | { success: true; files: FileItem[] } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + const starredIds = await getStarredIds(db, user.id) + + const result = await client.searchFiles( + googleEmail, + query, + 50, + auth.sharedDriveId ?? undefined + ) + + const files = result.files.map(f => + mapDriveFileToFileItem(f, starredIds, null) + ) + + return { success: true, files } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Search failed", + } + } +} + +export async function createDriveFolder( + name: string, + parentId?: string +): Promise< + | { success: true; folder: FileItem } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "create") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + + const driveFile = await client.createFolder(googleEmail, { + name, + parentId: parentId ?? auth.sharedDriveId ?? undefined, + driveId: auth.sharedDriveId ?? undefined, + }) + + const starredIds = await getStarredIds(db, user.id) + const folder = mapDriveFileToFileItem( + driveFile, + starredIds, + parentId ?? null + ) + + revalidatePath("/dashboard/files") + return { success: true, folder } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to create folder", + } + } +} + +export async function renameDriveFile( + fileId: string, + newName: string +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "document", "update") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + await client.renameFile(googleEmail, fileId, newName) + + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to rename", + } + } +} + +export async function moveDriveFile( + fileId: string, + newParentId: string, + oldParentId: string +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "document", "update") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + await client.moveFile( + googleEmail, + fileId, + newParentId, + oldParentId + ) + + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to move file", + } + } +} + +export async function trashDriveFile( + fileId: string +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "document", "delete") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + await client.trashFile(googleEmail, fileId) + + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to trash file", + } + } +} + +export async function restoreDriveFile( + fileId: string +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "document", "update") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + await client.restoreFile(googleEmail, fileId) + + revalidatePath("/dashboard/files") + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to restore file", + } + } +} + +export async function getDriveStorageQuota(): Promise< + | { success: true; used: number; total: number } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + const about = await client.getStorageQuota(googleEmail) + + return { + success: true, + used: Number(about.storageQuota.usage), + total: about.storageQuota.limit + ? Number(about.storageQuota.limit) + : 0, + } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to get quota", + } + } +} + +export async function getUploadSessionUrl( + fileName: string, + mimeType: string, + parentId?: string +): Promise< + | { success: true; uploadUrl: string } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "create") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + + const uploadUrl = await client.initiateResumableUpload( + googleEmail, + { + name: fileName, + mimeType, + parentId: parentId ?? auth.sharedDriveId ?? undefined, + driveId: auth.sharedDriveId ?? undefined, + } + ) + + return { success: true, uploadUrl } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to initiate upload", + } + } +} + +export async function toggleStarFile( + googleFileId: string +): Promise< + | { success: true; starred: boolean } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const existing = await db + .select() + .from(googleStarredFiles) + .where( + and( + eq(googleStarredFiles.userId, user.id), + eq(googleStarredFiles.googleFileId, googleFileId) + ) + ) + .get() + + if (existing) { + await db + .delete(googleStarredFiles) + .where(eq(googleStarredFiles.id, existing.id)) + .run() + return { success: true, starred: false } + } + + await db + .insert(googleStarredFiles) + .values({ + id: crypto.randomUUID(), + userId: user.id, + googleFileId, + createdAt: new Date().toISOString(), + }) + .run() + return { success: true, starred: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to toggle star", + } + } +} + +export async function getStarredFileIds(): Promise< + | { success: true; fileIds: string[] } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + const rows = await db + .select({ googleFileId: googleStarredFiles.googleFileId }) + .from(googleStarredFiles) + .where(eq(googleStarredFiles.userId, user.id)) + + return { + success: true, + fileIds: rows.map(r => r.googleFileId), + } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to get starred files", + } + } +} + +export async function updateUserGoogleEmail( + userId: string, + googleEmail: string | null +): Promise<{ success: true } | { success: false; error: string }> { + try { + const user = await requireAuth() + requirePermission(user, "user", "update") + + const { env } = await getCloudflareContext() + const db = getDb(env.DB) + + await db + .update(users) + .set({ + googleEmail, + updatedAt: new Date().toISOString(), + }) + .where(eq(users.id, userId)) + .run() + + return { success: true } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to update email", + } + } +} + +export async function getDriveFileInfo( + fileId: string +): Promise< + | { success: true; file: FileItem } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + const starredIds = await getStarredIds(db, user.id) + const driveFile = await client.getFile( + googleEmail, + fileId + ) + + return { + success: true, + file: mapDriveFileToFileItem(driveFile, starredIds, null), + } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to get file", + } + } +} + +export async function listDriveFolders( + parentId?: string +): Promise< + | { + success: true + folders: ReadonlyArray<{ id: string; name: string }> + } + | { success: false; error: string } +> { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = resolveGoogleEmail(user) + if (!googleEmail) { + return { success: false, error: "No Google account linked" } + } + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + const auth = await getOrgGoogleAuth(db) + if (!auth) { + return { success: false, error: "Google Drive not connected" } + } + + const client = await buildDriveClient( + auth.serviceAccountKeyEncrypted, + config.encryptionKey + ) + + const targetFolder = + parentId ?? auth.sharedDriveId ?? undefined + const result = await client.listFiles(googleEmail, { + folderId: targetFolder, + query: + "mimeType = 'application/vnd.google-apps.folder'", + driveId: auth.sharedDriveId ?? undefined, + pageSize: 200, + orderBy: "name", + }) + + return { + success: true, + folders: result.files.map(f => ({ + id: f.id, + name: f.name, + })), + } + } catch (err) { + return { + success: false, + error: err instanceof Error + ? err.message + : "Failed to list folders", + } + } +} diff --git a/src/app/api/google/download/[fileId]/route.ts b/src/app/api/google/download/[fileId]/route.ts new file mode 100755 index 0000000..a562827 --- /dev/null +++ b/src/app/api/google/download/[fileId]/route.ts @@ -0,0 +1,106 @@ +import { NextRequest } from "next/server" +import { getCloudflareContext } from "@opennextjs/cloudflare" +import { requireAuth } from "@/lib/auth" +import { requirePermission } from "@/lib/permissions" +import { getDb } from "@/db" +import { googleAuth } from "@/db/schema-google" +import { decrypt } from "@/lib/crypto" +import { + getGoogleConfig, + getGoogleCryptoSalt, + parseServiceAccountKey, +} from "@/lib/google/config" +import { DriveClient } from "@/lib/google/client/drive-client" +import { + isGoogleNativeFile, + getExportMimeType, + getExportExtension, +} from "@/lib/google/mapper" + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ fileId: string }> } +): Promise { + try { + const user = await requireAuth() + requirePermission(user, "document", "read") + + const googleEmail = user.googleEmail ?? user.email + + const { env } = await getCloudflareContext() + const envRecord = env as unknown as Record + const config = getGoogleConfig(envRecord) + const db = getDb(env.DB) + + const auth = await db + .select() + .from(googleAuth) + .limit(1) + .then(rows => rows[0] ?? null) + if (!auth) { + return new Response("Google Drive not connected", { + status: 404, + }) + } + + const keyJson = await decrypt( + auth.serviceAccountKeyEncrypted, + config.encryptionKey, + getGoogleCryptoSalt() + ) + const serviceAccountKey = parseServiceAccountKey(keyJson) + const client = new DriveClient({ serviceAccountKey }) + + const { fileId } = await params + + // get file metadata to determine type + const fileMeta = await client.getFile( + googleEmail, + fileId + ) + + let response: Response + let fileName = fileMeta.name + let contentType: string + + if (isGoogleNativeFile(fileMeta.mimeType)) { + const exportMime = getExportMimeType(fileMeta.mimeType) + if (!exportMime) { + return new Response("Cannot export this file type", { + status: 400, + }) + } + const ext = getExportExtension(fileMeta.mimeType) + fileName = `${fileMeta.name}${ext}` + contentType = exportMime + response = await client.exportFile( + googleEmail, + fileId, + exportMime + ) + } else { + contentType = fileMeta.mimeType + response = await client.downloadFile( + googleEmail, + fileId + ) + } + + if (!response.ok) { + return new Response("Failed to download file", { + status: response.status, + }) + } + + return new Response(response.body, { + headers: { + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`, + "Cache-Control": "private, max-age=300", + }, + }) + } catch (err) { + console.error("Download error:", err) + return new Response("Download failed", { status: 500 }) + } +} diff --git a/src/app/dashboard/files/folder/[folderId]/page.tsx b/src/app/dashboard/files/folder/[folderId]/page.tsx new file mode 100755 index 0000000..c1111a1 --- /dev/null +++ b/src/app/dashboard/files/folder/[folderId]/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { Suspense } from "react" +import { useParams } from "next/navigation" +import { FileBrowser } from "@/components/files/file-browser" + +export default function FilesFolderPage() { + const params = useParams() + const folderId = params.folderId as string + + return ( + + + + ) +} diff --git a/src/app/dashboard/files/page.tsx b/src/app/dashboard/files/page.tsx index af61c02..e3c7c62 100755 --- a/src/app/dashboard/files/page.tsx +++ b/src/app/dashboard/files/page.tsx @@ -6,7 +6,7 @@ import { FileBrowser } from "@/components/files/file-browser" export default function FilesPage() { return ( - + ) } diff --git a/src/components/files/file-breadcrumb.tsx b/src/components/files/file-breadcrumb.tsx index 015c42c..0915559 100755 --- a/src/components/files/file-breadcrumb.tsx +++ b/src/components/files/file-breadcrumb.tsx @@ -1,8 +1,11 @@ "use client" +import { useState, useEffect } from "react" import Link from "next/link" import { IconChevronRight } from "@tabler/icons-react" +import { useFiles } from "@/hooks/use-files" +import { getDriveFileInfo } from "@/app/actions/google-drive" import { Breadcrumb, BreadcrumbItem, @@ -12,12 +15,112 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb" -export function FileBreadcrumb({ path }: { path: string[] }) { +type BreadcrumbSegment = { + name: string + folderId: string | null +} + +export function FileBreadcrumb({ + path, + folderId, +}: { + path?: string[] + folderId?: string +}) { + const { state } = useFiles() + const [segments, setSegments] = useState< + BreadcrumbSegment[] + >([]) + + // for google drive mode: resolve folder ancestry + useEffect(() => { + if (state.isConnected !== true || !folderId) { + setSegments([]) + return + } + + let cancelled = false + + async function resolve() { + const trail: BreadcrumbSegment[] = [] + let currentId: string | null = folderId ?? null + + // walk up the parents chain (max 10 deep to prevent infinite loops) + for (let depth = 0; depth < 10 && currentId; depth++) { + try { + const result = await getDriveFileInfo(currentId) + if (!result.success || cancelled) break + trail.unshift({ + name: result.file.name, + folderId: currentId, + }) + currentId = result.file.parentId + } catch { + break + } + } + + if (!cancelled) { + setSegments(trail) + } + } + + resolve() + return () => { + cancelled = true + } + }, [folderId, state.isConnected]) + + // mock data mode: use path array + if (state.isConnected !== true) { + const effectivePath = path ?? [] + return ( + + + + {effectivePath.length === 0 ? ( + My Files + ) : ( + + + My Files + + + )} + + {effectivePath.map((segment, i) => { + const isLast = i === effectivePath.length - 1 + const href = `/dashboard/files/${effectivePath.slice(0, i + 1).join("/")}` + return ( + + + + + + {isLast ? ( + + {segment} + + ) : ( + + {segment} + + )} + + + ) + })} + + + ) + } + + // google drive mode: use resolved segments return ( - {path.length === 0 ? ( + {segments.length === 0 && !folderId ? ( My Files ) : ( @@ -25,20 +128,26 @@ export function FileBreadcrumb({ path }: { path: string[] }) { )} - {path.map((segment, i) => { - const isLast = i === path.length - 1 - const href = `/dashboard/files/${path.slice(0, i + 1).join("/")}` + {segments.map((seg, i) => { + const isLast = i === segments.length - 1 return ( - + {isLast ? ( - {segment} + {seg.name} ) : ( - {segment} + + {seg.name} + )} diff --git a/src/components/files/file-browser.tsx b/src/components/files/file-browser.tsx index 689c2ab..9329662 100755 --- a/src/components/files/file-browser.tsx +++ b/src/components/files/file-browser.tsx @@ -1,8 +1,14 @@ "use client" -import { useState, useCallback } from "react" +import { useState, useCallback, useEffect } from "react" import { useSearchParams } from "next/navigation" import { toast } from "sonner" +import { + IconCloudOff, + IconAlertTriangle, + IconRefresh, + IconLoader2, +} from "@tabler/icons-react" import type { FileItem } from "@/lib/files-data" import { useFiles, type FileView } from "@/hooks/use-files" @@ -17,23 +23,54 @@ import { FileRenameDialog } from "./file-rename-dialog" import { FileMoveDialog } from "./file-move-dialog" import { FileDropZone } from "./file-drop-zone" import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" -export function FileBrowser({ path }: { path: string[] }) { +export function FileBrowser({ + path, + folderId, +}: { + path?: string[] + folderId?: string +}) { const searchParams = useSearchParams() const viewParam = searchParams.get("view") as FileView | null const currentView = viewParam ?? "my-files" - const { state, dispatch, getFilesForView } = useFiles() - const files = getFilesForView(currentView, path) + const { + state, + dispatch, + getFilesForView, + fetchFiles, + loadMore, + createFolder, + starFile, + } = useFiles() + + const effectivePath = path ?? [] + const files = getFilesForView(currentView, effectivePath) const [uploadOpen, setUploadOpen] = useState(false) + const [uploadFiles, setUploadFiles] = useState([]) const [newFolderOpen, setNewFolderOpen] = useState(false) - const [renameFile, setRenameFile] = useState(null) + const [renameFile, setRenameFile] = useState( + null + ) const [moveFile, setMoveFile] = useState(null) - const { handleClick } = useFileSelection(files, state.selectedIds, { - select: (ids) => dispatch({ type: "SET_SELECTED", payload: ids }), - }) + const { handleClick } = useFileSelection( + files, + state.selectedIds, + { + select: ids => + dispatch({ type: "SET_SELECTED", payload: ids }), + } + ) + + // fetch files when connected and folder/view changes + useEffect(() => { + if (state.isConnected !== true) return + fetchFiles(folderId, currentView) + }, [state.isConnected, folderId, currentView, fetchFiles]) const handleBackgroundClick = useCallback( (e: React.MouseEvent) => { @@ -44,45 +81,40 @@ export function FileBrowser({ path }: { path: string[] }) { [dispatch] ) + // for mock data mode, resolve parentId from path const parentId = (() => { - if (path.length === 0) return null + if (folderId) return folderId + if (effectivePath.length === 0) return null const folder = state.files.find( - (f) => + f => f.type === "folder" && - f.name === path[path.length - 1] && - JSON.stringify(f.path) === JSON.stringify(path.slice(0, -1)) + f.name === effectivePath[effectivePath.length - 1] && + JSON.stringify(f.path) === + JSON.stringify(effectivePath.slice(0, -1)) ) return folder?.id ?? null })() const handleNew = useCallback( - (type: NewFileType) => { + async (type: NewFileType) => { if (type === "folder") { setNewFolderOpen(true) return } - - const names: Record = { - document: "Untitled Document", - spreadsheet: "Untitled Spreadsheet", - presentation: "Untitled Presentation", - } - const fileType = type === "presentation" ? "document" : type - - dispatch({ - type: "CREATE_FILE", - payload: { - name: names[type], - fileType: fileType as FileItem["type"], - parentId, - path, - }, - }) - toast.success(`${names[type]} created`) + // for google-native doc creation, these would + // be created as google docs/sheets/slides + toast.info( + "Creating Google Workspace files coming soon" + ) }, - [dispatch, parentId, path] + [] ) + const handleDrop = useCallback((droppedFiles: File[]) => { + setUploadFiles(droppedFiles) + setUploadOpen(true) + }, []) + const viewTitle: Record = { "my-files": "", shared: "Shared with me", @@ -91,19 +123,97 @@ export function FileBrowser({ path }: { path: string[] }) { trash: "Trash", } + // loading state (only for initial load) + if (state.isConnected === null) { + return ( +
+
+ +

+ Checking connection... +

+
+
+ ) + } + + // error state + if (state.error && state.files.length === 0) { + return ( +
+
+ +

+ {state.error} +

+ +
+
+ ) + } + return (
- {currentView !== "my-files" && ( -

{viewTitle[currentView]}

+ {/* not connected banner (demo mode) */} + {state.isConnected === false && ( +
+ +

+ Showing demo files. Connect Google Drive in + Settings to see your real files. +

+
+ )} + + {currentView !== "my-files" && ( +

+ {viewTitle[currentView]} +

+ )} + {currentView === "my-files" && ( + )} - {currentView === "my-files" && } setUploadOpen(true)} + onUpload={() => { + setUploadFiles([]) + setUploadOpen(true) + }} /> - setUploadOpen(true)}> - - {state.viewMode === "grid" ? ( + + + {state.isLoading && state.files.length === 0 ? ( +
+ +
+ ) : state.viewMode === "grid" ? ( )} + + {state.nextPageToken && ( +
+ +
+ )}
- + !open && setRenameFile(null)} + onOpenChange={open => !open && setRenameFile(null)} file={renameFile} /> !open && setMoveFile(null)} + onOpenChange={open => !open && setMoveFile(null)} file={moveFile} /> -
) } diff --git a/src/components/files/file-context-menu.tsx b/src/components/files/file-context-menu.tsx index 097b746..4f577a9 100755 --- a/src/components/files/file-context-menu.tsx +++ b/src/components/files/file-context-menu.tsx @@ -3,6 +3,7 @@ import { IconDownload, IconEdit, + IconExternalLink, IconFolderSymlink, IconShare, IconStar, @@ -13,6 +14,7 @@ import { import type { FileItem } from "@/lib/files-data" import { useFiles } from "@/hooks/use-files" +import { isGoogleNativeFile } from "@/lib/google/mapper" import { ContextMenu, ContextMenuContent, @@ -33,43 +35,143 @@ export function FileContextMenu({ onRename: (file: FileItem) => void onMove: (file: FileItem) => void }) { - const { dispatch } = useFiles() + const { + starFile, + trashFile, + restoreFile, + state, + dispatch, + } = useFiles() + + const handleStar = async () => { + if (state.isConnected === true) { + await starFile(file.id) + } else { + dispatch({ type: "OPTIMISTIC_STAR", payload: file.id }) + } + } + + const handleTrash = async () => { + if (state.isConnected === true) { + const ok = await trashFile(file.id) + if (ok) { + toast.success(`"${file.name}" moved to trash`) + } else { + toast.error("Failed to delete file") + } + } else { + dispatch({ + type: "OPTIMISTIC_TRASH", + payload: file.id, + }) + toast.success(`"${file.name}" moved to trash`) + } + } + + const handleRestore = async () => { + if (state.isConnected === true) { + const ok = await restoreFile(file.id) + if (ok) { + toast.success(`"${file.name}" restored`) + } else { + toast.error("Failed to restore file") + } + } else { + dispatch({ + type: "OPTIMISTIC_RESTORE", + payload: file.id, + }) + toast.success(`"${file.name}" restored`) + } + } + + const handleDownload = () => { + if (state.isConnected === true) { + window.open( + `/api/google/download/${file.id}`, + "_blank" + ) + } else { + toast.success("Download started") + } + } + + const handleOpenInDrive = () => { + if (file.webViewLink) { + window.open(file.webViewLink, "_blank") + } + } return ( - {children} + + {children} + {file.type === "folder" && ( - toast.info("Opening folder...")}> - + + toast.info("Opening folder...") + } + > + Open )} - toast.info("Share dialog coming soon")}> + + toast.info("Share dialog coming soon") + } + > Share {!file.trashed && ( <> - toast.success("Download started")}> - - Download - + {file.webViewLink && + file.mimeType && + isGoogleNativeFile(file.mimeType) && ( + + + Open in Google Drive + + )} + {file.type !== "folder" && ( + + + Download + + )} onRename(file)}> Rename onMove(file)}> - + Move to - dispatch({ type: "STAR_FILE", payload: file.id })} - > + {file.starred ? ( <> - + Unstar ) : ( @@ -82,10 +184,7 @@ export function FileContextMenu({ { - dispatch({ type: "TRASH_FILE", payload: file.id }) - toast.success(`"${file.name}" moved to trash`) - }} + onClick={handleTrash} > Delete @@ -93,12 +192,7 @@ export function FileContextMenu({ )} {file.trashed && ( - { - dispatch({ type: "RESTORE_FILE", payload: file.id }) - toast.success(`"${file.name}" restored`) - }} - > + Restore diff --git a/src/components/files/file-drop-zone.tsx b/src/components/files/file-drop-zone.tsx index 96f9f6c..43e54d8 100755 --- a/src/components/files/file-drop-zone.tsx +++ b/src/components/files/file-drop-zone.tsx @@ -9,7 +9,7 @@ export function FileDropZone({ onDrop, }: { children: React.ReactNode - onDrop: () => void + onDrop: (files: File[]) => void }) { const [dragging, setDragging] = useState(false) const [dragCounter, setDragCounter] = useState(0) @@ -17,7 +17,7 @@ export function FileDropZone({ const handleDragEnter = useCallback( (e: React.DragEvent) => { e.preventDefault() - setDragCounter((c) => c + 1) + setDragCounter(c => c + 1) if (e.dataTransfer.types.includes("Files")) { setDragging(true) } @@ -28,7 +28,7 @@ export function FileDropZone({ const handleDragLeave = useCallback( (e: React.DragEvent) => { e.preventDefault() - setDragCounter((c) => { + setDragCounter(c => { const next = c - 1 if (next <= 0) setDragging(false) return Math.max(0, next) @@ -37,9 +37,12 @@ export function FileDropZone({ [] ) - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - }, []) + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + }, + [] + ) const handleDrop = useCallback( (e: React.DragEvent) => { @@ -47,7 +50,7 @@ export function FileDropZone({ setDragging(false) setDragCounter(0) if (e.dataTransfer.files.length > 0) { - onDrop() + onDrop(Array.from(e.dataTransfer.files)) } }, [onDrop] @@ -73,7 +76,9 @@ export function FileDropZone({ >
-

Drop files to upload

+

+ Drop files to upload +

diff --git a/src/components/files/file-item.tsx b/src/components/files/file-item.tsx index 59e9899..614e5da 100755 --- a/src/components/files/file-item.tsx +++ b/src/components/files/file-item.tsx @@ -1,7 +1,11 @@ "use client" import { forwardRef } from "react" -import { IconStar, IconStarFilled, IconUsers, IconDots } from "@tabler/icons-react" +import { + IconStar, + IconStarFilled, + IconUsers, +} from "@tabler/icons-react" import { useRouter } from "next/navigation" import type { FileItem as FileItemType } from "@/lib/files-data" @@ -29,13 +33,32 @@ export const FolderCard = forwardRef< selected: boolean onClick: (e: React.MouseEvent) => void } ->(function FolderCard({ file, selected, onClick, ...props }, ref) { +>(function FolderCard( + { file, selected, onClick, ...props }, + ref +) { const router = useRouter() - const { dispatch } = useFiles() + const { starFile, state, dispatch } = useFiles() const handleDoubleClick = () => { - const folderPath = [...file.path, file.name].join("/") - router.push(`/dashboard/files/${folderPath}`) + if (state.isConnected === true) { + router.push(`/dashboard/files/folder/${file.id}`) + } else { + const folderPath = [...file.path, file.name].join("/") + router.push(`/dashboard/files/${folderPath}`) + } + } + + const handleStar = async (e: React.MouseEvent) => { + e.stopPropagation() + if (state.isConnected === true) { + await starFile(file.id) + } else { + dispatch({ + type: "OPTIMISTIC_STAR", + payload: file.id, + }) + } } return ( @@ -50,25 +73,37 @@ export const FolderCard = forwardRef< onDoubleClick={handleDoubleClick} {...props} > - - {file.name} + + + {file.name} + {file.shared && ( - + )} @@ -82,8 +117,23 @@ export const FileCard = forwardRef< selected: boolean onClick: (e: React.MouseEvent) => void } ->(function FileCard({ file, selected, onClick, ...props }, ref) { - const { dispatch } = useFiles() +>(function FileCard( + { file, selected, onClick, ...props }, + ref +) { + const { starFile, state, dispatch } = useFiles() + + const handleStar = async (e: React.MouseEvent) => { + e.stopPropagation() + if (state.isConnected === true) { + await starFile(file.id) + } else { + dispatch({ + type: "OPTIMISTIC_STAR", + payload: file.id, + }) + } + } return (
- +
-

{file.name}

+

+ {file.name} +

{formatRelativeDate(file.modifiedAt)} @@ -116,15 +173,18 @@ export const FileCard = forwardRef< "opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0", file.starred && "opacity-100" )} - onClick={(e) => { - e.stopPropagation() - dispatch({ type: "STAR_FILE", payload: file.id }) - }} + onClick={handleStar} > {file.starred ? ( - + ) : ( - + )}

diff --git a/src/components/files/file-move-dialog.tsx b/src/components/files/file-move-dialog.tsx index 3a0a54d..233933a 100755 --- a/src/components/files/file-move-dialog.tsx +++ b/src/components/files/file-move-dialog.tsx @@ -1,7 +1,11 @@ "use client" -import { useState } from "react" -import { IconFolder, IconFolderSymlink } from "@tabler/icons-react" +import { useState, useEffect } from "react" +import { + IconFolder, + IconFolderSymlink, + IconLoader2, +} from "@tabler/icons-react" import type { FileItem } from "@/lib/files-data" import { useFiles } from "@/hooks/use-files" @@ -17,6 +21,8 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { cn } from "@/lib/utils" import { toast } from "sonner" +type FolderEntry = { id: string; name: string } + export function FileMoveDialog({ open, onOpenChange, @@ -26,31 +32,91 @@ export function FileMoveDialog({ onOpenChange: (open: boolean) => void file: FileItem | null }) { - const { dispatch, getFolders } = useFiles() - const [selectedFolderId, setSelectedFolderId] = useState(null) + const { + moveFile: moveFileFn, + fetchFolders, + getFolders, + state, + dispatch, + } = useFiles() - const folders = getFolders().filter((f) => f.id !== file?.id) + const [selectedFolderId, setSelectedFolderId] = useState< + string | null + >(null) + const [loading, setLoading] = useState(false) + const [movePending, setMovePending] = useState(false) + const [driveFolders, setDriveFolders] = useState< + FolderEntry[] + >([]) - const handleMove = () => { + // fetch folders when dialog opens + useEffect(() => { + if (!open) return + + if (state.isConnected === true) { + setLoading(true) + fetchFolders().then(folders => { + if (folders) { + setDriveFolders( + folders.filter(f => f.id !== file?.id) + ) + } + setLoading(false) + }) + } + }, [open, state.isConnected, fetchFolders, file?.id]) + + const mockFolders = getFolders().filter( + f => f.id !== file?.id + ) + + const handleMove = async () => { if (!file) return - const targetFolder = folders.find((f) => f.id === selectedFolderId) - const targetPath = targetFolder - ? [...targetFolder.path, targetFolder.name] - : [] + setMovePending(true) + try { + if (state.isConnected === true) { + if (!selectedFolderId) { + toast.error("Select a destination folder") + return + } + const oldParentId = file.parentId ?? "root" + const ok = await moveFileFn( + file.id, + selectedFolderId, + oldParentId + ) + if (ok) { + const dest = driveFolders.find( + f => f.id === selectedFolderId + ) + toast.success( + `Moved "${file.name}" to ${dest?.name ?? "folder"}` + ) + } else { + toast.error("Failed to move file") + } + } else { + // mock mode + const targetFolder = mockFolders.find( + f => f.id === selectedFolderId + ) + const targetPath = targetFolder + ? [...targetFolder.path, targetFolder.name] + : [] - dispatch({ - type: "MOVE_FILE", - payload: { - id: file.id, - targetFolderId: selectedFolderId, - targetPath, - }, - }) - toast.success( - `Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}` - ) - onOpenChange(false) + dispatch({ + type: "REMOVE_FILE", + payload: file.id, + }) + toast.success( + `Moved "${file.name}" to ${targetFolder?.name ?? "My Files"}` + ) + } + onOpenChange(false) + } finally { + setMovePending(false) + } } return ( @@ -62,37 +128,105 @@ export function FileMoveDialog({ Move to - -
+ ) : ( + + {state.isConnected === true ? ( + <> + {driveFolders.map(folder => ( + + ))} + {driveFolders.length === 0 && ( +

+ No folders found +

+ )} + + ) : ( + <> + + {mockFolders.map(folder => ( + + ))} + )} - onClick={() => setSelectedFolderId(null)} - > - - My Files (root) - - {folders.map((folder) => ( - - ))} -
+ + )} + - - + diff --git a/src/components/files/file-new-folder-dialog.tsx b/src/components/files/file-new-folder-dialog.tsx index eacc71b..e1b8bf7 100755 --- a/src/components/files/file-new-folder-dialog.tsx +++ b/src/components/files/file-new-folder-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { IconFolderPlus } from "@tabler/icons-react" +import { IconFolderPlus, IconLoader2 } from "@tabler/icons-react" import { useFiles } from "@/hooks/use-files" import { Button } from "@/components/ui/button" @@ -27,19 +27,52 @@ export function FileNewFolderDialog({ parentId: string | null }) { const [name, setName] = useState("") - const { dispatch } = useFiles() + const [loading, setLoading] = useState(false) + const { createFolder, state, dispatch } = useFiles() - const handleCreate = () => { + const handleCreate = async () => { const trimmed = name.trim() if (!trimmed) return - dispatch({ - type: "CREATE_FOLDER", - payload: { name: trimmed, parentId, path: currentPath }, - }) - toast.success(`Folder "${trimmed}" created`) - setName("") - onOpenChange(false) + setLoading(true) + try { + if (state.isConnected === true) { + const ok = await createFolder( + trimmed, + parentId ?? undefined + ) + if (ok) { + toast.success(`Folder "${trimmed}" created`) + } else { + toast.error("Failed to create folder") + } + } else { + // mock mode: local dispatch + dispatch({ + type: "OPTIMISTIC_ADD_FOLDER", + payload: { + id: `folder-${Date.now()}`, + name: trimmed, + type: "folder", + size: 0, + path: currentPath, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + owner: { name: "You" }, + starred: false, + shared: false, + trashed: false, + parentId, + }, + }) + toast.success(`Folder "${trimmed}" created`) + } + + setName("") + onOpenChange(false) + } finally { + setLoading(false) + } } return ( @@ -55,16 +88,32 @@ export function FileNewFolderDialog({ setName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleCreate()} + onChange={e => setName(e.target.value)} + onKeyDown={e => + e.key === "Enter" && handleCreate() + } autoFocus + disabled={loading} /> - - diff --git a/src/components/files/file-rename-dialog.tsx b/src/components/files/file-rename-dialog.tsx index 6fc7f0e..076e8d8 100755 --- a/src/components/files/file-rename-dialog.tsx +++ b/src/components/files/file-rename-dialog.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect } from "react" -import { IconEdit } from "@tabler/icons-react" +import { IconEdit, IconLoader2 } from "@tabler/icons-react" import type { FileItem } from "@/lib/files-data" import { useFiles } from "@/hooks/use-files" @@ -26,20 +26,42 @@ export function FileRenameDialog({ file: FileItem | null }) { const [name, setName] = useState("") - const { dispatch } = useFiles() + const [loading, setLoading] = useState(false) + const { + renameFile: renameFileFn, + state, + dispatch, + } = useFiles() useEffect(() => { if (file) setName(file.name) }, [file]) - const handleRename = () => { + const handleRename = async () => { if (!file) return const trimmed = name.trim() if (!trimmed || trimmed === file.name) return - dispatch({ type: "RENAME_FILE", payload: { id: file.id, name: trimmed } }) - toast.success(`Renamed to "${trimmed}"`) - onOpenChange(false) + setLoading(true) + try { + if (state.isConnected === true) { + const ok = await renameFileFn(file.id, trimmed) + if (ok) { + toast.success(`Renamed to "${trimmed}"`) + } else { + toast.error("Failed to rename") + } + } else { + dispatch({ + type: "OPTIMISTIC_RENAME", + payload: { id: file.id, name: trimmed }, + }) + toast.success(`Renamed to "${trimmed}"`) + } + onOpenChange(false) + } finally { + setLoading(false) + } } return ( @@ -54,19 +76,36 @@ export function FileRenameDialog({
setName(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleRename()} + onChange={e => setName(e.target.value)} + onKeyDown={e => + e.key === "Enter" && handleRename() + } autoFocus + disabled={loading} />
- diff --git a/src/components/files/file-row.tsx b/src/components/files/file-row.tsx index 25f09fb..1fdb68b 100755 --- a/src/components/files/file-row.tsx +++ b/src/components/files/file-row.tsx @@ -2,10 +2,17 @@ import { forwardRef } from "react" import { useRouter } from "next/navigation" -import { IconStar, IconStarFilled, IconUsers } from "@tabler/icons-react" +import { + IconStar, + IconStarFilled, + IconUsers, +} from "@tabler/icons-react" import type { FileItem } from "@/lib/files-data" -import { formatFileSize, formatRelativeDate } from "@/lib/file-utils" +import { + formatFileSize, + formatRelativeDate, +} from "@/lib/file-utils" import { FileIcon } from "./file-icon" import { useFiles } from "@/hooks/use-files" import { TableCell, TableRow } from "@/components/ui/table" @@ -18,14 +25,35 @@ export const FileRow = forwardRef< selected: boolean onClick: (e: React.MouseEvent) => void } ->(function FileRow({ file, selected, onClick, ...props }, ref) { +>(function FileRow( + { file, selected, onClick, ...props }, + ref +) { const router = useRouter() - const { dispatch } = useFiles() + const { starFile, state, dispatch } = useFiles() const handleDoubleClick = () => { if (file.type === "folder") { - const folderPath = [...file.path, file.name].join("/") - router.push(`/dashboard/files/${folderPath}`) + if (state.isConnected === true) { + router.push(`/dashboard/files/folder/${file.id}`) + } else { + const folderPath = [...file.path, file.name].join( + "/" + ) + router.push(`/dashboard/files/${folderPath}`) + } + } + } + + const handleStar = async (e: React.MouseEvent) => { + e.stopPropagation() + if (state.isConnected === true) { + await starFile(file.id) + } else { + dispatch({ + type: "OPTIMISTIC_STAR", + payload: file.id, + }) } } @@ -43,8 +71,15 @@ export const FileRow = forwardRef<
- {file.name} - {file.shared && } + + {file.name} + + {file.shared && ( + + )}
@@ -54,7 +89,9 @@ export const FileRow = forwardRef< {file.owner.name} - {file.type === "folder" ? "—" : formatFileSize(file.size)} + {file.type === "folder" + ? "—" + : formatFileSize(file.size)} diff --git a/src/components/files/file-upload-dialog.tsx b/src/components/files/file-upload-dialog.tsx index 313802f..a0c5bb1 100755 --- a/src/components/files/file-upload-dialog.tsx +++ b/src/components/files/file-upload-dialog.tsx @@ -1,51 +1,229 @@ "use client" -import { useState, useEffect } from "react" -import { IconUpload } from "@tabler/icons-react" +import { useState, useEffect, useCallback, useRef } from "react" +import { IconUpload, IconFile, IconCheck, IconX } from "@tabler/icons-react" +import { useFiles } from "@/hooks/use-files" import { Dialog, DialogContent, DialogHeader, DialogTitle, + DialogFooter, } from "@/components/ui/dialog" import { Progress } from "@/components/ui/progress" +import { Button } from "@/components/ui/button" import { toast } from "sonner" +import { formatFileSize } from "@/lib/file-utils" + +type UploadItem = { + file: File + progress: number + status: "pending" | "uploading" | "done" | "error" + error?: string +} export function FileUploadDialog({ open, onOpenChange, + files: initialFiles, + parentId, }: { open: boolean onOpenChange: (open: boolean) => void + files?: File[] + parentId?: string | null }) { - const [progress, setProgress] = useState(0) + const { getUploadUrl, state, fetchFiles } = useFiles() + const [uploads, setUploads] = useState([]) const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(null) useEffect(() => { if (!open) { - setProgress(0) + setUploads([]) setUploading(false) return } - setUploading(true) - const interval = setInterval(() => { - setProgress((prev) => { - if (prev >= 100) { - clearInterval(interval) - setTimeout(() => { - onOpenChange(false) - toast.success("File uploaded successfully") - }, 300) - return 100 - } - return prev + Math.random() * 15 - }) - }, 200) + if (initialFiles && initialFiles.length > 0) { + setUploads( + initialFiles.map(f => ({ + file: f, + progress: 0, + status: "pending" as const, + })) + ) + } + }, [open, initialFiles]) - return () => clearInterval(interval) - }, [open, onOpenChange]) + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const selected = e.target.files + if (!selected) return + const newUploads: UploadItem[] = Array.from( + selected + ).map(f => ({ + file: f, + progress: 0, + status: "pending" as const, + })) + setUploads(prev => [...prev, ...newUploads]) + }, + [] + ) + + const uploadSingleFile = useCallback( + async (item: UploadItem, index: number) => { + setUploads(prev => + prev.map((u, i) => + i === index ? { ...u, status: "uploading" } : u + ) + ) + + try { + if (state.isConnected !== true) { + // mock mode: fake progress + for (let p = 0; p <= 100; p += 20) { + await new Promise(r => setTimeout(r, 100)) + setUploads(prev => + prev.map((u, i) => + i === index + ? { ...u, progress: Math.min(p, 100) } + : u + ) + ) + } + setUploads(prev => + prev.map((u, i) => + i === index + ? { ...u, status: "done", progress: 100 } + : u + ) + ) + return + } + + const uploadUrl = await getUploadUrl( + item.file.name, + item.file.type || "application/octet-stream", + parentId ?? undefined + ) + + if (!uploadUrl) { + throw new Error("Failed to get upload URL") + } + + // upload directly to google using XHR for progress + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("PUT", uploadUrl) + xhr.setRequestHeader( + "Content-Type", + item.file.type || "application/octet-stream" + ) + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const pct = Math.round( + (e.loaded / e.total) * 100 + ) + setUploads(prev => + prev.map((u, i) => + i === index + ? { ...u, progress: pct } + : u + ) + ) + } + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + reject( + new Error( + `Upload failed: ${xhr.status}` + ) + ) + } + } + + xhr.onerror = () => + reject(new Error("Upload failed")) + xhr.send(item.file) + }) + + setUploads(prev => + prev.map((u, i) => + i === index + ? { ...u, status: "done", progress: 100 } + : u + ) + ) + } catch (err) { + setUploads(prev => + prev.map((u, i) => + i === index + ? { + ...u, + status: "error", + error: + err instanceof Error + ? err.message + : "Upload failed", + } + : u + ) + ) + } + }, + [getUploadUrl, parentId, state.isConnected] + ) + + const handleUpload = useCallback(async () => { + if (uploads.length === 0) return + setUploading(true) + + for (let i = 0; i < uploads.length; i++) { + if (uploads[i].status === "pending") { + await uploadSingleFile(uploads[i], i) + } + } + + setUploading(false) + + const allDone = uploads.every( + u => u.status === "done" || u.status === "error" + ) + if (allDone) { + const successCount = uploads.filter( + u => u.status === "done" + ).length + if (successCount > 0) { + toast.success( + `${successCount} file${successCount > 1 ? "s" : ""} uploaded` + ) + // refresh file list + if (state.isConnected === true) { + await fetchFiles(parentId ?? undefined) + } + } + setTimeout(() => onOpenChange(false), 500) + } + }, [ + uploads, + uploadSingleFile, + onOpenChange, + state.isConnected, + fetchFiles, + parentId, + ]) + + const removeUpload = useCallback((index: number) => { + setUploads(prev => prev.filter((_, i) => i !== index)) + }, []) return ( @@ -53,23 +231,117 @@ export function FileUploadDialog({ - Uploading file + Upload files +
-
- example-file.pdf - - {Math.min(100, Math.round(progress))}% - -
- - {uploading && progress < 100 && ( -

- Uploading to cloud storage... -

+ {uploads.length === 0 && ( +
fileInputRef.current?.click()} + > + +

+ Click to select files or drag and drop +

+
)} + + {uploads.map((item, i) => ( +
+
+
+ {item.status === "done" ? ( + + ) : item.status === "error" ? ( + + ) : ( + + )} + + {item.file.name} + +
+
+ + {formatFileSize(item.file.size)} + + {item.status === "pending" && + !uploading && ( + + )} +
+
+ {(item.status === "uploading" || + item.status === "done") && ( + + )} + {item.error && ( +

+ {item.error} +

+ )} +
+ ))}
+ + + + + {uploads.length > 0 && !uploading && ( + + )} + + +
) diff --git a/src/components/google/connect-dialog.tsx b/src/components/google/connect-dialog.tsx new file mode 100755 index 0000000..ae00124 --- /dev/null +++ b/src/components/google/connect-dialog.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useState, useRef } from "react" +import { + IconBrandGoogleDrive, + IconUpload, + IconLoader2, + IconCheck, +} from "@tabler/icons-react" + +import { connectGoogleDrive } from "@/app/actions/google-drive" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { toast } from "sonner" + +export function GoogleConnectDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [keyFile, setKeyFile] = useState(null) + const [keyFileName, setKeyFileName] = useState("") + const [domain, setDomain] = useState("") + const [loading, setLoading] = useState(false) + const fileInputRef = useRef(null) + + const handleFileSelect = ( + e: React.ChangeEvent + ) => { + const file = e.target.files?.[0] + if (!file) return + + setKeyFileName(file.name) + const reader = new FileReader() + reader.onload = ev => { + const content = ev.target?.result + if (typeof content === "string") { + setKeyFile(content) + // try to extract domain hint from client_email + try { + const parsed = JSON.parse(content) + if (parsed.client_email) { + // not setting domain automatically - admin needs to enter workspace domain + } + } catch { + // ignore + } + } + } + reader.readAsText(file) + } + + const handleConnect = async () => { + if (!keyFile || !domain.trim()) return + + setLoading(true) + const result = await connectGoogleDrive( + keyFile, + domain.trim() + ) + + if (result.success) { + toast.success("Google Drive connected") + setKeyFile(null) + setKeyFileName("") + setDomain("") + onOpenChange(false) + } else { + toast.error(result.error) + } + setLoading(false) + } + + return ( + + + + + + Connect Google Drive + + + Upload your Google service account JSON key and + enter your workspace domain. + + + +
+
+ +
fileInputRef.current?.click()} + > + {keyFile ? ( + <> + + + {keyFileName} + + + ) : ( + <> + + + Click to select JSON key file + + + )} +
+ +
+ +
+ + setDomain(e.target.value)} + disabled={loading} + /> +

+ The Google Workspace domain your organization + uses. +

+
+
+ + + + + +
+
+ ) +} diff --git a/src/components/google/connection-status.tsx b/src/components/google/connection-status.tsx new file mode 100755 index 0000000..5a4378c --- /dev/null +++ b/src/components/google/connection-status.tsx @@ -0,0 +1,155 @@ +"use client" + +import { useState, useEffect } from "react" +import { + IconBrandGoogleDrive, + IconCheck, + IconX, + IconLoader2, +} from "@tabler/icons-react" + +import { + getGoogleDriveConnectionStatus, + disconnectGoogleDrive, +} from "@/app/actions/google-drive" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import { GoogleConnectDialog } from "./connect-dialog" +import { SharedDrivePicker } from "./shared-drive-picker" + +export function GoogleDriveConnectionStatus() { + const [status, setStatus] = useState<{ + connected: boolean + workspaceDomain: string | null + sharedDriveName: string | null + } | null>(null) + const [loading, setLoading] = useState(true) + const [connectOpen, setConnectOpen] = useState(false) + const [pickerOpen, setPickerOpen] = useState(false) + const [disconnecting, setDisconnecting] = useState(false) + + const fetchStatus = async () => { + setLoading(true) + const result = await getGoogleDriveConnectionStatus() + setStatus(result) + setLoading(false) + } + + useEffect(() => { + fetchStatus() + }, []) + + const handleDisconnect = async () => { + setDisconnecting(true) + const result = await disconnectGoogleDrive() + if (result.success) { + toast.success("Google Drive disconnected") + await fetchStatus() + } else { + toast.error(result.error) + } + setDisconnecting(false) + } + + if (loading) { + return ( +
+ + + Checking Google Drive connection... + +
+ ) + } + + return ( + <> +
+
+
+ + + Google Drive + + {status?.connected ? ( + + + Connected + + ) : ( + + + Not connected + + )} +
+
+ + {status?.connected && ( +
+

Domain: {status.workspaceDomain}

+ {status.sharedDriveName && ( +

Shared drive: {status.sharedDriveName}

+ )} +
+ )} + +
+ {status?.connected ? ( + <> + + + + ) : ( + + )} +
+
+ + { + setConnectOpen(open) + if (!open) fetchStatus() + }} + /> + { + setPickerOpen(open) + if (!open) fetchStatus() + }} + /> + + ) +} diff --git a/src/components/google/shared-drive-picker.tsx b/src/components/google/shared-drive-picker.tsx new file mode 100755 index 0000000..e009e5d --- /dev/null +++ b/src/components/google/shared-drive-picker.tsx @@ -0,0 +1,161 @@ +"use client" + +import { useState, useEffect } from "react" +import { + IconFolder, + IconLoader2, + IconFolderShare, +} from "@tabler/icons-react" + +import { + listAvailableSharedDrives, + selectSharedDrive, +} from "@/app/actions/google-drive" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { ScrollArea } from "@/components/ui/scroll-area" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +export function SharedDrivePicker({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [drives, setDrives] = useState< + ReadonlyArray<{ id: string; name: string }> + >([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [selectedId, setSelectedId] = useState< + string | null + >(null) + + useEffect(() => { + if (!open) return + + setLoading(true) + listAvailableSharedDrives().then(result => { + if (result.success) { + setDrives(result.drives) + } else { + toast.error(result.error) + } + setLoading(false) + }) + }, [open]) + + const handleSelect = async () => { + setSaving(true) + const driveName = + selectedId === null + ? null + : drives.find(d => d.id === selectedId)?.name ?? + null + + const result = await selectSharedDrive( + selectedId, + driveName + ) + if (result.success) { + toast.success( + selectedId + ? `Using shared drive "${driveName}"` + : "Using root drive" + ) + onOpenChange(false) + } else { + toast.error(result.error) + } + setSaving(false) + } + + return ( + + + + + + Select Shared Drive + + + + {loading ? ( +
+ +
+ ) : ( + + + {drives.map(drive => ( + + ))} + {drives.length === 0 && ( +

+ No shared drives found. +

+ )} +
+ )} + + + + + +
+
+ ) +} diff --git a/src/components/google/user-email-mapping.tsx b/src/components/google/user-email-mapping.tsx new file mode 100755 index 0000000..7d148a1 --- /dev/null +++ b/src/components/google/user-email-mapping.tsx @@ -0,0 +1,77 @@ +"use client" + +import { useState } from "react" +import { IconLoader2, IconMail } from "@tabler/icons-react" + +import { updateUserGoogleEmail } from "@/app/actions/google-drive" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "sonner" + +export function UserEmailMapping({ + userId, + currentEmail, + googleEmail, +}: { + userId: string + currentEmail: string + googleEmail: string | null +}) { + const [email, setEmail] = useState(googleEmail ?? "") + const [loading, setLoading] = useState(false) + + const handleSave = async () => { + setLoading(true) + const value = email.trim() || null + const result = await updateUserGoogleEmail(userId, value) + if (result.success) { + toast.success( + value + ? `Google email set to ${value}` + : "Google email override removed" + ) + } else { + toast.error(result.error) + } + setLoading(false) + } + + return ( +
+
+ + +
+

+ Default: {currentEmail}. Set a different email if + your Google Workspace account uses a different + address. +

+
+ setEmail(e.target.value)} + className="h-8 text-sm" + disabled={loading} + /> + +
+
+ ) +} diff --git a/src/components/nav-files.tsx b/src/components/nav-files.tsx index d618ff9..20bf4f1 100755 --- a/src/components/nav-files.tsx +++ b/src/components/nav-files.tsx @@ -11,7 +11,7 @@ import { import Link from "next/link" import { usePathname, useSearchParams } from "next/navigation" -import { mockStorageUsage } from "@/lib/files-data" +import { useFiles } from "@/hooks/use-files" import { StorageIndicator } from "@/components/files/storage-indicator" import { SidebarGroup, @@ -22,11 +22,24 @@ import { } from "@/components/ui/sidebar" import { cn } from "@/lib/utils" -type FileView = "my-files" | "shared" | "recent" | "starred" | "trash" +type FileView = + | "my-files" + | "shared" + | "recent" + | "starred" + | "trash" -const fileNavItems: { title: string; view: FileView; icon: typeof IconFiles }[] = [ +const fileNavItems: { + title: string + view: FileView + icon: typeof IconFiles +}[] = [ { title: "My Files", view: "my-files", icon: IconFiles }, - { title: "Shared with me", view: "shared", icon: IconUsers }, + { + title: "Shared with me", + view: "shared", + icon: IconUsers, + }, { title: "Recent", view: "recent", icon: IconClock }, { title: "Starred", view: "starred", icon: IconStar }, { title: "Trash", view: "trash", icon: IconTrash }, @@ -36,6 +49,7 @@ export function NavFiles() { const pathname = usePathname() const searchParams = useSearchParams() const activeView = searchParams.get("view") ?? "my-files" + const { storageUsage } = useFiles() return ( <> @@ -43,7 +57,10 @@ export function NavFiles() { - + Back @@ -56,14 +73,16 @@ export function NavFiles() { - {fileNavItems.map((item) => ( + {fileNavItems.map(item => ( @@ -84,7 +103,7 @@ export function NavFiles() {
- +
) diff --git a/src/components/settings-modal.tsx b/src/components/settings-modal.tsx index 7a3e61b..c51fd9c 100755 --- a/src/components/settings-modal.tsx +++ b/src/components/settings-modal.tsx @@ -25,6 +25,7 @@ import { import { Separator } from "@/components/ui/separator" import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status" import { SyncControls } from "@/components/netsuite/sync-controls" +import { GoogleDriveConnectionStatus } from "@/components/google/connection-status" import { MemoriesTable } from "@/components/agent/memories-table" import { SkillsTab } from "@/components/settings/skills-tab" import { AIModelTab } from "@/components/settings/ai-model-tab" @@ -150,6 +151,8 @@ export function SettingsModal({ const integrationsPage = ( <> + + @@ -309,6 +312,8 @@ export function SettingsModal({ value="integrations" className="space-y-3 pt-3" > + + diff --git a/src/db/index.ts b/src/db/index.ts index 6056edd..ae54ec2 100755 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -4,6 +4,7 @@ import * as netsuiteSchema from "./schema-netsuite" import * as pluginSchema from "./schema-plugins" import * as agentSchema from "./schema-agent" import * as aiConfigSchema from "./schema-ai-config" +import * as googleSchema from "./schema-google" const allSchemas = { ...schema, @@ -11,6 +12,7 @@ const allSchemas = { ...pluginSchema, ...agentSchema, ...aiConfigSchema, + ...googleSchema, } export function getDb(d1: D1Database) { diff --git a/src/db/schema-google.ts b/src/db/schema-google.ts new file mode 100755 index 0000000..d298f9b --- /dev/null +++ b/src/db/schema-google.ts @@ -0,0 +1,34 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { users, organizations } from "./schema" + +export const googleAuth = sqliteTable("google_auth", { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + serviceAccountKeyEncrypted: text( + "service_account_key_encrypted" + ).notNull(), + workspaceDomain: text("workspace_domain").notNull(), + sharedDriveId: text("shared_drive_id"), + sharedDriveName: text("shared_drive_name"), + connectedBy: text("connected_by") + .notNull() + .references(() => users.id), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}) + +export const googleStarredFiles = sqliteTable("google_starred_files", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + googleFileId: text("google_file_id").notNull(), + createdAt: text("created_at").notNull(), +}) + +export type GoogleAuth = typeof googleAuth.$inferSelect +export type NewGoogleAuth = typeof googleAuth.$inferInsert +export type GoogleStarredFile = typeof googleStarredFiles.$inferSelect +export type NewGoogleStarredFile = typeof googleStarredFiles.$inferInsert diff --git a/src/db/schema.ts b/src/db/schema.ts index 4e75765..34ee42d 100755 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,6 +14,7 @@ export const users = sqliteTable("users", { displayName: text("display_name"), avatarUrl: text("avatar_url"), role: text("role").notNull().default("office"), // admin, office, field, client + googleEmail: text("google_email"), // override for google workspace impersonation isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), lastLoginAt: text("last_login_at"), createdAt: text("created_at").notNull(), diff --git a/src/hooks/use-files.tsx b/src/hooks/use-files.tsx index 01ef1d6..dacf4e0 100755 --- a/src/hooks/use-files.tsx +++ b/src/hooks/use-files.tsx @@ -5,9 +5,31 @@ import { useContext, useReducer, useCallback, + useEffect, + useRef, type ReactNode, } from "react" -import { mockFiles, mockStorageUsage, type FileItem } from "@/lib/files-data" +import { + mockFiles, + mockStorageUsage, + type FileItem, + type StorageUsage, +} from "@/lib/files-data" +import { + getGoogleDriveConnectionStatus, + listDriveFiles, + listDriveFilesForView, + searchDriveFiles, + createDriveFolder, + renameDriveFile, + moveDriveFile, + trashDriveFile, + restoreDriveFile, + toggleStarFile, + getDriveStorageQuota, + getUploadSessionUrl, + listDriveFolders, +} from "@/app/actions/google-drive" export type FileView = | "my-files" @@ -28,6 +50,11 @@ type FilesState = { sortDirection: SortDirection searchQuery: string files: FileItem[] + isConnected: boolean | null + isLoading: boolean + error: string | null + storageQuota: StorageUsage + nextPageToken: string | null } type FilesAction = @@ -36,22 +63,41 @@ type FilesAction = | { type: "SET_SELECTED"; payload: Set } | { type: "TOGGLE_SELECTED"; payload: string } | { type: "CLEAR_SELECTION" } - | { type: "SET_SORT"; payload: { field: SortField; direction: SortDirection } } + | { + type: "SET_SORT" + payload: { field: SortField; direction: SortDirection } + } | { type: "SET_SEARCH"; payload: string } - | { type: "STAR_FILE"; payload: string } - | { type: "TRASH_FILE"; payload: string } - | { type: "RESTORE_FILE"; payload: string } - | { type: "RENAME_FILE"; payload: { id: string; name: string } } - | { type: "CREATE_FOLDER"; payload: { name: string; parentId: string | null; path: string[] } } - | { type: "CREATE_FILE"; payload: { name: string; fileType: FileItem["type"]; parentId: string | null; path: string[] } } - | { type: "MOVE_FILE"; payload: { id: string; targetFolderId: string | null; targetPath: string[] } } + | { type: "SET_FILES"; payload: FileItem[] } + | { type: "APPEND_FILES"; payload: FileItem[] } + | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_ERROR"; payload: string | null } + | { type: "SET_CONNECTED"; payload: boolean } + | { type: "SET_STORAGE_QUOTA"; payload: StorageUsage } + | { type: "SET_NEXT_PAGE_TOKEN"; payload: string | null } + | { type: "OPTIMISTIC_STAR"; payload: string } + | { type: "OPTIMISTIC_TRASH"; payload: string } + | { type: "OPTIMISTIC_RESTORE"; payload: string } + | { + type: "OPTIMISTIC_RENAME" + payload: { id: string; name: string } + } + | { type: "OPTIMISTIC_ADD_FOLDER"; payload: FileItem } + | { type: "REMOVE_FILE"; payload: string } -function filesReducer(state: FilesState, action: FilesAction): FilesState { +function filesReducer( + state: FilesState, + action: FilesAction +): FilesState { switch (action.type) { case "SET_VIEW_MODE": return { ...state, viewMode: action.payload } case "SET_CURRENT_VIEW": - return { ...state, currentView: action.payload, selectedIds: new Set() } + return { + ...state, + currentView: action.payload, + selectedIds: new Set(), + } case "SET_SELECTED": return { ...state, selectedIds: action.payload } case "TOGGLE_SELECTED": { @@ -69,86 +115,77 @@ function filesReducer(state: FilesState, action: FilesAction): FilesState { sortDirection: action.payload.direction, } case "SET_SEARCH": - return { ...state, searchQuery: action.payload, selectedIds: new Set() } - case "STAR_FILE": return { ...state, - files: state.files.map((f) => - f.id === action.payload ? { ...f, starred: !f.starred } : f + searchQuery: action.payload, + selectedIds: new Set(), + } + case "SET_FILES": + return { ...state, files: action.payload } + case "APPEND_FILES": + return { + ...state, + files: [...state.files, ...action.payload], + } + case "SET_LOADING": + return { ...state, isLoading: action.payload } + case "SET_ERROR": + return { ...state, error: action.payload } + case "SET_CONNECTED": + return { ...state, isConnected: action.payload } + case "SET_STORAGE_QUOTA": + return { ...state, storageQuota: action.payload } + case "SET_NEXT_PAGE_TOKEN": + return { ...state, nextPageToken: action.payload } + case "OPTIMISTIC_STAR": + return { + ...state, + files: state.files.map(f => + f.id === action.payload + ? { ...f, starred: !f.starred } + : f ), } - case "TRASH_FILE": + case "OPTIMISTIC_TRASH": return { ...state, - files: state.files.map((f) => - f.id === action.payload ? { ...f, trashed: true } : f + files: state.files.filter( + f => f.id !== action.payload ), selectedIds: new Set(), } - case "RESTORE_FILE": + case "OPTIMISTIC_RESTORE": return { ...state, - files: state.files.map((f) => - f.id === action.payload ? { ...f, trashed: false } : f + files: state.files.filter( + f => f.id !== action.payload ), } - case "RENAME_FILE": + case "OPTIMISTIC_RENAME": return { ...state, - files: state.files.map((f) => - f.id === action.payload.id - ? { ...f, name: action.payload.name, modifiedAt: new Date().toISOString() } - : f - ), - } - case "CREATE_FOLDER": { - const newFolder: FileItem = { - id: `folder-${Date.now()}`, - name: action.payload.name, - type: "folder", - size: 0, - path: action.payload.path, - createdAt: new Date().toISOString(), - modifiedAt: new Date().toISOString(), - owner: { name: "Martine Vogel" }, - starred: false, - shared: false, - trashed: false, - parentId: action.payload.parentId, - } - return { ...state, files: [...state.files, newFolder] } - } - case "CREATE_FILE": { - const newFile: FileItem = { - id: `file-${Date.now()}`, - name: action.payload.name, - type: action.payload.fileType, - size: 0, - path: action.payload.path, - createdAt: new Date().toISOString(), - modifiedAt: new Date().toISOString(), - owner: { name: "Martine Vogel" }, - starred: false, - shared: false, - trashed: false, - parentId: action.payload.parentId, - } - return { ...state, files: [...state.files, newFile] } - } - case "MOVE_FILE": - return { - ...state, - files: state.files.map((f) => + files: state.files.map(f => f.id === action.payload.id ? { ...f, - parentId: action.payload.targetFolderId, - path: action.payload.targetPath, + name: action.payload.name, modifiedAt: new Date().toISOString(), } : f ), } + case "OPTIMISTIC_ADD_FOLDER": + return { + ...state, + files: [action.payload, ...state.files], + } + case "REMOVE_FILE": + return { + ...state, + files: state.files.filter( + f => f.id !== action.payload + ), + } default: return state } @@ -161,42 +198,440 @@ const initialState: FilesState = { sortBy: "name", sortDirection: "asc", searchQuery: "", - files: mockFiles, + files: [], + isConnected: null, + isLoading: true, + error: null, + storageQuota: mockStorageUsage, + nextPageToken: null, } type FilesContextValue = { state: FilesState dispatch: React.Dispatch + // fetching + fetchFiles: ( + folderId?: string, + view?: FileView + ) => Promise + loadMore: () => Promise + // mutations + createFolder: ( + name: string, + parentId?: string + ) => Promise + renameFile: ( + fileId: string, + newName: string + ) => Promise + moveFile: ( + fileId: string, + newParentId: string, + oldParentId: string + ) => Promise + trashFile: (fileId: string) => Promise + restoreFile: (fileId: string) => Promise + starFile: (fileId: string) => Promise + getUploadUrl: ( + fileName: string, + mimeType: string, + parentId?: string + ) => Promise + fetchFolders: ( + parentId?: string + ) => Promise< + ReadonlyArray<{ id: string; name: string }> | null + > + // backward compat for mock data mode getFilesForPath: (path: string[]) => FileItem[] getFilesForView: (view: FileView, path: string[]) => FileItem[] - storageUsage: typeof mockStorageUsage + storageUsage: StorageUsage getFolders: () => FileItem[] } const FilesContext = createContext(null) -export function FilesProvider({ children }: { children: ReactNode }) { +export function FilesProvider({ + children, +}: { + children: ReactNode +}) { const [state, dispatch] = useReducer(filesReducer, initialState) + const currentFolderRef = useRef(undefined) + const currentViewRef = useRef("my-files") + // check connection on mount + useEffect(() => { + let cancelled = false + async function check() { + try { + const status = + await getGoogleDriveConnectionStatus() + if (cancelled) return + dispatch({ + type: "SET_CONNECTED", + payload: status.connected, + }) + + if (!status.connected) { + // fall back to mock data + dispatch({ type: "SET_FILES", payload: mockFiles }) + dispatch({ type: "SET_LOADING", payload: false }) + } + } catch { + if (cancelled) return + dispatch({ type: "SET_CONNECTED", payload: false }) + dispatch({ type: "SET_FILES", payload: mockFiles }) + dispatch({ type: "SET_LOADING", payload: false }) + } + } + check() + return () => { + cancelled = true + } + }, []) + + // fetch storage quota when connected + useEffect(() => { + if (state.isConnected !== true) return + let cancelled = false + async function fetchQuota() { + const result = await getDriveStorageQuota() + if (cancelled) return + if (result.success) { + dispatch({ + type: "SET_STORAGE_QUOTA", + payload: { + used: result.used, + total: result.total, + }, + }) + } + } + fetchQuota() + return () => { + cancelled = true + } + }, [state.isConnected]) + + const fetchFiles = useCallback( + async (folderId?: string, view?: FileView) => { + if (state.isConnected !== true) return + + currentFolderRef.current = folderId + currentViewRef.current = view ?? "my-files" + dispatch({ type: "SET_LOADING", payload: true }) + dispatch({ type: "SET_ERROR", payload: null }) + + try { + let result + + if (view && view !== "my-files") { + result = await listDriveFilesForView(view) + } else if (state.searchQuery) { + const searchResult = await searchDriveFiles( + state.searchQuery + ) + if (searchResult.success) { + dispatch({ + type: "SET_FILES", + payload: searchResult.files, + }) + dispatch({ + type: "SET_NEXT_PAGE_TOKEN", + payload: null, + }) + } else { + dispatch({ + type: "SET_ERROR", + payload: searchResult.error, + }) + } + dispatch({ type: "SET_LOADING", payload: false }) + return + } else { + result = await listDriveFiles(folderId) + } + + if (result.success) { + dispatch({ + type: "SET_FILES", + payload: sortFiles( + result.files, + state.sortBy, + state.sortDirection + ), + }) + dispatch({ + type: "SET_NEXT_PAGE_TOKEN", + payload: result.nextPageToken, + }) + } else { + dispatch({ + type: "SET_ERROR", + payload: result.error, + }) + } + } catch (err) { + dispatch({ + type: "SET_ERROR", + payload: + err instanceof Error + ? err.message + : "Failed to load files", + }) + } finally { + dispatch({ type: "SET_LOADING", payload: false }) + } + }, + [ + state.isConnected, + state.searchQuery, + state.sortBy, + state.sortDirection, + ] + ) + + const loadMore = useCallback(async () => { + if (!state.nextPageToken || state.isConnected !== true) + return + + dispatch({ type: "SET_LOADING", payload: true }) + try { + const view = currentViewRef.current + let result + + if (view !== "my-files") { + result = await listDriveFilesForView( + view, + state.nextPageToken + ) + } else { + result = await listDriveFiles( + currentFolderRef.current, + state.nextPageToken + ) + } + + if (result.success) { + dispatch({ type: "APPEND_FILES", payload: result.files }) + dispatch({ + type: "SET_NEXT_PAGE_TOKEN", + payload: result.nextPageToken, + }) + } + } finally { + dispatch({ type: "SET_LOADING", payload: false }) + } + }, [state.nextPageToken, state.isConnected]) + + const createFolder = useCallback( + async ( + name: string, + parentId?: string + ): Promise => { + if (state.isConnected !== true) return false + + const result = await createDriveFolder(name, parentId) + if (result.success) { + dispatch({ + type: "OPTIMISTIC_ADD_FOLDER", + payload: result.folder, + }) + return true + } + return false + }, + [state.isConnected] + ) + + const renameFile = useCallback( + async ( + fileId: string, + newName: string + ): Promise => { + if (state.isConnected !== true) return false + + dispatch({ + type: "OPTIMISTIC_RENAME", + payload: { id: fileId, name: newName }, + }) + const result = await renameDriveFile(fileId, newName) + if (!result.success) { + // revert will happen on next fetch + return false + } + return true + }, + [state.isConnected] + ) + + const moveFile = useCallback( + async ( + fileId: string, + newParentId: string, + oldParentId: string + ): Promise => { + if (state.isConnected !== true) return false + + dispatch({ type: "REMOVE_FILE", payload: fileId }) + const result = await moveDriveFile( + fileId, + newParentId, + oldParentId + ) + if (!result.success) { + // re-fetch to recover + await fetchFiles( + currentFolderRef.current, + currentViewRef.current + ) + return false + } + return true + }, + [state.isConnected, fetchFiles] + ) + + const trashFile = useCallback( + async (fileId: string): Promise => { + if (state.isConnected !== true) return false + + dispatch({ type: "OPTIMISTIC_TRASH", payload: fileId }) + const result = await trashDriveFile(fileId) + if (!result.success) { + await fetchFiles( + currentFolderRef.current, + currentViewRef.current + ) + return false + } + return true + }, + [state.isConnected, fetchFiles] + ) + + const restoreFile = useCallback( + async (fileId: string): Promise => { + if (state.isConnected !== true) return false + + dispatch({ + type: "OPTIMISTIC_RESTORE", + payload: fileId, + }) + const result = await restoreDriveFile(fileId) + if (!result.success) { + await fetchFiles( + currentFolderRef.current, + currentViewRef.current + ) + return false + } + return true + }, + [state.isConnected, fetchFiles] + ) + + const starFile = useCallback( + async (fileId: string): Promise => { + if (state.isConnected !== true) { + // mock mode: just toggle locally + dispatch({ type: "OPTIMISTIC_STAR", payload: fileId }) + return true + } + + dispatch({ type: "OPTIMISTIC_STAR", payload: fileId }) + const result = await toggleStarFile(fileId) + if (!result.success) { + dispatch({ type: "OPTIMISTIC_STAR", payload: fileId }) + return false + } + return true + }, + [state.isConnected] + ) + + const getUploadUrl = useCallback( + async ( + fileName: string, + mimeType: string, + parentId?: string + ): Promise => { + if (state.isConnected !== true) return null + + const result = await getUploadSessionUrl( + fileName, + mimeType, + parentId + ) + if (result.success) return result.uploadUrl + return null + }, + [state.isConnected] + ) + + const fetchFolders = useCallback( + async ( + parentId?: string + ): Promise< + ReadonlyArray<{ id: string; name: string }> | null + > => { + if (state.isConnected !== true) return null + + const result = await listDriveFolders(parentId) + if (result.success) return result.folders + return null + }, + [state.isConnected] + ) + + // backward compat: mock data selectors const getFilesForPath = useCallback( (path: string[]) => { - return state.files.filter((f) => { + if (state.isConnected === true) return state.files + + const allFiles = + state.files.length > 0 ? state.files : mockFiles + return allFiles.filter(f => { if (f.trashed) return false if (path.length === 0) return f.parentId === null - const parentFolder = state.files.find( - (folder) => + const parentFolder = allFiles.find( + folder => folder.type === "folder" && folder.name === path[path.length - 1] && - JSON.stringify(folder.path) === JSON.stringify(path.slice(0, -1)) + JSON.stringify(folder.path) === + JSON.stringify(path.slice(0, -1)) ) return parentFolder && f.parentId === parentFolder.id }) }, - [state.files] + [state.files, state.isConnected] ) const getFilesForView = useCallback( (view: FileView, path: string[]) => { + // when connected, files are already fetched for + // the right view + if (state.isConnected === true) { + let files = state.files + + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase() + files = files.filter(f => + f.name.toLowerCase().includes(q) + ) + } + + return sortFiles( + files, + state.sortBy, + state.sortDirection + ) + } + + // mock data mode + const allFiles = + state.files.length > 0 ? state.files : mockFiles let files: FileItem[] switch (view) { @@ -204,21 +639,33 @@ export function FilesProvider({ children }: { children: ReactNode }) { files = getFilesForPath(path) break case "shared": - files = state.files.filter((f) => !f.trashed && f.shared) + files = allFiles.filter( + f => !f.trashed && f.shared + ) break case "recent": { const cutoff = new Date() cutoff.setDate(cutoff.getDate() - 30) - files = state.files - .filter((f) => !f.trashed && new Date(f.modifiedAt) > cutoff) - .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) + files = allFiles + .filter( + f => + !f.trashed && + new Date(f.modifiedAt) > cutoff + ) + .sort( + (a, b) => + new Date(b.modifiedAt).getTime() - + new Date(a.modifiedAt).getTime() + ) break } case "starred": - files = state.files.filter((f) => !f.trashed && f.starred) + files = allFiles.filter( + f => !f.trashed && f.starred + ) break case "trash": - files = state.files.filter((f) => f.trashed) + files = allFiles.filter(f => f.trashed) break default: files = [] @@ -226,16 +673,29 @@ export function FilesProvider({ children }: { children: ReactNode }) { if (state.searchQuery) { const q = state.searchQuery.toLowerCase() - files = files.filter((f) => f.name.toLowerCase().includes(q)) + files = files.filter(f => + f.name.toLowerCase().includes(q) + ) } return sortFiles(files, state.sortBy, state.sortDirection) }, - [state.files, state.searchQuery, state.sortBy, state.sortDirection, getFilesForPath] + [ + state.files, + state.searchQuery, + state.sortBy, + state.sortDirection, + state.isConnected, + getFilesForPath, + ] ) const getFolders = useCallback(() => { - return state.files.filter((f) => f.type === "folder" && !f.trashed) + const allFiles = + state.files.length > 0 ? state.files : mockFiles + return allFiles.filter( + f => f.type === "folder" && !f.trashed + ) }, [state.files]) return ( @@ -243,9 +703,19 @@ export function FilesProvider({ children }: { children: ReactNode }) { value={{ state, dispatch, + fetchFiles, + loadMore, + createFolder, + renameFile, + moveFile, + trashFile, + restoreFile, + starFile, + getUploadUrl, + fetchFolders, getFilesForPath, getFilesForView, - storageUsage: mockStorageUsage, + storageUsage: state.storageQuota, getFolders, }} > @@ -256,7 +726,10 @@ export function FilesProvider({ children }: { children: ReactNode }) { export function useFiles() { const ctx = useContext(FilesContext) - if (!ctx) throw new Error("useFiles must be used within FilesProvider") + if (!ctx) + throw new Error( + "useFiles must be used within FilesProvider" + ) return ctx } @@ -266,7 +739,6 @@ function sortFiles( direction: SortDirection ): FileItem[] { const sorted = [...files].sort((a, b) => { - // folders always first if (a.type === "folder" && b.type !== "folder") return -1 if (a.type !== "folder" && b.type === "folder") return 1 @@ -276,7 +748,9 @@ function sortFiles( cmp = a.name.localeCompare(b.name) break case "modified": - cmp = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime() + cmp = + new Date(a.modifiedAt).getTime() - + new Date(b.modifiedAt).getTime() break case "size": cmp = a.size - b.size diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a5c7b8f..f2e5ef6 100755 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -13,6 +13,7 @@ export type AuthUser = { readonly displayName: string | null readonly avatarUrl: string | null readonly role: string + readonly googleEmail: string | null readonly isActive: boolean readonly lastLoginAt: string | null readonly createdAt: string @@ -63,6 +64,7 @@ export async function getCurrentUser(): Promise { displayName: "Dev User", avatarUrl: null, role: "admin", + googleEmail: null, isActive: true, lastLoginAt: new Date().toISOString(), createdAt: new Date().toISOString(), @@ -108,6 +110,7 @@ export async function getCurrentUser(): Promise { displayName: dbUser.displayName, avatarUrl: dbUser.avatarUrl, role: dbUser.role, + googleEmail: dbUser.googleEmail ?? null, isActive: dbUser.isActive, lastLoginAt: now, createdAt: dbUser.createdAt, diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100755 index 0000000..92d2c04 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,76 @@ +// shared AES-256-GCM encryption for secrets at rest in D1. +// uses Web Crypto API (available in Cloudflare Workers). + +const ALGORITHM = "AES-GCM" +const KEY_LENGTH = 256 +const IV_LENGTH = 12 +const TAG_LENGTH = 128 + +async function deriveKey( + secret: string, + salt: string +): Promise { + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "PBKDF2" }, + false, + ["deriveKey"] + ) + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: encoder.encode(salt), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: ALGORITHM, length: KEY_LENGTH }, + false, + ["encrypt", "decrypt"] + ) +} + +export async function encrypt( + plaintext: string, + secret: string, + salt: string +): Promise { + const key = await deriveKey(secret, salt) + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) + const encoder = new TextEncoder() + + const ciphertext = await crypto.subtle.encrypt( + { name: ALGORITHM, iv, tagLength: TAG_LENGTH }, + key, + encoder.encode(plaintext) + ) + + const packed = new Uint8Array(iv.length + ciphertext.byteLength) + packed.set(iv) + packed.set(new Uint8Array(ciphertext), iv.length) + + return btoa(String.fromCharCode(...packed)) +} + +export async function decrypt( + encoded: string, + secret: string, + salt: string +): Promise { + const key = await deriveKey(secret, salt) + + const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)) + const iv = packed.slice(0, IV_LENGTH) + const ciphertext = packed.slice(IV_LENGTH) + + const plaintext = await crypto.subtle.decrypt( + { name: ALGORITHM, iv, tagLength: TAG_LENGTH }, + key, + ciphertext + ) + + return new TextDecoder().decode(plaintext) +} diff --git a/src/lib/files-data.ts b/src/lib/files-data.ts index 96b1248..8cc7097 100755 --- a/src/lib/files-data.ts +++ b/src/lib/files-data.ts @@ -38,6 +38,7 @@ export type FileItem = { sharedWith?: SharedUser[] trashed: boolean parentId: string | null + webViewLink?: string } export type StorageUsage = { diff --git a/src/lib/google/auth/service-account.ts b/src/lib/google/auth/service-account.ts new file mode 100755 index 0000000..16eb01b --- /dev/null +++ b/src/lib/google/auth/service-account.ts @@ -0,0 +1,126 @@ +// JWT-based auth for google service accounts with +// domain-wide delegation (impersonating workspace users). +// uses Web Crypto API for RS256 signing (cloudflare workers compatible). + +import { + GOOGLE_DRIVE_SCOPES, + GOOGLE_TOKEN_URL, + type ServiceAccountKey, +} from "../config" + +function base64url(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") +} + +function base64urlEncode(str: string): string { + return base64url(new TextEncoder().encode(str)) +} + +// convert PEM private key to CryptoKey for RS256 signing +async function importPrivateKey( + pem: string +): Promise { + const pemBody = pem + .replace(/-----BEGIN PRIVATE KEY-----/g, "") + .replace(/-----END PRIVATE KEY-----/g, "") + .replace(/\s/g, "") + + const binaryString = atob(pemBody) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + return crypto.subtle.importKey( + "pkcs8", + bytes.buffer, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"] + ) +} + +export async function createServiceAccountJWT( + serviceAccountKey: ServiceAccountKey, + userEmail: string, + scopes: ReadonlyArray = GOOGLE_DRIVE_SCOPES +): Promise { + const now = Math.floor(Date.now() / 1000) + + const header = { + alg: "RS256", + typ: "JWT", + kid: serviceAccountKey.private_key_id, + } + + const payload = { + iss: serviceAccountKey.client_email, + sub: userEmail, + scope: scopes.join(" "), + aud: GOOGLE_TOKEN_URL, + iat: now, + exp: now + 3600, + } + + const headerB64 = base64urlEncode(JSON.stringify(header)) + const payloadB64 = base64urlEncode(JSON.stringify(payload)) + const signingInput = `${headerB64}.${payloadB64}` + + const key = await importPrivateKey( + serviceAccountKey.private_key + ) + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + key, + new TextEncoder().encode(signingInput) + ) + + const signatureB64 = base64url(new Uint8Array(signature)) + + return `${signingInput}.${signatureB64}` +} + +export type AccessTokenResponse = { + readonly access_token: string + readonly token_type: string + readonly expires_in: number +} + +export async function exchangeJWTForAccessToken( + jwt: string +): Promise { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), + }) + + if (!response.ok) { + const body = await response.text() + throw new Error( + `Token exchange failed (${response.status}): ${body}` + ) + } + + return response.json() as Promise +} + +export async function getAccessToken( + serviceAccountKey: ServiceAccountKey, + userEmail: string +): Promise { + const jwt = await createServiceAccountJWT( + serviceAccountKey, + userEmail + ) + const tokenResponse = await exchangeJWTForAccessToken(jwt) + return tokenResponse.access_token +} diff --git a/src/lib/google/auth/token-cache.ts b/src/lib/google/auth/token-cache.ts new file mode 100755 index 0000000..e865c75 --- /dev/null +++ b/src/lib/google/auth/token-cache.ts @@ -0,0 +1,42 @@ +// in-memory token cache keyed by user email. +// NOTE: in cloudflare workers, this resets per request since +// each request runs in its own isolate. for now we just +// generate tokens per-request (they're fast ~100ms). +// if perf becomes an issue, swap to KV-backed cache. + +type CachedToken = { + readonly accessToken: string + readonly expiresAt: number +} + +const TOKEN_BUFFER_MS = 5 * 60 * 1000 // refresh 5 min early + +const cache = new Map() + +export function getCachedToken( + userEmail: string +): string | null { + const entry = cache.get(userEmail) + if (!entry) return null + if (Date.now() >= entry.expiresAt) { + cache.delete(userEmail) + return null + } + return entry.accessToken +} + +export function setCachedToken( + userEmail: string, + accessToken: string, + expiresInSeconds: number +): void { + cache.set(userEmail, { + accessToken, + expiresAt: + Date.now() + expiresInSeconds * 1000 - TOKEN_BUFFER_MS, + }) +} + +export function clearCachedToken(userEmail: string): void { + cache.delete(userEmail) +} diff --git a/src/lib/google/client/drive-client.ts b/src/lib/google/client/drive-client.ts new file mode 100755 index 0000000..c8835c5 --- /dev/null +++ b/src/lib/google/client/drive-client.ts @@ -0,0 +1,427 @@ +// google drive REST API v3 wrapper. +// each method accepts userEmail for domain-wide delegation impersonation. + +import { + GOOGLE_DRIVE_API, + GOOGLE_UPLOAD_API, + type ServiceAccountKey, +} from "../config" +import { + getAccessToken, +} from "../auth/service-account" +import { + getCachedToken, + setCachedToken, + clearCachedToken, +} from "../auth/token-cache" +import { ConcurrencyLimiter } from "@/lib/netsuite/rate-limiter/concurrency-limiter" +import { + DRIVE_FILE_FIELDS, + DRIVE_LIST_FIELDS, + type DriveFile, + type DriveFileList, + type DriveAbout, + type DriveSharedDriveList, + type ListFilesOptions, + type UploadOptions, +} from "./types" + +const MAX_RETRIES = 3 +const INITIAL_BACKOFF_MS = 1000 + +type DriveClientConfig = { + readonly serviceAccountKey: ServiceAccountKey + readonly limiter?: ConcurrencyLimiter +} + +export class DriveClient { + private serviceAccountKey: ServiceAccountKey + private limiter: ConcurrencyLimiter + + constructor(config: DriveClientConfig) { + this.serviceAccountKey = config.serviceAccountKey + this.limiter = config.limiter ?? new ConcurrencyLimiter(10) + } + + private async getToken(userEmail: string): Promise { + const cached = getCachedToken(userEmail) + if (cached) return cached + + const token = await getAccessToken( + this.serviceAccountKey, + userEmail + ) + setCachedToken(userEmail, token, 3600) + return token + } + + private async request( + userEmail: string, + path: string, + options: RequestInit = {}, + isUpload = false + ): Promise { + return this.limiter.execute(async () => { + let lastError: Error | null = null + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + const token = await this.getToken(userEmail) + const baseUrl = isUpload + ? GOOGLE_UPLOAD_API + : GOOGLE_DRIVE_API + + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }) + + if (response.ok) { + if (response.status === 204) return undefined as T + return response.json() as Promise + } + + // refresh token on 401 + if (response.status === 401) { + clearCachedToken(userEmail) + continue + } + + // retry on rate limit or server error + if ( + response.status === 429 || + response.status >= 500 + ) { + if (response.status === 429) { + this.limiter.reduceConcurrency() + } + const backoff = + INITIAL_BACKOFF_MS * Math.pow(2, attempt) + await new Promise(r => setTimeout(r, backoff)) + lastError = new Error( + `Google API ${response.status}: ${await response.text()}` + ) + continue + } + + // non-retryable error + const body = await response.text() + throw new Error( + `Google Drive API error (${response.status}): ${body}` + ) + } + + throw lastError ?? new Error("Max retries exceeded") + }) + } + + async listFiles( + userEmail: string, + options: ListFilesOptions = {} + ): Promise { + const params = new URLSearchParams({ + fields: DRIVE_LIST_FIELDS, + pageSize: String(options.pageSize ?? 100), + }) + + const queryParts: string[] = [] + + if (options.folderId) { + queryParts.push(`'${options.folderId}' in parents`) + } + + if (options.trashed !== undefined) { + queryParts.push(`trashed = ${options.trashed}`) + } else { + queryParts.push("trashed = false") + } + + if (options.sharedWithMe) { + queryParts.push("sharedWithMe = true") + } + + if (options.query) { + queryParts.push(options.query) + } + + if (queryParts.length > 0) { + params.set("q", queryParts.join(" and ")) + } + + if (options.orderBy) { + params.set("orderBy", options.orderBy) + } + + if (options.pageToken) { + params.set("pageToken", options.pageToken) + } + + if (options.driveId) { + params.set("driveId", options.driveId) + params.set("corpora", "drive") + params.set("includeItemsFromAllDrives", "true") + params.set("supportsAllDrives", "true") + } + + return this.request( + userEmail, + `/files?${params.toString()}` + ) + } + + async getFile( + userEmail: string, + fileId: string + ): Promise { + const params = new URLSearchParams({ + fields: DRIVE_FILE_FIELDS, + supportsAllDrives: "true", + }) + return this.request( + userEmail, + `/files/${fileId}?${params.toString()}` + ) + } + + async createFolder( + userEmail: string, + options: { + readonly name: string + readonly parentId?: string + readonly driveId?: string + } + ): Promise { + const metadata: Record = { + name: options.name, + mimeType: "application/vnd.google-apps.folder", + } + + if (options.parentId) { + metadata.parents = [options.parentId] + } else if (options.driveId) { + metadata.parents = [options.driveId] + } + + const params = new URLSearchParams({ + fields: DRIVE_FILE_FIELDS, + supportsAllDrives: "true", + }) + + return this.request( + userEmail, + `/files?${params.toString()}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(metadata), + } + ) + } + + async initiateResumableUpload( + userEmail: string, + options: UploadOptions + ): Promise { + // returns the resumable upload session URI + return this.limiter.execute(async () => { + const token = await this.getToken(userEmail) + + const metadata: Record = { + name: options.name, + mimeType: options.mimeType, + } + if (options.parentId) { + metadata.parents = [options.parentId] + } else if (options.driveId) { + metadata.parents = [options.driveId] + } + + const params = new URLSearchParams({ + uploadType: "resumable", + supportsAllDrives: "true", + fields: DRIVE_FILE_FIELDS, + }) + + const response = await fetch( + `${GOOGLE_UPLOAD_API}/files?${params.toString()}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-Upload-Content-Type": options.mimeType, + }, + body: JSON.stringify(metadata), + } + ) + + if (!response.ok) { + const body = await response.text() + throw new Error( + `Failed to initiate upload (${response.status}): ${body}` + ) + } + + const location = response.headers.get("Location") + if (!location) { + throw new Error("No upload URI in response") + } + + return location + }) + } + + async downloadFile( + userEmail: string, + fileId: string + ): Promise { + const token = await this.getToken(userEmail) + const params = new URLSearchParams({ + alt: "media", + supportsAllDrives: "true", + }) + + return fetch( + `${GOOGLE_DRIVE_API}/files/${fileId}?${params.toString()}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ) + } + + async exportFile( + userEmail: string, + fileId: string, + exportMimeType: string + ): Promise { + const token = await this.getToken(userEmail) + const params = new URLSearchParams({ + mimeType: exportMimeType, + }) + + return fetch( + `${GOOGLE_DRIVE_API}/files/${fileId}/export?${params.toString()}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ) + } + + async renameFile( + userEmail: string, + fileId: string, + newName: string + ): Promise { + const params = new URLSearchParams({ + fields: DRIVE_FILE_FIELDS, + supportsAllDrives: "true", + }) + + return this.request( + userEmail, + `/files/${fileId}?${params.toString()}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + } + ) + } + + async moveFile( + userEmail: string, + fileId: string, + newParentId: string, + oldParentId: string + ): Promise { + const params = new URLSearchParams({ + addParents: newParentId, + removeParents: oldParentId, + fields: DRIVE_FILE_FIELDS, + supportsAllDrives: "true", + }) + + return this.request( + userEmail, + `/files/${fileId}?${params.toString()}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + } + ) + } + + async trashFile( + userEmail: string, + fileId: string + ): Promise { + const params = new URLSearchParams({ + fields: DRIVE_FILE_FIELDS, + supportsAllDrives: "true", + }) + + return this.request( + userEmail, + `/files/${fileId}?${params.toString()}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trashed: true }), + } + ) + } + + async restoreFile( + userEmail: string, + fileId: string + ): Promise { + const params = new URLSearchParams({ + fields: DRIVE_FILE_FIELDS, + supportsAllDrives: "true", + }) + + return this.request( + userEmail, + `/files/${fileId}?${params.toString()}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ trashed: false }), + } + ) + } + + async getStorageQuota( + userEmail: string + ): Promise { + return this.request( + userEmail, + "/about?fields=storageQuota,user" + ) + } + + async searchFiles( + userEmail: string, + searchQuery: string, + pageSize = 50, + driveId?: string + ): Promise { + return this.listFiles(userEmail, { + query: `fullText contains '${searchQuery.replace(/'/g, "\\'")}'`, + pageSize, + driveId, + }) + } + + async listSharedDrives( + userEmail: string + ): Promise { + return this.request( + userEmail, + "/drives?pageSize=100" + ) + } +} diff --git a/src/lib/google/client/types.ts b/src/lib/google/client/types.ts new file mode 100755 index 0000000..bc81c61 --- /dev/null +++ b/src/lib/google/client/types.ts @@ -0,0 +1,89 @@ +// google drive API v3 response types + +export type DriveUser = { + readonly displayName: string + readonly photoLink?: string + readonly emailAddress?: string +} + +export type DrivePermission = { + readonly id: string + readonly type: string + readonly role: string + readonly emailAddress?: string + readonly displayName?: string + readonly photoLink?: string +} + +export type DriveFile = { + readonly id: string + readonly name: string + readonly mimeType: string + readonly size?: string + readonly createdTime?: string + readonly modifiedTime?: string + readonly owners?: ReadonlyArray + readonly parents?: ReadonlyArray + readonly permissions?: ReadonlyArray + readonly shared?: boolean + readonly trashed?: boolean + readonly webViewLink?: string + readonly webContentLink?: string + readonly iconLink?: string + readonly thumbnailLink?: string + readonly driveId?: string +} + +export type DriveFileList = { + readonly files: ReadonlyArray + readonly nextPageToken?: string + readonly incompleteSearch?: boolean +} + +export type DriveAbout = { + readonly storageQuota: { + readonly limit?: string + readonly usage: string + readonly usageInDrive: string + readonly usageInDriveTrash: string + } + readonly user: DriveUser +} + +export type DriveSharedDrive = { + readonly id: string + readonly name: string + readonly createdTime?: string +} + +export type DriveSharedDriveList = { + readonly drives: ReadonlyArray + readonly nextPageToken?: string +} + +export type ListFilesOptions = { + readonly folderId?: string + readonly query?: string + readonly pageSize?: number + readonly pageToken?: string + readonly orderBy?: string + readonly driveId?: string + readonly trashed?: boolean + readonly sharedWithMe?: boolean +} + +export type UploadOptions = { + readonly name: string + readonly parentId?: string + readonly mimeType: string + readonly driveId?: string +} + +// fields we always request from the API +export const DRIVE_FILE_FIELDS = + "id,name,mimeType,size,createdTime,modifiedTime,owners," + + "parents,permissions,shared,trashed,webViewLink," + + "webContentLink,iconLink,thumbnailLink,driveId" + +export const DRIVE_LIST_FIELDS = + `nextPageToken,files(${DRIVE_FILE_FIELDS})` diff --git a/src/lib/google/config.ts b/src/lib/google/config.ts new file mode 100755 index 0000000..dcba18b --- /dev/null +++ b/src/lib/google/config.ts @@ -0,0 +1,65 @@ +export type GoogleConfig = { + readonly encryptionKey: string +} + +export function getGoogleConfig( + env: Record +): GoogleConfig { + const encryptionKey = env.GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY + if (!encryptionKey) { + throw new Error( + "GOOGLE_SERVICE_ACCOUNT_ENCRYPTION_KEY not configured" + ) + } + + return { encryptionKey } +} + +export type ServiceAccountKey = { + readonly type: string + readonly project_id: string + readonly private_key_id: string + readonly private_key: string + readonly client_email: string + readonly client_id: string + readonly auth_uri: string + readonly token_uri: string + readonly auth_provider_x509_cert_url: string + readonly client_x509_cert_url: string + readonly universe_domain: string +} + +export function parseServiceAccountKey( + json: string +): ServiceAccountKey { + const parsed: unknown = JSON.parse(json) + if ( + typeof parsed !== "object" || + parsed === null || + !("type" in parsed) || + !("private_key" in parsed) || + !("client_email" in parsed) + ) { + throw new Error("Invalid service account key JSON") + } + return parsed as ServiceAccountKey +} + +export const GOOGLE_DRIVE_SCOPES = [ + "https://www.googleapis.com/auth/drive", +] as const + +export const GOOGLE_TOKEN_URL = + "https://oauth2.googleapis.com/token" + +export const GOOGLE_DRIVE_API = + "https://www.googleapis.com/drive/v3" + +export const GOOGLE_UPLOAD_API = + "https://www.googleapis.com/upload/drive/v3" + +const GOOGLE_CRYPTO_SALT = "compass-google-service-account" + +export function getGoogleCryptoSalt(): string { + return GOOGLE_CRYPTO_SALT +} diff --git a/src/lib/google/mapper.ts b/src/lib/google/mapper.ts new file mode 100755 index 0000000..4d9d465 --- /dev/null +++ b/src/lib/google/mapper.ts @@ -0,0 +1,135 @@ +// maps google drive API responses to our FileItem type + +import type { FileItem, FileType, SharedUser, SharedRole } from "@/lib/files-data" +import type { DriveFile } from "./client/types" + +const GOOGLE_APPS_FOLDER = "application/vnd.google-apps.folder" +const GOOGLE_APPS_DOCUMENT = "application/vnd.google-apps.document" +const GOOGLE_APPS_SPREADSHEET = + "application/vnd.google-apps.spreadsheet" +const GOOGLE_APPS_PRESENTATION = + "application/vnd.google-apps.presentation" + +function mimeTypeToFileType(mimeType: string): FileType { + if (mimeType === GOOGLE_APPS_FOLDER) return "folder" + if (mimeType === GOOGLE_APPS_DOCUMENT) return "document" + if (mimeType === GOOGLE_APPS_SPREADSHEET) + return "spreadsheet" + if (mimeType === GOOGLE_APPS_PRESENTATION) + return "document" + if (mimeType === "application/pdf") return "pdf" + if (mimeType.startsWith("image/")) return "image" + if (mimeType.startsWith("video/")) return "video" + if (mimeType.startsWith("audio/")) return "audio" + if ( + mimeType.includes("zip") || + mimeType.includes("compressed") || + mimeType.includes("archive") || + mimeType.includes("tar") || + mimeType.includes("gzip") + ) + return "archive" + if ( + mimeType.includes("spreadsheet") || + mimeType.includes("excel") || + mimeType === "text/csv" + ) + return "spreadsheet" + if ( + mimeType.includes("document") || + mimeType.includes("word") || + mimeType === "text/plain" || + mimeType === "text/rtf" + ) + return "document" + if ( + mimeType.includes("javascript") || + mimeType.includes("json") || + mimeType.includes("xml") || + mimeType.includes("html") || + mimeType.includes("css") || + mimeType.includes("typescript") + ) + return "code" + return "unknown" +} + +function mapPermissionRole(role: string): SharedRole { + if (role === "writer" || role === "owner") return "editor" + return "viewer" +} + +export function mapDriveFileToFileItem( + driveFile: DriveFile, + starredIds: ReadonlySet, + parentId: string | null = null +): FileItem { + const owner = driveFile.owners?.[0] + const sharedWith: SharedUser[] = ( + driveFile.permissions ?? [] + ) + .filter(p => p.role !== "owner" && p.type === "user") + .map(p => ({ + name: p.displayName ?? p.emailAddress ?? "Unknown", + avatar: p.photoLink, + role: mapPermissionRole(p.role), + })) + + return { + id: driveFile.id, + name: driveFile.name, + type: mimeTypeToFileType(driveFile.mimeType), + mimeType: driveFile.mimeType, + size: driveFile.size ? Number(driveFile.size) : 0, + path: [], + createdAt: driveFile.createdTime ?? new Date().toISOString(), + modifiedAt: + driveFile.modifiedTime ?? new Date().toISOString(), + owner: { + name: owner?.displayName ?? "Unknown", + avatar: owner?.photoLink, + }, + starred: starredIds.has(driveFile.id), + shared: sharedWith.length > 0 || driveFile.shared === true, + sharedWith: + sharedWith.length > 0 ? sharedWith : undefined, + trashed: driveFile.trashed ?? false, + parentId, + webViewLink: driveFile.webViewLink, + } +} + +// export types for google-native files +export function isGoogleNativeFile(mimeType: string): boolean { + return mimeType.startsWith("application/vnd.google-apps.") +} + +export function getExportMimeType( + googleMimeType: string +): string | null { + switch (googleMimeType) { + case GOOGLE_APPS_DOCUMENT: + return "application/pdf" + case GOOGLE_APPS_SPREADSHEET: + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case GOOGLE_APPS_PRESENTATION: + return "application/pdf" + default: + return null + } +} + +export function getExportExtension( + googleMimeType: string +): string { + switch (googleMimeType) { + case GOOGLE_APPS_DOCUMENT: + return ".pdf" + case GOOGLE_APPS_SPREADSHEET: + return ".xlsx" + case GOOGLE_APPS_PRESENTATION: + return ".pdf" + default: + return "" + } +} diff --git a/src/lib/netsuite/auth/crypto.ts b/src/lib/netsuite/auth/crypto.ts index 6cc5bf5..1774e04 100755 --- a/src/lib/netsuite/auth/crypto.ts +++ b/src/lib/netsuite/auth/crypto.ts @@ -1,76 +1,23 @@ -// AES-256-GCM encryption for OAuth tokens at rest in D1. -// uses Web Crypto API (available in Cloudflare Workers). +// netsuite-specific encrypt/decrypt that delegates to shared crypto +// with the netsuite-specific PBKDF2 salt. -const ALGORITHM = "AES-GCM" -const KEY_LENGTH = 256 -const IV_LENGTH = 12 -const TAG_LENGTH = 128 +import { + encrypt as sharedEncrypt, + decrypt as sharedDecrypt, +} from "@/lib/crypto" -async function deriveKey(secret: string): Promise { - const encoder = new TextEncoder() - const keyMaterial = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "PBKDF2" }, - false, - ["deriveKey"] - ) - - // static salt is fine here - the encryption key itself - // is the secret, and each ciphertext gets a unique IV - const salt = encoder.encode("compass-netsuite-tokens") - - return crypto.subtle.deriveKey( - { - name: "PBKDF2", - salt, - iterations: 100000, - hash: "SHA-256", - }, - keyMaterial, - { name: ALGORITHM, length: KEY_LENGTH }, - false, - ["encrypt", "decrypt"] - ) -} +const NETSUITE_SALT = "compass-netsuite-tokens" export async function encrypt( plaintext: string, secret: string ): Promise { - const key = await deriveKey(secret) - const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) - const encoder = new TextEncoder() - - const ciphertext = await crypto.subtle.encrypt( - { name: ALGORITHM, iv, tagLength: TAG_LENGTH }, - key, - encoder.encode(plaintext) - ) - - // pack as iv:ciphertext in base64 - const packed = new Uint8Array(iv.length + ciphertext.byteLength) - packed.set(iv) - packed.set(new Uint8Array(ciphertext), iv.length) - - return btoa(String.fromCharCode(...packed)) + return sharedEncrypt(plaintext, secret, NETSUITE_SALT) } export async function decrypt( encoded: string, secret: string ): Promise { - const key = await deriveKey(secret) - - const packed = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)) - const iv = packed.slice(0, IV_LENGTH) - const ciphertext = packed.slice(IV_LENGTH) - - const plaintext = await crypto.subtle.decrypt( - { name: ALGORITHM, iv, tagLength: TAG_LENGTH }, - key, - ciphertext - ) - - return new TextDecoder().decode(plaintext) + return sharedDecrypt(encoded, secret, NETSUITE_SALT) } diff --git a/src/middleware.ts b/src/middleware.ts index 13bf686..1792e62 100755 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -17,7 +17,8 @@ function isPublicPath(pathname: string): boolean { return ( publicPaths.includes(pathname) || pathname.startsWith("/api/auth/") || - pathname.startsWith("/api/netsuite/") + pathname.startsWith("/api/netsuite/") || + pathname.startsWith("/api/google/") ) }