@realtimex/folio 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +20 -0
- package/README.md +63 -0
- package/api/server.ts +130 -0
- package/api/src/config/index.ts +96 -0
- package/api/src/middleware/auth.ts +128 -0
- package/api/src/middleware/errorHandler.ts +88 -0
- package/api/src/middleware/index.ts +4 -0
- package/api/src/middleware/rateLimit.ts +71 -0
- package/api/src/middleware/validation.ts +58 -0
- package/api/src/routes/accounts.ts +142 -0
- package/api/src/routes/baseline-config.ts +124 -0
- package/api/src/routes/chat.ts +154 -0
- package/api/src/routes/health.ts +61 -0
- package/api/src/routes/index.ts +35 -0
- package/api/src/routes/ingestions.ts +275 -0
- package/api/src/routes/migrate.ts +112 -0
- package/api/src/routes/policies.ts +121 -0
- package/api/src/routes/processing.ts +90 -0
- package/api/src/routes/rules.ts +11 -0
- package/api/src/routes/sdk.ts +100 -0
- package/api/src/routes/settings.ts +80 -0
- package/api/src/routes/setup.ts +389 -0
- package/api/src/routes/stats.ts +81 -0
- package/api/src/routes/tts.ts +190 -0
- package/api/src/services/BaselineConfigService.ts +208 -0
- package/api/src/services/ChatService.ts +204 -0
- package/api/src/services/GoogleDriveService.ts +331 -0
- package/api/src/services/GoogleSheetsService.ts +1107 -0
- package/api/src/services/IngestionService.ts +1187 -0
- package/api/src/services/ModelCapabilityService.ts +248 -0
- package/api/src/services/PolicyEngine.ts +1625 -0
- package/api/src/services/PolicyLearningService.ts +527 -0
- package/api/src/services/PolicyLoader.ts +249 -0
- package/api/src/services/RAGService.ts +391 -0
- package/api/src/services/SDKService.ts +249 -0
- package/api/src/services/supabase.ts +113 -0
- package/api/src/utils/Actuator.ts +284 -0
- package/api/src/utils/actions/ActionHandler.ts +34 -0
- package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
- package/api/src/utils/actions/AutoRenameAction.ts +58 -0
- package/api/src/utils/actions/CopyAction.ts +120 -0
- package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
- package/api/src/utils/actions/LogCsvAction.ts +48 -0
- package/api/src/utils/actions/NotifyAction.ts +39 -0
- package/api/src/utils/actions/RenameAction.ts +57 -0
- package/api/src/utils/actions/WebhookAction.ts +58 -0
- package/api/src/utils/actions/utils.ts +293 -0
- package/api/src/utils/llmResponse.ts +61 -0
- package/api/src/utils/logger.ts +67 -0
- package/bin/folio-deploy.js +12 -0
- package/bin/folio-setup.js +45 -0
- package/bin/folio.js +65 -0
- package/dist/api/server.js +106 -0
- package/dist/api/src/config/index.js +81 -0
- package/dist/api/src/middleware/auth.js +93 -0
- package/dist/api/src/middleware/errorHandler.js +73 -0
- package/dist/api/src/middleware/index.js +4 -0
- package/dist/api/src/middleware/rateLimit.js +43 -0
- package/dist/api/src/middleware/validation.js +54 -0
- package/dist/api/src/routes/accounts.js +110 -0
- package/dist/api/src/routes/baseline-config.js +91 -0
- package/dist/api/src/routes/chat.js +114 -0
- package/dist/api/src/routes/health.js +52 -0
- package/dist/api/src/routes/index.js +31 -0
- package/dist/api/src/routes/ingestions.js +207 -0
- package/dist/api/src/routes/migrate.js +91 -0
- package/dist/api/src/routes/policies.js +86 -0
- package/dist/api/src/routes/processing.js +75 -0
- package/dist/api/src/routes/rules.js +8 -0
- package/dist/api/src/routes/sdk.js +80 -0
- package/dist/api/src/routes/settings.js +68 -0
- package/dist/api/src/routes/setup.js +315 -0
- package/dist/api/src/routes/stats.js +62 -0
- package/dist/api/src/routes/tts.js +178 -0
- package/dist/api/src/services/BaselineConfigService.js +168 -0
- package/dist/api/src/services/ChatService.js +166 -0
- package/dist/api/src/services/GoogleDriveService.js +280 -0
- package/dist/api/src/services/GoogleSheetsService.js +795 -0
- package/dist/api/src/services/IngestionService.js +990 -0
- package/dist/api/src/services/ModelCapabilityService.js +179 -0
- package/dist/api/src/services/PolicyEngine.js +1353 -0
- package/dist/api/src/services/PolicyLearningService.js +397 -0
- package/dist/api/src/services/PolicyLoader.js +159 -0
- package/dist/api/src/services/RAGService.js +295 -0
- package/dist/api/src/services/SDKService.js +212 -0
- package/dist/api/src/services/supabase.js +72 -0
- package/dist/api/src/utils/Actuator.js +225 -0
- package/dist/api/src/utils/actions/ActionHandler.js +1 -0
- package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
- package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
- package/dist/api/src/utils/actions/CopyAction.js +112 -0
- package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
- package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
- package/dist/api/src/utils/actions/NotifyAction.js +32 -0
- package/dist/api/src/utils/actions/RenameAction.js +51 -0
- package/dist/api/src/utils/actions/WebhookAction.js +51 -0
- package/dist/api/src/utils/actions/utils.js +237 -0
- package/dist/api/src/utils/llmResponse.js +63 -0
- package/dist/api/src/utils/logger.js +51 -0
- package/dist/assets/index-DzN8-j-e.css +1 -0
- package/dist/assets/index-Uy-ai3Dh.js +113 -0
- package/dist/favicon.svg +31 -0
- package/dist/folio-logo.svg +46 -0
- package/dist/index.html +14 -0
- package/docs-dev/FPE-spec.md +196 -0
- package/docs-dev/folio-prd.md +47 -0
- package/docs-dev/foundation-checklist.md +30 -0
- package/docs-dev/hybrid-routing-architecture.md +205 -0
- package/docs-dev/ingestion-engine.md +69 -0
- package/docs-dev/port-from-email-automator.md +32 -0
- package/docs-dev/tech-spec.md +98 -0
- package/index.html +13 -0
- package/package.json +101 -0
- package/public/favicon.svg +31 -0
- package/public/folio-logo.svg +46 -0
- package/scripts/dev-task.mjs +51 -0
- package/scripts/get-latest-migration-timestamp.mjs +34 -0
- package/scripts/migrate.sh +91 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/config.toml +64 -0
- package/supabase/functions/_shared/auth.ts +35 -0
- package/supabase/functions/_shared/cors.ts +12 -0
- package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
- package/supabase/functions/api-v1-settings/index.ts +66 -0
- package/supabase/functions/setup/index.ts +91 -0
- package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
- package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
- package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
- package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
- package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
- package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
- package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
- package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
- package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
- package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
- package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
- package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
- package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
- package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
- package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
- package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
- package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
- package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
- package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
- package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
- package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
- package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
- package/supabase/migrations/29991231235959_test_migration.sql +0 -0
- package/supabase/templates/confirmation.html +76 -0
- package/supabase/templates/email-change.html +76 -0
- package/supabase/templates/invite.html +72 -0
- package/supabase/templates/magic-link.html +68 -0
- package/supabase/templates/recovery.html +82 -0
- package/tsconfig.api.json +16 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +146 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import { getServiceRoleSupabase } from "./supabase.js";
|
|
3
|
+
import { createLogger } from "../utils/logger.js";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const logger = createLogger("GoogleDriveService");
|
|
8
|
+
|
|
9
|
+
type IntegrationCredentials = {
|
|
10
|
+
access_token?: string;
|
|
11
|
+
refresh_token?: string;
|
|
12
|
+
expires_at?: number;
|
|
13
|
+
client_id?: string;
|
|
14
|
+
client_secret?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type UploadResult = { success: boolean; fileId?: string; error?: string };
|
|
18
|
+
|
|
19
|
+
function parseCredentials(value: unknown): IntegrationCredentials {
|
|
20
|
+
if (!value || typeof value !== "object") {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const record = value as Record<string, unknown>;
|
|
25
|
+
return {
|
|
26
|
+
access_token: typeof record.access_token === "string" ? record.access_token : undefined,
|
|
27
|
+
refresh_token: typeof record.refresh_token === "string" ? record.refresh_token : undefined,
|
|
28
|
+
expires_at: typeof record.expires_at === "number" ? record.expires_at : undefined,
|
|
29
|
+
client_id: typeof record.client_id === "string" ? record.client_id : undefined,
|
|
30
|
+
client_secret: typeof record.client_secret === "string" ? record.client_secret : undefined,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class GoogleDriveService {
|
|
35
|
+
/**
|
|
36
|
+
* Walks a slash-separated sub-path under a root Drive folder, creating any
|
|
37
|
+
* missing folders along the way, and returns the final folder ID.
|
|
38
|
+
*
|
|
39
|
+
* e.g. resolveFolderPath(token, "rootId", ["Utilities", "PGE", "Energy Statements"])
|
|
40
|
+
* → ID of "Energy Statements" folder (created if absent)
|
|
41
|
+
*/
|
|
42
|
+
private static async resolveFolderPath(
|
|
43
|
+
token: string,
|
|
44
|
+
rootFolderId: string,
|
|
45
|
+
subSegments: string[]
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
let parentId = rootFolderId;
|
|
48
|
+
|
|
49
|
+
for (const segment of subSegments) {
|
|
50
|
+
const name = segment.trim();
|
|
51
|
+
if (!name) continue;
|
|
52
|
+
|
|
53
|
+
// Search for an existing folder with this name under the current parent.
|
|
54
|
+
const q = `name='${name.replace(/'/g, "\\'")}' and mimeType='application/vnd.google-apps.folder' and '${parentId}' in parents and trashed=false`;
|
|
55
|
+
const searchResp = await fetch(
|
|
56
|
+
`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&fields=files(id)&spaces=drive`,
|
|
57
|
+
{ headers: { Authorization: `Bearer ${token}` } }
|
|
58
|
+
);
|
|
59
|
+
if (!searchResp.ok) {
|
|
60
|
+
throw new Error(`Drive folder search failed: HTTP ${searchResp.status}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { files } = await searchResp.json() as { files?: { id: string }[] };
|
|
64
|
+
if (files && files.length > 0) {
|
|
65
|
+
parentId = files[0].id;
|
|
66
|
+
} else {
|
|
67
|
+
// Folder doesn't exist — create it.
|
|
68
|
+
const createResp = await fetch("https://www.googleapis.com/drive/v3/files", {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${token}`,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
name,
|
|
76
|
+
mimeType: "application/vnd.google-apps.folder",
|
|
77
|
+
parents: [parentId],
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
if (!createResp.ok) {
|
|
81
|
+
throw new Error(`Drive folder creation failed for '${name}': HTTP ${createResp.status}`);
|
|
82
|
+
}
|
|
83
|
+
const { id } = await createResp.json() as { id?: string };
|
|
84
|
+
if (!id) throw new Error(`Folder '${name}' was created but Drive returned no ID.`);
|
|
85
|
+
logger.info(`Created Drive folder '${name}' (${id}) under parent ${parentId}`);
|
|
86
|
+
parentId = id;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return parentId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Uploads a local file to a user's connected Google Drive.
|
|
95
|
+
* Handles automatic token refreshment if the access_token has expired.
|
|
96
|
+
*
|
|
97
|
+
* `folderPath` can be:
|
|
98
|
+
* - undefined / empty → upload to My Drive root
|
|
99
|
+
* - a bare folder ID → upload directly into that folder
|
|
100
|
+
* - "rootId/Sub/Path" → resolve (and auto-create) the subfolder hierarchy,
|
|
101
|
+
* then upload into the deepest folder
|
|
102
|
+
*/
|
|
103
|
+
static async uploadFile(
|
|
104
|
+
userId: string,
|
|
105
|
+
localFilePath: string,
|
|
106
|
+
folderId?: string,
|
|
107
|
+
supabaseClient?: SupabaseClient | null,
|
|
108
|
+
fileName?: string
|
|
109
|
+
): Promise<UploadResult> {
|
|
110
|
+
logger.info(`Initiating Google Drive upload for user ${userId}: ${localFilePath}`);
|
|
111
|
+
const supabase = supabaseClient ?? getServiceRoleSupabase();
|
|
112
|
+
if (!supabase) {
|
|
113
|
+
return { success: false, error: "System error: Supabase client unavailable." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let fileStat: fs.Stats;
|
|
117
|
+
try {
|
|
118
|
+
fileStat = await fs.promises.stat(localFilePath);
|
|
119
|
+
if (!fileStat.isFile()) {
|
|
120
|
+
return { success: false, error: "Source path is not a file." };
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
return { success: false, error: "Source file not found." };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 1. Fetch Integration
|
|
127
|
+
const { data: integration, error } = await supabase
|
|
128
|
+
.from("integrations")
|
|
129
|
+
.select("*")
|
|
130
|
+
.eq("user_id", userId)
|
|
131
|
+
.eq("provider", "google_drive")
|
|
132
|
+
.single();
|
|
133
|
+
|
|
134
|
+
if (error || !integration) {
|
|
135
|
+
return { success: false, error: "Google Drive is not securely connected for this user." };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const credentials = parseCredentials((integration as { credentials?: unknown }).credentials);
|
|
139
|
+
|
|
140
|
+
let accessToken = credentials.access_token;
|
|
141
|
+
const clientId = credentials.client_id;
|
|
142
|
+
const clientSecret = credentials.client_secret;
|
|
143
|
+
const refreshToken = credentials.refresh_token;
|
|
144
|
+
|
|
145
|
+
if (!accessToken) {
|
|
146
|
+
return { success: false, error: "Google Drive credentials are incomplete. Please reconnect the drive." };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 2. Proactively refresh the token if it is expired or expires within 60 s.
|
|
150
|
+
// This must happen before any Drive API calls (folder resolution, upload initiation).
|
|
151
|
+
const tokenIsStale = credentials.expires_at && Date.now() >= credentials.expires_at - 60_000;
|
|
152
|
+
if (tokenIsStale && refreshToken && clientId && clientSecret) {
|
|
153
|
+
logger.info("Google Drive token is stale, refreshing before Drive API calls...");
|
|
154
|
+
try {
|
|
155
|
+
const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
158
|
+
body: new URLSearchParams({
|
|
159
|
+
client_id: clientId,
|
|
160
|
+
client_secret: clientSecret,
|
|
161
|
+
refresh_token: refreshToken,
|
|
162
|
+
grant_type: "refresh_token",
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
if (!tokenResp.ok) {
|
|
166
|
+
return { success: false, error: "Google Drive authentication expired and could not be refreshed. Please reconnect the drive." };
|
|
167
|
+
}
|
|
168
|
+
const tokenData = await tokenResp.json();
|
|
169
|
+
if (!tokenData?.access_token) {
|
|
170
|
+
return { success: false, error: "Google OAuth token response was invalid." };
|
|
171
|
+
}
|
|
172
|
+
accessToken = tokenData.access_token as string;
|
|
173
|
+
const updatedCredentials: IntegrationCredentials = {
|
|
174
|
+
...credentials,
|
|
175
|
+
access_token: accessToken,
|
|
176
|
+
...(typeof tokenData.refresh_token === "string" && tokenData.refresh_token
|
|
177
|
+
? { refresh_token: tokenData.refresh_token }
|
|
178
|
+
: {}),
|
|
179
|
+
...(typeof tokenData.expires_in === "number"
|
|
180
|
+
? { expires_at: Date.now() + tokenData.expires_in * 1000 }
|
|
181
|
+
: {}),
|
|
182
|
+
};
|
|
183
|
+
await supabase.from("integrations").update({
|
|
184
|
+
credentials: updatedCredentials,
|
|
185
|
+
updated_at: new Date().toISOString(),
|
|
186
|
+
}).eq("id", integration.id);
|
|
187
|
+
} catch (refreshErr) {
|
|
188
|
+
logger.error("Proactive token refresh failed", { error: refreshErr });
|
|
189
|
+
return { success: false, error: "Failed to communicate with Google Auth server." };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 3. Resolve subfolder path (auto-creates missing folders).
|
|
194
|
+
// folderPath may be "rootId", "rootId/Sub/Folder", or undefined.
|
|
195
|
+
// Drive folder IDs are stable across token refreshes so we resolve once.
|
|
196
|
+
let resolvedFolderId: string | undefined;
|
|
197
|
+
if (folderId && folderId.trim().length > 0) {
|
|
198
|
+
const parts = folderId.trim().split("/");
|
|
199
|
+
const rootId = parts[0];
|
|
200
|
+
const subSegments = parts.slice(1);
|
|
201
|
+
try {
|
|
202
|
+
resolvedFolderId = subSegments.length > 0
|
|
203
|
+
? await GoogleDriveService.resolveFolderPath(accessToken, rootId, subSegments)
|
|
204
|
+
: rootId;
|
|
205
|
+
} catch (pathErr) {
|
|
206
|
+
const msg = pathErr instanceof Error ? pathErr.message : String(pathErr);
|
|
207
|
+
return { success: false, error: `Failed to resolve Drive folder path: ${msg}` };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4. Perform upload (optimistic; catches 401 as a last-resort fallback).
|
|
212
|
+
const performUpload = async (token: string): Promise<Response> => {
|
|
213
|
+
const resolvedFileName = fileName ?? path.basename(localFilePath);
|
|
214
|
+
const mimeType = "application/octet-stream";
|
|
215
|
+
|
|
216
|
+
const metadata: Record<string, unknown> = { name: resolvedFileName };
|
|
217
|
+
if (resolvedFolderId) {
|
|
218
|
+
metadata.parents = [resolvedFolderId];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Resumable upload avoids buffering/base64 encoding the full file in memory.
|
|
222
|
+
const startResponse = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable", {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: {
|
|
225
|
+
Authorization: `Bearer ${token}`,
|
|
226
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
227
|
+
"X-Upload-Content-Type": mimeType,
|
|
228
|
+
"X-Upload-Content-Length": String(fileStat.size),
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify(metadata),
|
|
231
|
+
});
|
|
232
|
+
if (!startResponse.ok) {
|
|
233
|
+
return startResponse;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const uploadUrl = startResponse.headers.get("location");
|
|
237
|
+
if (!uploadUrl) {
|
|
238
|
+
throw new Error("Google Drive did not return an upload URL.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const contentRange = fileStat.size === 0
|
|
242
|
+
? `bytes */${fileStat.size}`
|
|
243
|
+
: `bytes 0-${fileStat.size - 1}/${fileStat.size}`;
|
|
244
|
+
|
|
245
|
+
// duplex: "half" is required by Node.js fetch when the request body is a stream.
|
|
246
|
+
const uploadRequest: RequestInit & { duplex?: "half" } = {
|
|
247
|
+
method: "PUT",
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${token}`,
|
|
250
|
+
"Content-Type": mimeType,
|
|
251
|
+
"Content-Length": String(fileStat.size),
|
|
252
|
+
"Content-Range": contentRange,
|
|
253
|
+
},
|
|
254
|
+
body: fs.createReadStream(localFilePath) as unknown as BodyInit,
|
|
255
|
+
duplex: "half",
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return fetch(uploadUrl, uploadRequest);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let response = await performUpload(accessToken);
|
|
262
|
+
|
|
263
|
+
// 5. Handle Token Expiry (last-resort retry if upload still returns 401)
|
|
264
|
+
if (response.status === 401 && refreshToken && clientId && clientSecret) {
|
|
265
|
+
logger.info("Google Drive token expired, attempting refresh...");
|
|
266
|
+
try {
|
|
267
|
+
const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
270
|
+
body: new URLSearchParams({
|
|
271
|
+
client_id: clientId,
|
|
272
|
+
client_secret: clientSecret,
|
|
273
|
+
refresh_token: refreshToken,
|
|
274
|
+
grant_type: "refresh_token"
|
|
275
|
+
})
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (tokenResp.ok) {
|
|
279
|
+
const tokenData = await tokenResp.json();
|
|
280
|
+
if (!tokenData?.access_token) {
|
|
281
|
+
return { success: false, error: "Google OAuth token response was invalid." };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const refreshedAccessToken = tokenData.access_token as string;
|
|
285
|
+
accessToken = refreshedAccessToken;
|
|
286
|
+
|
|
287
|
+
const updatedCredentials: IntegrationCredentials = {
|
|
288
|
+
...credentials,
|
|
289
|
+
access_token: accessToken,
|
|
290
|
+
};
|
|
291
|
+
if (typeof tokenData.refresh_token === "string" && tokenData.refresh_token) {
|
|
292
|
+
updatedCredentials.refresh_token = tokenData.refresh_token;
|
|
293
|
+
}
|
|
294
|
+
if (typeof tokenData.expires_in === "number") {
|
|
295
|
+
updatedCredentials.expires_at = Date.now() + tokenData.expires_in * 1000;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Transparently save refreshed credentials.
|
|
299
|
+
await supabase.from("integrations").update({
|
|
300
|
+
credentials: updatedCredentials,
|
|
301
|
+
updated_at: new Date().toISOString()
|
|
302
|
+
}).eq("id", integration.id);
|
|
303
|
+
|
|
304
|
+
// Retry Upload
|
|
305
|
+
logger.info("Retrying Google Drive upload with fresh token...");
|
|
306
|
+
response = await performUpload(refreshedAccessToken);
|
|
307
|
+
} else {
|
|
308
|
+
return { success: false, error: "Google Drive authentication expired and could not be refreshed. Please reconnect the drive." };
|
|
309
|
+
}
|
|
310
|
+
} catch (authErr) {
|
|
311
|
+
logger.error("Failed to refresh Google Drive token", { error: authErr });
|
|
312
|
+
return { success: false, error: "Failed to communicate with Google Auth server." };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!response.ok) {
|
|
317
|
+
const errorBody = await response.text();
|
|
318
|
+
logger.error("Google Drive API rejected upload", { status: response.status, body: errorBody });
|
|
319
|
+
return { success: false, error: `Upload failed: HTTP ${response.status}.` };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const responseData = await response.json() as { id?: string };
|
|
323
|
+
if (!responseData.id) {
|
|
324
|
+
return { success: false, error: "Google Drive upload succeeded but no file ID was returned." };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
logger.info(`Successfully uploaded file to Google Drive: ${responseData.id}`);
|
|
328
|
+
|
|
329
|
+
return { success: true, fileId: responseData.id };
|
|
330
|
+
}
|
|
331
|
+
}
|