@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.
Files changed (163) hide show
  1. package/.env.example +20 -0
  2. package/README.md +63 -0
  3. package/api/server.ts +130 -0
  4. package/api/src/config/index.ts +96 -0
  5. package/api/src/middleware/auth.ts +128 -0
  6. package/api/src/middleware/errorHandler.ts +88 -0
  7. package/api/src/middleware/index.ts +4 -0
  8. package/api/src/middleware/rateLimit.ts +71 -0
  9. package/api/src/middleware/validation.ts +58 -0
  10. package/api/src/routes/accounts.ts +142 -0
  11. package/api/src/routes/baseline-config.ts +124 -0
  12. package/api/src/routes/chat.ts +154 -0
  13. package/api/src/routes/health.ts +61 -0
  14. package/api/src/routes/index.ts +35 -0
  15. package/api/src/routes/ingestions.ts +275 -0
  16. package/api/src/routes/migrate.ts +112 -0
  17. package/api/src/routes/policies.ts +121 -0
  18. package/api/src/routes/processing.ts +90 -0
  19. package/api/src/routes/rules.ts +11 -0
  20. package/api/src/routes/sdk.ts +100 -0
  21. package/api/src/routes/settings.ts +80 -0
  22. package/api/src/routes/setup.ts +389 -0
  23. package/api/src/routes/stats.ts +81 -0
  24. package/api/src/routes/tts.ts +190 -0
  25. package/api/src/services/BaselineConfigService.ts +208 -0
  26. package/api/src/services/ChatService.ts +204 -0
  27. package/api/src/services/GoogleDriveService.ts +331 -0
  28. package/api/src/services/GoogleSheetsService.ts +1107 -0
  29. package/api/src/services/IngestionService.ts +1187 -0
  30. package/api/src/services/ModelCapabilityService.ts +248 -0
  31. package/api/src/services/PolicyEngine.ts +1625 -0
  32. package/api/src/services/PolicyLearningService.ts +527 -0
  33. package/api/src/services/PolicyLoader.ts +249 -0
  34. package/api/src/services/RAGService.ts +391 -0
  35. package/api/src/services/SDKService.ts +249 -0
  36. package/api/src/services/supabase.ts +113 -0
  37. package/api/src/utils/Actuator.ts +284 -0
  38. package/api/src/utils/actions/ActionHandler.ts +34 -0
  39. package/api/src/utils/actions/AppendToGSheetAction.ts +260 -0
  40. package/api/src/utils/actions/AutoRenameAction.ts +58 -0
  41. package/api/src/utils/actions/CopyAction.ts +120 -0
  42. package/api/src/utils/actions/CopyToGDriveAction.ts +64 -0
  43. package/api/src/utils/actions/LogCsvAction.ts +48 -0
  44. package/api/src/utils/actions/NotifyAction.ts +39 -0
  45. package/api/src/utils/actions/RenameAction.ts +57 -0
  46. package/api/src/utils/actions/WebhookAction.ts +58 -0
  47. package/api/src/utils/actions/utils.ts +293 -0
  48. package/api/src/utils/llmResponse.ts +61 -0
  49. package/api/src/utils/logger.ts +67 -0
  50. package/bin/folio-deploy.js +12 -0
  51. package/bin/folio-setup.js +45 -0
  52. package/bin/folio.js +65 -0
  53. package/dist/api/server.js +106 -0
  54. package/dist/api/src/config/index.js +81 -0
  55. package/dist/api/src/middleware/auth.js +93 -0
  56. package/dist/api/src/middleware/errorHandler.js +73 -0
  57. package/dist/api/src/middleware/index.js +4 -0
  58. package/dist/api/src/middleware/rateLimit.js +43 -0
  59. package/dist/api/src/middleware/validation.js +54 -0
  60. package/dist/api/src/routes/accounts.js +110 -0
  61. package/dist/api/src/routes/baseline-config.js +91 -0
  62. package/dist/api/src/routes/chat.js +114 -0
  63. package/dist/api/src/routes/health.js +52 -0
  64. package/dist/api/src/routes/index.js +31 -0
  65. package/dist/api/src/routes/ingestions.js +207 -0
  66. package/dist/api/src/routes/migrate.js +91 -0
  67. package/dist/api/src/routes/policies.js +86 -0
  68. package/dist/api/src/routes/processing.js +75 -0
  69. package/dist/api/src/routes/rules.js +8 -0
  70. package/dist/api/src/routes/sdk.js +80 -0
  71. package/dist/api/src/routes/settings.js +68 -0
  72. package/dist/api/src/routes/setup.js +315 -0
  73. package/dist/api/src/routes/stats.js +62 -0
  74. package/dist/api/src/routes/tts.js +178 -0
  75. package/dist/api/src/services/BaselineConfigService.js +168 -0
  76. package/dist/api/src/services/ChatService.js +166 -0
  77. package/dist/api/src/services/GoogleDriveService.js +280 -0
  78. package/dist/api/src/services/GoogleSheetsService.js +795 -0
  79. package/dist/api/src/services/IngestionService.js +990 -0
  80. package/dist/api/src/services/ModelCapabilityService.js +179 -0
  81. package/dist/api/src/services/PolicyEngine.js +1353 -0
  82. package/dist/api/src/services/PolicyLearningService.js +397 -0
  83. package/dist/api/src/services/PolicyLoader.js +159 -0
  84. package/dist/api/src/services/RAGService.js +295 -0
  85. package/dist/api/src/services/SDKService.js +212 -0
  86. package/dist/api/src/services/supabase.js +72 -0
  87. package/dist/api/src/utils/Actuator.js +225 -0
  88. package/dist/api/src/utils/actions/ActionHandler.js +1 -0
  89. package/dist/api/src/utils/actions/AppendToGSheetAction.js +191 -0
  90. package/dist/api/src/utils/actions/AutoRenameAction.js +49 -0
  91. package/dist/api/src/utils/actions/CopyAction.js +112 -0
  92. package/dist/api/src/utils/actions/CopyToGDriveAction.js +55 -0
  93. package/dist/api/src/utils/actions/LogCsvAction.js +42 -0
  94. package/dist/api/src/utils/actions/NotifyAction.js +32 -0
  95. package/dist/api/src/utils/actions/RenameAction.js +51 -0
  96. package/dist/api/src/utils/actions/WebhookAction.js +51 -0
  97. package/dist/api/src/utils/actions/utils.js +237 -0
  98. package/dist/api/src/utils/llmResponse.js +63 -0
  99. package/dist/api/src/utils/logger.js +51 -0
  100. package/dist/assets/index-DzN8-j-e.css +1 -0
  101. package/dist/assets/index-Uy-ai3Dh.js +113 -0
  102. package/dist/favicon.svg +31 -0
  103. package/dist/folio-logo.svg +46 -0
  104. package/dist/index.html +14 -0
  105. package/docs-dev/FPE-spec.md +196 -0
  106. package/docs-dev/folio-prd.md +47 -0
  107. package/docs-dev/foundation-checklist.md +30 -0
  108. package/docs-dev/hybrid-routing-architecture.md +205 -0
  109. package/docs-dev/ingestion-engine.md +69 -0
  110. package/docs-dev/port-from-email-automator.md +32 -0
  111. package/docs-dev/tech-spec.md +98 -0
  112. package/index.html +13 -0
  113. package/package.json +101 -0
  114. package/public/favicon.svg +31 -0
  115. package/public/folio-logo.svg +46 -0
  116. package/scripts/dev-task.mjs +51 -0
  117. package/scripts/get-latest-migration-timestamp.mjs +34 -0
  118. package/scripts/migrate.sh +91 -0
  119. package/supabase/.temp/cli-latest +1 -0
  120. package/supabase/.temp/gotrue-version +1 -0
  121. package/supabase/.temp/pooler-url +1 -0
  122. package/supabase/.temp/postgres-version +1 -0
  123. package/supabase/.temp/project-ref +1 -0
  124. package/supabase/.temp/rest-version +1 -0
  125. package/supabase/.temp/storage-migration +1 -0
  126. package/supabase/.temp/storage-version +1 -0
  127. package/supabase/config.toml +64 -0
  128. package/supabase/functions/_shared/auth.ts +35 -0
  129. package/supabase/functions/_shared/cors.ts +12 -0
  130. package/supabase/functions/_shared/supabaseAdmin.ts +17 -0
  131. package/supabase/functions/api-v1-settings/index.ts +66 -0
  132. package/supabase/functions/setup/index.ts +91 -0
  133. package/supabase/migrations/20260223000000_initial_foundation.sql +136 -0
  134. package/supabase/migrations/20260223000001_add_migration_rpc.sql +10 -0
  135. package/supabase/migrations/20260224000002_add_init_state_view.sql +20 -0
  136. package/supabase/migrations/20260224000003_port_user_creation_parity.sql +139 -0
  137. package/supabase/migrations/20260224000004_add_avatars_storage.sql +26 -0
  138. package/supabase/migrations/20260224000005_add_tts_and_embed_settings.sql +24 -0
  139. package/supabase/migrations/20260224000006_add_policies_table.sql +48 -0
  140. package/supabase/migrations/20260224000007_fix_migration_rpc.sql +9 -0
  141. package/supabase/migrations/20260224000008_add_ingestions_table.sql +42 -0
  142. package/supabase/migrations/20260225000000_setup_compatible_mode.sql +119 -0
  143. package/supabase/migrations/20260225000001_restore_ingestions.sql +49 -0
  144. package/supabase/migrations/20260225000002_add_ingestion_trace.sql +2 -0
  145. package/supabase/migrations/20260225000003_add_baseline_configs.sql +35 -0
  146. package/supabase/migrations/20260226000000_add_processing_events.sql +26 -0
  147. package/supabase/migrations/20260226000001_add_ingestion_file_hash.sql +10 -0
  148. package/supabase/migrations/20260226000002_add_dynamic_rag.sql +150 -0
  149. package/supabase/migrations/20260226000003_add_ingestion_summary.sql +4 -0
  150. package/supabase/migrations/20260226000004_add_ingestion_tags.sql +7 -0
  151. package/supabase/migrations/20260226000005_add_chat_tables.sql +60 -0
  152. package/supabase/migrations/20260227000000_harden_chat_messages_rls.sql +25 -0
  153. package/supabase/migrations/20260228000000_add_vision_model_capabilities.sql +8 -0
  154. package/supabase/migrations/20260228000001_add_policy_match_feedback.sql +51 -0
  155. package/supabase/migrations/29991231235959_test_migration.sql +0 -0
  156. package/supabase/templates/confirmation.html +76 -0
  157. package/supabase/templates/email-change.html +76 -0
  158. package/supabase/templates/invite.html +72 -0
  159. package/supabase/templates/magic-link.html +68 -0
  160. package/supabase/templates/recovery.html +82 -0
  161. package/tsconfig.api.json +16 -0
  162. package/tsconfig.json +25 -0
  163. package/vite.config.ts +146 -0
@@ -0,0 +1,795 @@
1
+ import { getServiceRoleSupabase } from "./supabase.js";
2
+ import { createLogger } from "../utils/logger.js";
3
+ const logger = createLogger("GoogleSheetsService");
4
+ function parseCredentials(value) {
5
+ if (!value || typeof value !== "object") {
6
+ return {};
7
+ }
8
+ const record = value;
9
+ return {
10
+ access_token: typeof record.access_token === "string" ? record.access_token : undefined,
11
+ refresh_token: typeof record.refresh_token === "string" ? record.refresh_token : undefined,
12
+ expires_at: typeof record.expires_at === "number" ? record.expires_at : undefined,
13
+ client_id: typeof record.client_id === "string" ? record.client_id : undefined,
14
+ client_secret: typeof record.client_secret === "string" ? record.client_secret : undefined,
15
+ };
16
+ }
17
+ function parseGid(value) {
18
+ if (!value)
19
+ return undefined;
20
+ const parsed = Number(value);
21
+ if (!Number.isInteger(parsed) || parsed < 0)
22
+ return undefined;
23
+ return parsed;
24
+ }
25
+ function parseSpreadsheetReference(reference) {
26
+ const raw = reference.trim();
27
+ if (!raw)
28
+ return null;
29
+ const idFromPath = raw.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
30
+ if (idFromPath?.[1]) {
31
+ let gid;
32
+ try {
33
+ const url = new URL(raw);
34
+ gid = parseGid(url.searchParams.get("gid"));
35
+ if (gid === undefined && url.hash) {
36
+ const hashGid = url.hash.match(/gid=(\d+)/)?.[1];
37
+ gid = parseGid(hashGid);
38
+ }
39
+ }
40
+ catch {
41
+ // Best-effort URL parsing; we still have spreadsheet ID.
42
+ }
43
+ if (gid === undefined) {
44
+ gid = parseGid(raw.match(/[?#]gid=(\d+)/)?.[1]);
45
+ }
46
+ return {
47
+ spreadsheetId: idFromPath[1],
48
+ ...(gid !== undefined ? { gid } : {}),
49
+ };
50
+ }
51
+ const idOnly = raw.match(/^([a-zA-Z0-9-_]{20,})$/);
52
+ if (idOnly?.[1]) {
53
+ return { spreadsheetId: idOnly[1] };
54
+ }
55
+ return null;
56
+ }
57
+ function toSheetRef(name) {
58
+ const trimmed = name.trim();
59
+ if (!trimmed)
60
+ return "Sheet1";
61
+ if (/^'.*'$/.test(trimmed))
62
+ return trimmed;
63
+ if (/^[A-Za-z0-9_]+$/.test(trimmed))
64
+ return trimmed;
65
+ return `'${trimmed.replace(/'/g, "''")}'`;
66
+ }
67
+ function isA1CoordinateRange(value) {
68
+ const trimmed = value.trim();
69
+ if (!trimmed)
70
+ return false;
71
+ // Row-based references: "1", "1:10"
72
+ if (/^\d+(?::\d+)?$/.test(trimmed))
73
+ return true;
74
+ // Column range references: "A:Z", "AA:AZ", "$A:$Z"
75
+ if (/^\$?[A-Za-z]{1,4}\$?(?::\$?[A-Za-z]{1,4}\$?)$/.test(trimmed))
76
+ return true;
77
+ // Cell / partial references: "A1", "A1:B20", "A1:B", "A:B20", "$A$1:$B$20"
78
+ if (/^\$?[A-Za-z]{1,4}\$?\d+(?::\$?[A-Za-z]{1,4}\$?\d*)?$/.test(trimmed))
79
+ return true;
80
+ if (/^\$?[A-Za-z]{1,4}\$?(?::\$?[A-Za-z]{1,4}\$?\d+)$/.test(trimmed))
81
+ return true;
82
+ // Single column references like "A", "AA" are valid A1,
83
+ // but long alphabetic strings are usually sheet names (e.g. "Transaction").
84
+ if (/^\$?[A-Za-z]{1,4}\$?$/.test(trimmed))
85
+ return true;
86
+ return false;
87
+ }
88
+ function extractSheetRef(range) {
89
+ const trimmed = range.trim();
90
+ if (!trimmed)
91
+ return null;
92
+ const bang = trimmed.indexOf("!");
93
+ if (bang >= 0) {
94
+ const sheetRef = trimmed.slice(0, bang).trim();
95
+ return sheetRef || null;
96
+ }
97
+ if (isA1CoordinateRange(trimmed))
98
+ return null;
99
+ return trimmed;
100
+ }
101
+ function normalizeSheetRefForComparison(value) {
102
+ const trimmed = value.trim();
103
+ if (/^'.*'$/.test(trimmed)) {
104
+ return trimmed.slice(1, -1).replace(/''/g, "'").trim().toLowerCase();
105
+ }
106
+ return trimmed.toLowerCase();
107
+ }
108
+ function isDefaultSheetRef(sheetRef) {
109
+ return normalizeSheetRefForComparison(sheetRef) === "sheet1";
110
+ }
111
+ function applySheetRefToRange(range, sheetRef) {
112
+ const trimmed = range.trim();
113
+ if (!trimmed)
114
+ return sheetRef;
115
+ // If caller already passed the target sheet title as a bare range, keep it as-is.
116
+ if (normalizeSheetRefForComparison(trimmed) === normalizeSheetRefForComparison(sheetRef)) {
117
+ return sheetRef;
118
+ }
119
+ const bang = trimmed.indexOf("!");
120
+ if (bang >= 0) {
121
+ const tail = trimmed.slice(bang + 1).trim();
122
+ return tail ? `${sheetRef}!${tail}` : sheetRef;
123
+ }
124
+ if (isA1CoordinateRange(trimmed)) {
125
+ return `${sheetRef}!${trimmed}`;
126
+ }
127
+ return sheetRef;
128
+ }
129
+ function shouldUseGidSheetRef(range) {
130
+ const sheetRef = extractSheetRef(range);
131
+ if (!sheetRef)
132
+ return true;
133
+ return isDefaultSheetRef(sheetRef);
134
+ }
135
+ function buildHeaderRange(range) {
136
+ const sheetRef = extractSheetRef(range);
137
+ return sheetRef ? `${sheetRef}!1:1` : "1:1";
138
+ }
139
+ function toA1ColumnLabel(index) {
140
+ let n = Math.max(0, Math.floor(index));
141
+ let label = "";
142
+ do {
143
+ label = String.fromCharCode(65 + (n % 26)) + label;
144
+ n = Math.floor(n / 26) - 1;
145
+ } while (n >= 0);
146
+ return label;
147
+ }
148
+ function normalizeDropdownOption(value) {
149
+ return value.trim().toLowerCase();
150
+ }
151
+ export class GoogleSheetsService {
152
+ static async createAuthContext(userId, supabaseClient) {
153
+ const supabase = supabaseClient ?? getServiceRoleSupabase();
154
+ if (!supabase) {
155
+ return { success: false, error: "System error: Supabase client unavailable." };
156
+ }
157
+ const { data: integration, error } = await supabase
158
+ .from("integrations")
159
+ .select("*")
160
+ .eq("user_id", userId)
161
+ .eq("provider", "google_drive")
162
+ .single();
163
+ if (error || !integration) {
164
+ return { success: false, error: "Google Drive is not securely connected for this user." };
165
+ }
166
+ const integrationRecord = integration;
167
+ if (!integrationRecord.id) {
168
+ return { success: false, error: "Google integration is invalid. Please reconnect the drive." };
169
+ }
170
+ const credentials = parseCredentials(integrationRecord.credentials);
171
+ let accessToken = credentials.access_token;
172
+ const refreshToken = credentials.refresh_token;
173
+ const clientId = credentials.client_id;
174
+ const clientSecret = credentials.client_secret;
175
+ if (!accessToken) {
176
+ return { success: false, error: "Google credentials are incomplete. Please reconnect the drive." };
177
+ }
178
+ const tokenIsStale = !!credentials.expires_at && Date.now() >= credentials.expires_at - 60_000;
179
+ if (tokenIsStale && refreshToken && clientId && clientSecret) {
180
+ const refreshResult = await this.refreshAccessToken(refreshToken, clientId, clientSecret);
181
+ if (!refreshResult.success) {
182
+ return { success: false, error: refreshResult.error };
183
+ }
184
+ accessToken = refreshResult.accessToken;
185
+ const updatedCredentials = {
186
+ ...credentials,
187
+ access_token: accessToken,
188
+ ...(refreshResult.refreshToken ? { refresh_token: refreshResult.refreshToken } : {}),
189
+ ...(refreshResult.expiresAt ? { expires_at: refreshResult.expiresAt } : {}),
190
+ };
191
+ await supabase
192
+ .from("integrations")
193
+ .update({
194
+ credentials: updatedCredentials,
195
+ updated_at: new Date().toISOString(),
196
+ })
197
+ .eq("id", integrationRecord.id);
198
+ }
199
+ return {
200
+ success: true,
201
+ context: {
202
+ supabase,
203
+ integrationId: integrationRecord.id,
204
+ credentials,
205
+ accessToken,
206
+ refreshToken,
207
+ clientId,
208
+ clientSecret,
209
+ },
210
+ };
211
+ }
212
+ static async refreshAccessToken(refreshToken, clientId, clientSecret) {
213
+ try {
214
+ const tokenResp = await fetch("https://oauth2.googleapis.com/token", {
215
+ method: "POST",
216
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
217
+ body: new URLSearchParams({
218
+ client_id: clientId,
219
+ client_secret: clientSecret,
220
+ refresh_token: refreshToken,
221
+ grant_type: "refresh_token",
222
+ }),
223
+ });
224
+ if (!tokenResp.ok) {
225
+ return { success: false, error: "Google Auth expired and could not be refreshed. Please reconnect." };
226
+ }
227
+ const tokenData = await tokenResp.json();
228
+ const accessToken = typeof tokenData.access_token === "string" ? tokenData.access_token : undefined;
229
+ if (!accessToken) {
230
+ return { success: false, error: "Google OAuth token response was invalid." };
231
+ }
232
+ const nextRefreshToken = typeof tokenData.refresh_token === "string" && tokenData.refresh_token
233
+ ? tokenData.refresh_token
234
+ : undefined;
235
+ const expiresAt = typeof tokenData.expires_in === "number"
236
+ ? Date.now() + tokenData.expires_in * 1000
237
+ : undefined;
238
+ return { success: true, accessToken, refreshToken: nextRefreshToken, expiresAt };
239
+ }
240
+ catch (error) {
241
+ logger.error("Failed to refresh Google OAuth token", { error });
242
+ return { success: false, error: "Failed to communicate with Google Auth server." };
243
+ }
244
+ }
245
+ static async requestWithRetry(context, request) {
246
+ try {
247
+ let response = await request(context.accessToken);
248
+ if (response.status !== 401) {
249
+ return { response };
250
+ }
251
+ if (!context.refreshToken || !context.clientId || !context.clientSecret) {
252
+ return { response };
253
+ }
254
+ const refreshResult = await this.refreshAccessToken(context.refreshToken, context.clientId, context.clientSecret);
255
+ if (!refreshResult.success) {
256
+ return { error: refreshResult.error };
257
+ }
258
+ context.accessToken = refreshResult.accessToken;
259
+ context.credentials = {
260
+ ...context.credentials,
261
+ access_token: refreshResult.accessToken,
262
+ ...(refreshResult.refreshToken ? { refresh_token: refreshResult.refreshToken } : {}),
263
+ ...(refreshResult.expiresAt ? { expires_at: refreshResult.expiresAt } : {}),
264
+ };
265
+ await context.supabase
266
+ .from("integrations")
267
+ .update({
268
+ credentials: context.credentials,
269
+ updated_at: new Date().toISOString(),
270
+ })
271
+ .eq("id", context.integrationId);
272
+ response = await request(context.accessToken);
273
+ return { response };
274
+ }
275
+ catch (error) {
276
+ logger.error("Google Sheets API request failed", { error });
277
+ return { error: "Failed to communicate with Google Sheets API." };
278
+ }
279
+ }
280
+ static normalizeHelpUrl(value) {
281
+ if (typeof value !== "string" || value.trim().length === 0) {
282
+ return undefined;
283
+ }
284
+ const candidate = value.trim();
285
+ try {
286
+ const parsed = new URL(candidate);
287
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
288
+ return undefined;
289
+ }
290
+ return parsed.toString();
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ }
296
+ static extractReason(details) {
297
+ for (const item of details) {
298
+ if (typeof item.reason === "string" && item.reason.trim().length > 0) {
299
+ return item.reason.trim();
300
+ }
301
+ }
302
+ return undefined;
303
+ }
304
+ static extractActivationUrl(details) {
305
+ for (const item of details) {
306
+ const metadataUrl = this.normalizeHelpUrl(item.metadata?.activationUrl);
307
+ if (metadataUrl) {
308
+ return metadataUrl;
309
+ }
310
+ const links = Array.isArray(item.links) ? item.links : [];
311
+ for (const link of links) {
312
+ const helpUrl = this.normalizeHelpUrl(link.url);
313
+ if (helpUrl) {
314
+ return helpUrl;
315
+ }
316
+ }
317
+ }
318
+ return undefined;
319
+ }
320
+ static buildRemediation(reason, activationUrl, message) {
321
+ const normalizedMessage = typeof message === "string" ? message.toLowerCase() : "";
322
+ if (reason === "SERVICE_DISABLED") {
323
+ const links = [];
324
+ if (activationUrl) {
325
+ links.push({ label: "Enable Google Sheets API", url: activationUrl });
326
+ }
327
+ return {
328
+ provider: "google_sheets",
329
+ code: reason,
330
+ title: "Google Sheets API is disabled for your project.",
331
+ summary: "Enable the Google Sheets API in Google Cloud, wait a few minutes, then retry the ingestion.",
332
+ steps: [
333
+ activationUrl ? "Open the enable-API link and click Enable." : "Open Google Cloud Console and enable the Google Sheets API.",
334
+ "Wait 1-5 minutes for Google API activation to propagate.",
335
+ "Retry the ingestion in Folio.",
336
+ ],
337
+ ...(links.length > 0 ? { links } : {}),
338
+ };
339
+ }
340
+ if ((reason === "PERMISSION_DENIED" || reason === "FAILED_PRECONDITION") &&
341
+ /(protected|cannot edit|not have permission|insufficient permissions|permission)/.test(normalizedMessage)) {
342
+ return {
343
+ provider: "google_sheets",
344
+ code: reason,
345
+ title: "Google Sheets write access is blocked for this tab or range.",
346
+ summary: "The connected account can read the sheet but cannot write to the target cells.",
347
+ steps: [
348
+ "Share the sheet with the connected Google account as Editor.",
349
+ "If the tab/range is protected, allow this account to edit it or remove protection.",
350
+ "Retry the ingestion after permissions update.",
351
+ ],
352
+ };
353
+ }
354
+ if (reason === "PERMISSION_DENIED" || reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT") {
355
+ return {
356
+ provider: "google_sheets",
357
+ code: reason,
358
+ title: "Google Sheets access is not permitted.",
359
+ summary: "The connected Google account cannot append to this sheet yet.",
360
+ steps: [
361
+ "Share the target Google Sheet with the connected Google account as Editor.",
362
+ "If permissions were just changed, retry the ingestion.",
363
+ "If it still fails, reconnect Google Drive/Sheets integration to refresh scopes.",
364
+ ],
365
+ };
366
+ }
367
+ if (reason === "INVALID_ARGUMENT" &&
368
+ /(data validation|must be one of|invalid value at 'data\.values|dropdown)/.test(normalizedMessage)) {
369
+ return {
370
+ provider: "google_sheets",
371
+ code: reason,
372
+ title: "Google Sheets rejected one or more values due to dropdown validation.",
373
+ summary: "A strict dropdown column received a value outside the allowed list.",
374
+ steps: [
375
+ "Open the sheet and check the dropdown options for the target tab.",
376
+ "Update policy extraction/mapping so values match allowed dropdown choices exactly.",
377
+ "If the dropdown is meant for humans only, leave that column unmapped so Folio appends blank and a human can select it.",
378
+ ],
379
+ };
380
+ }
381
+ if (reason === "INVALID_ARGUMENT" && /unable to parse range/i.test(normalizedMessage)) {
382
+ return {
383
+ provider: "google_sheets",
384
+ code: reason,
385
+ title: "Google Sheets range is invalid for this spreadsheet tab.",
386
+ summary: "The configured range points to a tab name that does not exist.",
387
+ steps: [
388
+ "Set range to the exact tab name (for example: 'Receipts' or 'Receipts!A:Z').",
389
+ "If your spreadsheet URL includes #gid=..., you can omit range and Folio will resolve the tab automatically.",
390
+ "Retry the ingestion after updating the policy.",
391
+ ],
392
+ };
393
+ }
394
+ return undefined;
395
+ }
396
+ static async parseApiError(response, fallbackPrefix) {
397
+ const errorBody = await response.text();
398
+ logger.error("Google Sheets API rejected request", { status: response.status, body: errorBody });
399
+ const errorDetails = {
400
+ provider: "google_sheets",
401
+ httpStatus: response.status,
402
+ };
403
+ let errorMessage = `${fallbackPrefix}: HTTP ${response.status}.`;
404
+ try {
405
+ const parsedError = JSON.parse(errorBody);
406
+ const apiError = parsedError.error;
407
+ if (apiError?.code !== undefined) {
408
+ errorDetails.googleCode = apiError.code;
409
+ }
410
+ if (typeof apiError?.status === "string" && apiError.status.trim().length > 0) {
411
+ errorDetails.googleStatus = apiError.status.trim();
412
+ }
413
+ const details = Array.isArray(apiError?.details) ? apiError.details : [];
414
+ const reason = this.extractReason(details);
415
+ const activationUrl = this.extractActivationUrl(details);
416
+ if (reason) {
417
+ errorDetails.reason = reason;
418
+ }
419
+ if (activationUrl) {
420
+ errorDetails.activationUrl = activationUrl;
421
+ }
422
+ const apiMessage = apiError?.message;
423
+ if (typeof apiMessage === "string" && apiMessage.trim().length > 0) {
424
+ errorMessage = `Google Sheets Error: ${apiMessage}`;
425
+ }
426
+ const remediation = this.buildRemediation(reason ?? apiError?.status, activationUrl, apiMessage);
427
+ if (remediation) {
428
+ errorDetails.remediation = remediation;
429
+ }
430
+ }
431
+ catch {
432
+ // Non-JSON error body; keep fallback error message.
433
+ }
434
+ return { message: errorMessage, errorDetails };
435
+ }
436
+ static async resolveSheetRangeFromGid(context, spreadsheetId, gid) {
437
+ const endpoint = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}?fields=sheets(properties(sheetId,title))`;
438
+ const requestResult = await this.requestWithRetry(context, (token) => fetch(endpoint, {
439
+ method: "GET",
440
+ headers: {
441
+ Authorization: `Bearer ${token}`,
442
+ },
443
+ }));
444
+ if (requestResult.error || !requestResult.response) {
445
+ logger.warn("Failed to resolve Google Sheet gid", { error: requestResult.error, spreadsheetId, gid });
446
+ return null;
447
+ }
448
+ if (!requestResult.response.ok) {
449
+ logger.warn("Google Sheets metadata request failed", { status: requestResult.response.status, spreadsheetId, gid });
450
+ return null;
451
+ }
452
+ const payload = await requestResult.response.json();
453
+ const matchedSheet = payload.sheets?.find((sheet) => sheet.properties?.sheetId === gid);
454
+ const title = matchedSheet?.properties?.title;
455
+ if (!title)
456
+ return null;
457
+ return toSheetRef(title);
458
+ }
459
+ static extractDropdownValidation(rule) {
460
+ if (!rule?.condition?.type)
461
+ return null;
462
+ const conditionType = rule.condition.type.trim().toUpperCase();
463
+ if (conditionType !== "ONE_OF_LIST" && conditionType !== "ONE_OF_RANGE") {
464
+ return null;
465
+ }
466
+ const conditionValues = Array.isArray(rule.condition.values) ? rule.condition.values : [];
467
+ const strict = rule.strict === true;
468
+ if (conditionType === "ONE_OF_LIST") {
469
+ const allowedValues = conditionValues
470
+ .map((entry) => (typeof entry.userEnteredValue === "string" ? entry.userEnteredValue.trim() : ""))
471
+ .filter((entry) => entry.length > 0);
472
+ return { strict, allowedValues };
473
+ }
474
+ const rawRange = conditionValues
475
+ .map((entry) => (typeof entry.userEnteredValue === "string" ? entry.userEnteredValue.trim() : ""))
476
+ .find(Boolean);
477
+ if (!rawRange) {
478
+ return { strict, allowedValues: [] };
479
+ }
480
+ const rangeRef = rawRange.startsWith("=") ? rawRange.slice(1).trim() : rawRange;
481
+ return { strict, allowedValues: [], rangeRef };
482
+ }
483
+ static async readDropdownOptionsFromRange(context, spreadsheetId, rangeRef) {
484
+ const normalizedRange = rangeRef.trim();
485
+ if (!normalizedRange)
486
+ return [];
487
+ const endpoint = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(normalizedRange)}?majorDimension=ROWS`;
488
+ const requestResult = await this.requestWithRetry(context, (token) => fetch(endpoint, {
489
+ method: "GET",
490
+ headers: {
491
+ Authorization: `Bearer ${token}`,
492
+ },
493
+ }));
494
+ if (requestResult.error || !requestResult.response) {
495
+ logger.warn("Failed to resolve Google Sheets dropdown options from range", {
496
+ spreadsheetId,
497
+ rangeRef: normalizedRange,
498
+ error: requestResult.error,
499
+ });
500
+ return [];
501
+ }
502
+ if (!requestResult.response.ok) {
503
+ logger.warn("Google Sheets dropdown options range request failed", {
504
+ spreadsheetId,
505
+ rangeRef: normalizedRange,
506
+ status: requestResult.response.status,
507
+ });
508
+ return [];
509
+ }
510
+ const payload = await requestResult.response.json();
511
+ const rows = Array.isArray(payload.values) ? payload.values : [];
512
+ const seen = new Set();
513
+ const options = [];
514
+ for (const row of rows) {
515
+ if (!Array.isArray(row))
516
+ continue;
517
+ for (const cell of row) {
518
+ const value = String(cell ?? "").trim();
519
+ if (!value)
520
+ continue;
521
+ const key = normalizeDropdownOption(value);
522
+ if (seen.has(key))
523
+ continue;
524
+ seen.add(key);
525
+ options.push(value);
526
+ }
527
+ }
528
+ return options;
529
+ }
530
+ static async resolveHeaderDropdowns(context, spreadsheetId, range, headerCount) {
531
+ if (headerCount <= 0)
532
+ return [];
533
+ const sheetRef = extractSheetRef(range);
534
+ if (!sheetRef)
535
+ return new Array(headerCount).fill(null);
536
+ const lastColumn = toA1ColumnLabel(headerCount - 1);
537
+ const gridRange = `${sheetRef}!A1:${lastColumn}2`;
538
+ const endpoint = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(spreadsheetId)}` +
539
+ `?includeGridData=true&ranges=${encodeURIComponent(gridRange)}` +
540
+ "&fields=sheets(data(rowData(values(dataValidation))))";
541
+ const requestResult = await this.requestWithRetry(context, (token) => fetch(endpoint, {
542
+ method: "GET",
543
+ headers: {
544
+ Authorization: `Bearer ${token}`,
545
+ },
546
+ }));
547
+ if (requestResult.error || !requestResult.response) {
548
+ logger.warn("Failed to read Google Sheets dropdown validations", {
549
+ spreadsheetId,
550
+ range: gridRange,
551
+ error: requestResult.error,
552
+ });
553
+ return new Array(headerCount).fill(null);
554
+ }
555
+ if (!requestResult.response.ok) {
556
+ logger.warn("Google Sheets dropdown validation metadata request failed", {
557
+ spreadsheetId,
558
+ range: gridRange,
559
+ status: requestResult.response.status,
560
+ });
561
+ return new Array(headerCount).fill(null);
562
+ }
563
+ const payload = await requestResult.response.json();
564
+ const rowData = payload.sheets?.[0]?.data?.[0]?.rowData ?? [];
565
+ const headerRow = Array.isArray(rowData?.[0]?.values) ? rowData[0].values : [];
566
+ const exampleDataRow = Array.isArray(rowData?.[1]?.values) ? rowData[1].values : [];
567
+ const dropdowns = new Array(headerCount).fill(null);
568
+ const pendingRangeLookups = new Map();
569
+ const rangeCache = new Map();
570
+ for (let i = 0; i < headerCount; i += 1) {
571
+ const rule = exampleDataRow[i]?.dataValidation ??
572
+ headerRow[i]?.dataValidation;
573
+ const dropdown = this.extractDropdownValidation(rule);
574
+ if (!dropdown)
575
+ continue;
576
+ if (dropdown.allowedValues.length > 0) {
577
+ dropdowns[i] = {
578
+ strict: dropdown.strict,
579
+ allowedValues: dropdown.allowedValues,
580
+ };
581
+ continue;
582
+ }
583
+ if (dropdown.rangeRef) {
584
+ pendingRangeLookups.set(i, {
585
+ strict: dropdown.strict,
586
+ rangeRef: dropdown.rangeRef,
587
+ });
588
+ }
589
+ }
590
+ for (const [columnIndex, pending] of pendingRangeLookups.entries()) {
591
+ if (!rangeCache.has(pending.rangeRef)) {
592
+ const options = await this.readDropdownOptionsFromRange(context, spreadsheetId, pending.rangeRef);
593
+ rangeCache.set(pending.rangeRef, options);
594
+ }
595
+ const allowedValues = rangeCache.get(pending.rangeRef) ?? [];
596
+ if (allowedValues.length > 0) {
597
+ dropdowns[columnIndex] = {
598
+ strict: pending.strict,
599
+ allowedValues,
600
+ };
601
+ }
602
+ }
603
+ return dropdowns;
604
+ }
605
+ static async resolveSheetTarget(context, spreadsheetReference, preferredRange) {
606
+ const parsed = parseSpreadsheetReference(spreadsheetReference);
607
+ if (!parsed) {
608
+ return { success: false, error: "Invalid Google Sheet reference. Provide a spreadsheet ID or full URL." };
609
+ }
610
+ let range = preferredRange?.trim();
611
+ if (parsed.gid !== undefined) {
612
+ const gidSheetRef = await this.resolveSheetRangeFromGid(context, parsed.spreadsheetId, parsed.gid);
613
+ if (gidSheetRef) {
614
+ if (!range) {
615
+ range = gidSheetRef;
616
+ }
617
+ else if (shouldUseGidSheetRef(range)) {
618
+ const originalRange = range;
619
+ range = applySheetRefToRange(range, gidSheetRef);
620
+ logger.info("Adjusted Google Sheets range using gid-resolved tab", {
621
+ spreadsheetId: parsed.spreadsheetId,
622
+ gid: parsed.gid,
623
+ originalRange,
624
+ resolvedRange: range,
625
+ });
626
+ }
627
+ }
628
+ else if (!range) {
629
+ return {
630
+ success: false,
631
+ error: "Could not resolve sheet tab from URL gid. Provide an explicit range like 'Sheet1!A:Z'.",
632
+ };
633
+ }
634
+ }
635
+ return {
636
+ success: true,
637
+ spreadsheetId: parsed.spreadsheetId,
638
+ range: range || "Sheet1",
639
+ };
640
+ }
641
+ static async resolveTemplate(userId, spreadsheetReference, preferredRange, supabaseClient) {
642
+ logger.info(`Resolving Google Sheet template for user ${userId}`);
643
+ const authResult = await this.createAuthContext(userId, supabaseClient);
644
+ if (!authResult.success) {
645
+ return { success: false, error: authResult.error };
646
+ }
647
+ const targetResult = await this.resolveSheetTarget(authResult.context, spreadsheetReference, preferredRange);
648
+ if (!targetResult.success) {
649
+ return { success: false, error: targetResult.error };
650
+ }
651
+ const parsedReference = parseSpreadsheetReference(spreadsheetReference);
652
+ const requestHeaders = async (rangeToRead) => {
653
+ const headerRange = buildHeaderRange(rangeToRead);
654
+ const endpoint = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(targetResult.spreadsheetId)}/values/${encodeURIComponent(headerRange)}?majorDimension=ROWS`;
655
+ return this.requestWithRetry(authResult.context, (token) => fetch(endpoint, {
656
+ method: "GET",
657
+ headers: {
658
+ Authorization: `Bearer ${token}`,
659
+ },
660
+ }));
661
+ };
662
+ let resolvedRange = targetResult.range;
663
+ const requestResult = await requestHeaders(resolvedRange);
664
+ if (requestResult.error || !requestResult.response) {
665
+ return { success: false, error: requestResult.error ?? "Failed to read Google Sheet template." };
666
+ }
667
+ if (!requestResult.response.ok) {
668
+ const parsedError = await this.parseApiError(requestResult.response, "Template read failed");
669
+ const isRangeParseError = parsedError.errorDetails.googleStatus === "INVALID_ARGUMENT" &&
670
+ /unable to parse range/i.test(parsedError.message);
671
+ if (parsedReference?.gid !== undefined && isRangeParseError) {
672
+ const gidSheetRef = await this.resolveSheetRangeFromGid(authResult.context, targetResult.spreadsheetId, parsedReference.gid);
673
+ if (gidSheetRef) {
674
+ const fallbackRange = applySheetRefToRange(targetResult.range, gidSheetRef);
675
+ if (fallbackRange !== targetResult.range) {
676
+ logger.warn("Retrying Google Sheet template read with gid-resolved range", {
677
+ spreadsheetId: targetResult.spreadsheetId,
678
+ originalRange: targetResult.range,
679
+ fallbackRange,
680
+ });
681
+ const fallbackRequestResult = await requestHeaders(fallbackRange);
682
+ if (fallbackRequestResult.response?.ok) {
683
+ resolvedRange = fallbackRange;
684
+ const fallbackPayload = await fallbackRequestResult.response.json();
685
+ const fallbackFirstRow = Array.isArray(fallbackPayload.values?.[0]) ? fallbackPayload.values[0] : [];
686
+ const fallbackHeaders = fallbackFirstRow.map((cell) => String(cell).trim()).filter(Boolean);
687
+ if (fallbackHeaders.length === 0) {
688
+ return {
689
+ success: false,
690
+ error: "Google Sheet template has no header row. Add column names in row 1.",
691
+ };
692
+ }
693
+ const fallbackHeaderDropdowns = await this.resolveHeaderDropdowns(authResult.context, targetResult.spreadsheetId, resolvedRange, fallbackHeaders.length);
694
+ return {
695
+ success: true,
696
+ spreadsheetId: targetResult.spreadsheetId,
697
+ range: resolvedRange,
698
+ headers: fallbackHeaders,
699
+ headerDropdowns: fallbackHeaderDropdowns,
700
+ };
701
+ }
702
+ }
703
+ }
704
+ }
705
+ return { success: false, error: parsedError.message, errorDetails: parsedError.errorDetails };
706
+ }
707
+ const payload = await requestResult.response.json();
708
+ const firstRow = Array.isArray(payload.values?.[0]) ? payload.values[0] : [];
709
+ const headers = firstRow.map((cell) => String(cell).trim()).filter(Boolean);
710
+ if (headers.length === 0) {
711
+ return {
712
+ success: false,
713
+ error: "Google Sheet template has no header row. Add column names in row 1.",
714
+ };
715
+ }
716
+ const headerDropdowns = await this.resolveHeaderDropdowns(authResult.context, targetResult.spreadsheetId, resolvedRange, headers.length);
717
+ return {
718
+ success: true,
719
+ spreadsheetId: targetResult.spreadsheetId,
720
+ range: resolvedRange,
721
+ headers,
722
+ headerDropdowns,
723
+ };
724
+ }
725
+ /**
726
+ * Appends a row to a specific Google Sheet using the Sheets API v4.
727
+ * `spreadsheetReference` accepts either a spreadsheet ID or full Google Sheet URL.
728
+ */
729
+ static async appendRow(userId, spreadsheetReference, range, values, supabaseClient) {
730
+ logger.info(`Initiating Google Sheets append for user ${userId}`);
731
+ const authResult = await this.createAuthContext(userId, supabaseClient);
732
+ if (!authResult.success) {
733
+ return { success: false, error: authResult.error };
734
+ }
735
+ const parsedReference = parseSpreadsheetReference(spreadsheetReference);
736
+ const targetResult = await this.resolveSheetTarget(authResult.context, spreadsheetReference, range);
737
+ if (!targetResult.success) {
738
+ return { success: false, error: targetResult.error };
739
+ }
740
+ const requestAppend = async (rangeToAppend) => {
741
+ const endpoint = `https://sheets.googleapis.com/v4/spreadsheets/${encodeURIComponent(targetResult.spreadsheetId)}/values/${encodeURIComponent(rangeToAppend)}:append?valueInputOption=RAW`;
742
+ return this.requestWithRetry(authResult.context, (token) => fetch(endpoint, {
743
+ method: "POST",
744
+ headers: {
745
+ Authorization: `Bearer ${token}`,
746
+ "Content-Type": "application/json",
747
+ },
748
+ body: JSON.stringify({
749
+ values: [values], // Sheets API expects an array of arrays (rows)
750
+ }),
751
+ }));
752
+ };
753
+ const requestResult = await requestAppend(targetResult.range);
754
+ if (requestResult.error || !requestResult.response) {
755
+ return { success: false, error: requestResult.error ?? "Failed to append to Google Sheet." };
756
+ }
757
+ if (!requestResult.response.ok) {
758
+ const parsedError = await this.parseApiError(requestResult.response, "Append failed");
759
+ const isRangeParseError = parsedError.errorDetails.googleStatus === "INVALID_ARGUMENT" &&
760
+ /unable to parse range/i.test(parsedError.message);
761
+ if (parsedReference?.gid !== undefined && isRangeParseError) {
762
+ const gidSheetRef = await this.resolveSheetRangeFromGid(authResult.context, targetResult.spreadsheetId, parsedReference.gid);
763
+ if (gidSheetRef) {
764
+ const fallbackRange = applySheetRefToRange(targetResult.range, gidSheetRef);
765
+ if (fallbackRange !== targetResult.range) {
766
+ logger.warn("Retrying Google Sheets append with gid-resolved range", {
767
+ spreadsheetId: targetResult.spreadsheetId,
768
+ originalRange: targetResult.range,
769
+ fallbackRange,
770
+ });
771
+ const fallbackRequestResult = await requestAppend(fallbackRange);
772
+ if (fallbackRequestResult.response?.ok) {
773
+ logger.info("Google Sheets append succeeded after range fallback", {
774
+ spreadsheetId: targetResult.spreadsheetId,
775
+ range: fallbackRange,
776
+ });
777
+ return {
778
+ success: true,
779
+ spreadsheetId: targetResult.spreadsheetId,
780
+ range: fallbackRange,
781
+ };
782
+ }
783
+ }
784
+ }
785
+ }
786
+ return { success: false, error: parsedError.message, errorDetails: parsedError.errorDetails };
787
+ }
788
+ logger.info(`Successfully appended row to Google Sheet ${targetResult.spreadsheetId}`);
789
+ return {
790
+ success: true,
791
+ spreadsheetId: targetResult.spreadsheetId,
792
+ range: targetResult.range,
793
+ };
794
+ }
795
+ }