whale-code 6.4.0 → 6.5.0

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 (187) hide show
  1. package/bin/swagmanager-mcp.js +7 -0
  2. package/dist/cli/app.js +30 -2
  3. package/dist/cli/chat/ChatApp.d.ts +4 -4
  4. package/dist/cli/chat/ChatApp.js +114 -44
  5. package/dist/cli/chat/ChatInput.d.ts +13 -6
  6. package/dist/cli/chat/ChatInput.js +433 -89
  7. package/dist/cli/chat/MemoryManager.d.ts +15 -0
  8. package/dist/cli/chat/MemoryManager.js +61 -0
  9. package/dist/cli/chat/MessageList.d.ts +8 -0
  10. package/dist/cli/chat/MessageList.js +1 -1
  11. package/dist/cli/chat/NodeManager.d.ts +30 -0
  12. package/dist/cli/chat/NodeManager.js +89 -0
  13. package/dist/cli/chat/NodeSelector.d.ts +19 -0
  14. package/dist/cli/chat/NodeSelector.js +37 -0
  15. package/dist/cli/chat/PlanApproval.d.ts +17 -0
  16. package/dist/cli/chat/PlanApproval.js +82 -0
  17. package/dist/cli/chat/SessionManager.d.ts +16 -0
  18. package/dist/cli/chat/SessionManager.js +43 -0
  19. package/dist/cli/chat/SlashMenu.d.ts +38 -0
  20. package/dist/cli/chat/SlashMenu.js +208 -0
  21. package/dist/cli/chat/StatusBar.d.ts +16 -0
  22. package/dist/cli/chat/StatusBar.js +22 -0
  23. package/dist/cli/chat/ThemeSelector.d.ts +14 -0
  24. package/dist/cli/chat/ThemeSelector.js +29 -0
  25. package/dist/cli/chat/ToolIndicator.d.ts +8 -0
  26. package/dist/cli/chat/ToolIndicator.js +33 -9
  27. package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
  28. package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
  29. package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
  30. package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
  31. package/dist/cli/commands/config-cmd.js +4 -25
  32. package/dist/cli/commands/db.d.ts +13 -0
  33. package/dist/cli/commands/db.js +243 -0
  34. package/dist/cli/commands/doctor.js +6 -9
  35. package/dist/cli/commands/mcp.js +1 -20
  36. package/dist/cli/services/agent-events.d.ts +22 -1
  37. package/dist/cli/services/agent-events.js +9 -0
  38. package/dist/cli/services/agent-loop.js +66 -2
  39. package/dist/cli/services/agent-worker-base.js +21 -6
  40. package/dist/cli/services/api-retry.d.ts +25 -0
  41. package/dist/cli/services/api-retry.js +91 -0
  42. package/dist/cli/services/auth-service.d.ts +1 -1
  43. package/dist/cli/services/auth-service.js +40 -19
  44. package/dist/cli/services/background-processes.js +26 -2
  45. package/dist/cli/services/config-store.d.ts +13 -1
  46. package/dist/cli/services/config-store.js +116 -13
  47. package/dist/cli/services/format-server-response.js +12 -6
  48. package/dist/cli/services/ink-resize-fix.d.ts +18 -0
  49. package/dist/cli/services/ink-resize-fix.js +66 -0
  50. package/dist/cli/services/interactive-tools.d.ts +14 -0
  51. package/dist/cli/services/interactive-tools.js +47 -2
  52. package/dist/cli/services/keybinding-manager.js +1 -1
  53. package/dist/cli/services/local-tools.js +35 -2
  54. package/dist/cli/services/server-tools.js +175 -3
  55. package/dist/cli/services/subagent.js +15 -3
  56. package/dist/cli/services/system-prompt.js +5 -3
  57. package/dist/cli/services/task-decomposer.d.ts +35 -0
  58. package/dist/cli/services/task-decomposer.js +199 -0
  59. package/dist/cli/services/team-lead.d.ts +18 -0
  60. package/dist/cli/services/team-lead.js +80 -0
  61. package/dist/cli/services/teammate.js +5 -5
  62. package/dist/cli/services/telemetry.d.ts +8 -2
  63. package/dist/cli/services/telemetry.js +116 -92
  64. package/dist/cli/services/tools/agent-tools.d.ts +1 -0
  65. package/dist/cli/services/tools/agent-tools.js +50 -4
  66. package/dist/cli/services/tools/file-ops.d.ts +2 -0
  67. package/dist/cli/services/tools/file-ops.js +71 -19
  68. package/dist/cli/services/tools/shell-exec.js +22 -12
  69. package/dist/cli/shared/Theme.d.ts +1 -2
  70. package/dist/cli/shared/Theme.js +1 -1
  71. package/dist/cli/shared/WhaleBanner.d.ts +4 -1
  72. package/dist/cli/shared/WhaleBanner.js +12 -8
  73. package/dist/cli/shared/markdown.d.ts +5 -4
  74. package/dist/cli/shared/markdown.js +376 -334
  75. package/dist/cli/shared/theme-manager.d.ts +27 -0
  76. package/dist/cli/shared/theme-manager.js +178 -0
  77. package/dist/cli/shared/theme-presets.d.ts +16 -0
  78. package/dist/cli/shared/theme-presets.js +265 -0
  79. package/dist/index.js +0 -51
  80. package/dist/node/adapters/imessage.d.ts +10 -0
  81. package/dist/node/adapters/imessage.js +45 -6
  82. package/dist/node/cli.js +459 -8
  83. package/dist/node/config.d.ts +17 -0
  84. package/dist/node/gateway-client.d.ts +55 -0
  85. package/dist/node/gateway-client.js +201 -0
  86. package/dist/node/portal/clipboard.d.ts +28 -0
  87. package/dist/node/portal/clipboard.js +183 -0
  88. package/dist/node/portal/discovery.d.ts +29 -0
  89. package/dist/node/portal/discovery.js +61 -0
  90. package/dist/node/portal/forward.d.ts +30 -0
  91. package/dist/node/portal/forward.js +90 -0
  92. package/dist/node/portal/index.d.ts +47 -0
  93. package/dist/node/portal/index.js +250 -0
  94. package/dist/node/portal/multiplexer.d.ts +48 -0
  95. package/dist/node/portal/multiplexer.js +207 -0
  96. package/dist/node/portal/permissions.d.ts +36 -0
  97. package/dist/node/portal/permissions.js +131 -0
  98. package/dist/node/portal/protocol.d.ts +140 -0
  99. package/dist/node/portal/protocol.js +193 -0
  100. package/dist/node/portal/screen.d.ts +18 -0
  101. package/dist/node/portal/screen.js +93 -0
  102. package/dist/node/portal/session.d.ts +68 -0
  103. package/dist/node/portal/session.js +127 -0
  104. package/dist/node/portal/shell.d.ts +26 -0
  105. package/dist/node/portal/shell.js +142 -0
  106. package/dist/node/portal/stream.d.ts +43 -0
  107. package/dist/node/portal/stream.js +90 -0
  108. package/dist/node/portal/transfer.d.ts +33 -0
  109. package/dist/node/portal/transfer.js +231 -0
  110. package/dist/node/portal/ui.d.ts +16 -0
  111. package/dist/node/portal/ui.js +148 -0
  112. package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
  113. package/dist/node/remote-desktop/compile-helper.js +73 -0
  114. package/dist/node/remote-desktop/index.d.ts +67 -0
  115. package/dist/node/remote-desktop/index.js +220 -0
  116. package/dist/node/remote-desktop/protocol.d.ts +96 -0
  117. package/dist/node/remote-desktop/protocol.js +67 -0
  118. package/dist/node/runtime.d.ts +8 -1
  119. package/dist/node/runtime.js +117 -9
  120. package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
  121. package/dist/server/handlers/__test-utils__/test-db.js +128 -0
  122. package/dist/server/handlers/api-keys.js +26 -2
  123. package/dist/server/handlers/browser.d.ts +0 -4
  124. package/dist/server/handlers/browser.js +0 -46
  125. package/dist/server/handlers/catalog.js +37 -14
  126. package/dist/server/handlers/clickhouse.d.ts +10 -0
  127. package/dist/server/handlers/clickhouse.js +215 -0
  128. package/dist/server/handlers/comms.d.ts +308 -4
  129. package/dist/server/handlers/comms.js +444 -11
  130. package/dist/server/handlers/creations.js +1 -1
  131. package/dist/server/handlers/crm.d.ts +54 -8
  132. package/dist/server/handlers/crm.js +353 -68
  133. package/dist/server/handlers/embeddings.js +3 -3
  134. package/dist/server/handlers/enrichment.js +39 -55
  135. package/dist/server/handlers/inventory.js +1 -1
  136. package/dist/server/handlers/kali.d.ts +9 -1
  137. package/dist/server/handlers/kali.js +50 -1
  138. package/dist/server/handlers/media.d.ts +8 -0
  139. package/dist/server/handlers/media.js +902 -0
  140. package/dist/server/handlers/meta-ads.js +6 -3
  141. package/dist/server/handlers/nodes.d.ts +2 -0
  142. package/dist/server/handlers/nodes.js +331 -40
  143. package/dist/server/handlers/operations.d.ts +4 -6
  144. package/dist/server/handlers/operations.js +99 -38
  145. package/dist/server/handlers/platform.js +224 -107
  146. package/dist/server/handlers/remove-bg.d.ts +6 -0
  147. package/dist/server/handlers/remove-bg.js +96 -0
  148. package/dist/server/handlers/storefront.d.ts +6 -0
  149. package/dist/server/handlers/storefront.js +477 -0
  150. package/dist/server/handlers/supply-chain.js +21 -3
  151. package/dist/server/handlers/workflow-steps.js +87 -31
  152. package/dist/server/handlers/workflows.js +4 -1
  153. package/dist/server/index.js +334 -88
  154. package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
  155. package/dist/server/lib/clickhouse-buffer.js +175 -0
  156. package/dist/server/lib/clickhouse-client.d.ts +112 -0
  157. package/dist/server/lib/clickhouse-client.js +141 -0
  158. package/dist/server/lib/coa-renderer.d.ts +91 -0
  159. package/dist/server/lib/coa-renderer.js +411 -0
  160. package/dist/server/lib/compaction-service.js +45 -1
  161. package/dist/server/lib/pdf-renderer.d.ts +143 -0
  162. package/dist/server/lib/pdf-renderer.js +867 -0
  163. package/dist/server/lib/react-pdf-layout.d.ts +40 -0
  164. package/dist/server/lib/react-pdf-layout.js +437 -0
  165. package/dist/server/lib/server-agent-loop.d.ts +2 -0
  166. package/dist/server/lib/server-agent-loop.js +61 -15
  167. package/dist/server/lib/server-subagent.d.ts +3 -0
  168. package/dist/server/lib/server-subagent.js +7 -4
  169. package/dist/server/lib/supabase-client.js +51 -3
  170. package/dist/server/lib/template-resolver.js +14 -4
  171. package/dist/server/lib/utils.js +15 -0
  172. package/dist/server/local-agent-gateway.d.ts +44 -0
  173. package/dist/server/local-agent-gateway.js +389 -49
  174. package/dist/server/providers/anthropic.js +12 -2
  175. package/dist/server/providers/gemini.js +17 -2
  176. package/dist/server/proxy-handlers.js +151 -0
  177. package/dist/server/tool-router.d.ts +2 -2
  178. package/dist/server/tool-router.js +25 -35
  179. package/dist/shared/agent-core.d.ts +5 -2
  180. package/dist/shared/agent-core.js +30 -4
  181. package/dist/shared/api-client.js +54 -3
  182. package/dist/shared/sse-parser.d.ts +1 -1
  183. package/dist/shared/sse-parser.js +5 -2
  184. package/dist/shared/tool-dispatch.js +1 -1
  185. package/package.json +16 -10
  186. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
  187. package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
@@ -0,0 +1,902 @@
1
+ // server/handlers/media.ts — Full media management (23 actions)
2
+ //
3
+ // Upload, search, organize, tag, link to products, track usage, analytics,
4
+ // duplicate, replace — complete media catalog management for AI agents.
5
+ //
6
+ // Input methods for upload/replace:
7
+ // - file_path → CLI reads local file, base64-encodes in-process (preferred)
8
+ // - base64 → raw base64 string (auto-strips data URI prefix)
9
+ // - file_url → public URL to fetch and re-upload
10
+ //
11
+ // Note: file_path is handled client-side in server-tools.ts (converted to base64
12
+ // before reaching this handler).
13
+ import { sanitizeFilterValue } from "../lib/utils.js";
14
+ const BUCKET = "product-images";
15
+ const ALL_ACTIONS = [
16
+ "upload", "bulk_upload", "list", "get", "update", "bulk_update",
17
+ "delete", "bulk_delete", "search", "move", "archive", "unarchive",
18
+ "tag", "folders_list", "folders_create", "folders_update", "folders_delete",
19
+ "link_product", "unlink_product", "usage", "analytics", "duplicate", "replace",
20
+ ];
21
+ const LIST_COLUMNS = "id, file_name, file_url, file_size, file_type, category, folder, status, title, alt_text, custom_tags, usage_count, linked_product_ids, created_at, updated_at";
22
+ const VALID_CATEGORIES = ["product_photos", "marketing", "menus", "brand", "social_media", "print_marketing", "promotional", "brand_assets", "ai_generated"];
23
+ const VALID_STATUSES = ["active", "archived", "processing"];
24
+ const VALID_SORT_COLUMNS = ["created_at", "updated_at", "file_name", "file_size", "usage_count", "title"];
25
+ const UPDATE_WHITELIST = ["title", "alt_text", "notes", "category", "status", "folder", "custom_tags"];
26
+ // Detect mime type from base64 magic bytes
27
+ function detectMime(base64) {
28
+ if (base64.startsWith("iVBORw0KGgo"))
29
+ return "image/png";
30
+ if (base64.startsWith("/9j/"))
31
+ return "image/jpeg";
32
+ if (base64.startsWith("R0lGOD"))
33
+ return "image/gif";
34
+ if (base64.startsWith("UklGR"))
35
+ return "image/webp";
36
+ if (base64.startsWith("JVBER"))
37
+ return "application/pdf";
38
+ // Blender files start with "BLENDER" → base64 of "BLENDER" starts with "QkxFTkRFUg"
39
+ if (base64.startsWith("QkxFTkRFUg"))
40
+ return "application/x-blender";
41
+ return "application/octet-stream";
42
+ }
43
+ function mimeToExt(mime) {
44
+ const map = {
45
+ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif",
46
+ "image/webp": "webp", "image/svg+xml": "svg", "application/pdf": "pdf",
47
+ "application/x-blender": "blend",
48
+ };
49
+ return map[mime] || "bin";
50
+ }
51
+ export async function handleMedia(sb, args, storeId) {
52
+ const action = args.action || "upload";
53
+ if (!storeId)
54
+ return { success: false, error: "store_id is required" };
55
+ switch (action) {
56
+ // ── UPLOAD ─────────────────────────────────────────────────────────────────
57
+ case "upload": {
58
+ let base64 = args.base64;
59
+ const fileUrl = args.file_url;
60
+ if (!base64 && !fileUrl) {
61
+ return { success: false, error: "Provide base64 (file contents), file_url (public URL), or file_path (local — handled by CLI)" };
62
+ }
63
+ if (base64)
64
+ base64 = base64.replace(/^data:[^;]+;base64,/, "").replace(/\s/g, "");
65
+ if (!base64 && fileUrl) {
66
+ if (fileUrl.startsWith("/") || /^[A-Z]:\\/i.test(fileUrl)) {
67
+ return {
68
+ success: false,
69
+ error: `Local file paths cannot be used as file_url. Use file_path instead: media(action="upload", file_path="${fileUrl}"). The CLI reads the file locally and uploads it.`,
70
+ };
71
+ }
72
+ let fetchUrl = fileUrl;
73
+ if (!fetchUrl.startsWith("http://") && !fetchUrl.startsWith("https://")) {
74
+ fetchUrl = `https://${fetchUrl}`;
75
+ }
76
+ const resp = await fetch(fetchUrl);
77
+ if (!resp.ok)
78
+ return { success: false, error: `Failed to fetch file_url: ${resp.status}` };
79
+ const buf = await resp.arrayBuffer();
80
+ base64 = Buffer.from(buf).toString("base64");
81
+ }
82
+ const mime = args.mime_type || detectMime(base64);
83
+ const ext = mimeToExt(mime);
84
+ const originalName = args.file_name || "";
85
+ const folder = args.folder || "uploads";
86
+ const id = crypto.randomUUID();
87
+ const fileName = originalName
88
+ ? `${id}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
89
+ : `${id}.${ext}`;
90
+ const storagePath = `${folder}/${storeId.toUpperCase()}/${fileName}`;
91
+ const buffer = Buffer.from(base64, "base64");
92
+ const { error: uploadErr } = await sb.storage
93
+ .from(BUCKET)
94
+ .upload(storagePath, buffer, { contentType: mime, upsert: true });
95
+ if (uploadErr)
96
+ return { success: false, error: `Upload failed: ${uploadErr.message}` };
97
+ const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(storagePath);
98
+ const cdnUrl = urlData.publicUrl;
99
+ const insertData = {
100
+ store_id: storeId,
101
+ file_name: fileName,
102
+ file_path: storagePath,
103
+ file_url: cdnUrl,
104
+ file_size: buffer.length,
105
+ file_type: mime,
106
+ category: args.category || "product_photos",
107
+ source: "media-upload",
108
+ folder,
109
+ };
110
+ if (args.title)
111
+ insertData.title = args.title;
112
+ if (args.alt_text)
113
+ insertData.alt_text = args.alt_text;
114
+ if (args.notes)
115
+ insertData.notes = args.notes;
116
+ if (args.tags)
117
+ insertData.custom_tags = args.tags;
118
+ const { data: mediaRow, error: mediaErr } = await sb
119
+ .from("store_media")
120
+ .insert(insertData)
121
+ .select("id")
122
+ .single();
123
+ if (mediaErr)
124
+ console.error("[media] store_media insert error:", mediaErr.message);
125
+ return {
126
+ success: true,
127
+ data: {
128
+ media_id: mediaRow?.id || id,
129
+ file_url: cdnUrl,
130
+ file_name: fileName,
131
+ file_size: buffer.length,
132
+ mime_type: mime,
133
+ storage_path: storagePath,
134
+ },
135
+ };
136
+ }
137
+ // ── BULK UPLOAD ────────────────────────────────────────────────────────────
138
+ case "bulk_upload": {
139
+ const files = args.files;
140
+ if (!files || !Array.isArray(files) || files.length === 0) {
141
+ return { success: false, error: "bulk_upload requires files: [{base64, file_name}]" };
142
+ }
143
+ const folder = args.folder || "uploads";
144
+ const category = args.category || "product_photos";
145
+ const readErrors = args._read_errors || [];
146
+ const results = await Promise.allSettled(files.map(async (file) => {
147
+ const b64 = file.base64.replace(/^data:[^;]+;base64,/, "").replace(/\s/g, "");
148
+ const mime = args.mime_type || detectMime(b64);
149
+ const ext = mimeToExt(mime);
150
+ const fid = crypto.randomUUID();
151
+ const originalName = file.file_name || "";
152
+ const fName = originalName
153
+ ? `${fid}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
154
+ : `${fid}.${ext}`;
155
+ const storagePath = `${folder}/${storeId.toUpperCase()}/${fName}`;
156
+ const buffer = Buffer.from(b64, "base64");
157
+ const { error: uploadErr } = await sb.storage
158
+ .from(BUCKET)
159
+ .upload(storagePath, buffer, { contentType: mime, upsert: true });
160
+ if (uploadErr)
161
+ throw new Error(`Storage: ${uploadErr.message}`);
162
+ const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(storagePath);
163
+ const cdnUrl = urlData.publicUrl;
164
+ const insertData = {
165
+ store_id: storeId, file_name: fName, file_path: storagePath,
166
+ file_url: cdnUrl, file_size: buffer.length, file_type: mime,
167
+ category, source: "media-upload", folder,
168
+ };
169
+ if (args.title)
170
+ insertData.title = args.title;
171
+ if (args.alt_text)
172
+ insertData.alt_text = args.alt_text;
173
+ if (args.notes)
174
+ insertData.notes = args.notes;
175
+ if (args.tags)
176
+ insertData.custom_tags = args.tags;
177
+ const { data: mediaRow, error: mediaErr } = await sb
178
+ .from("store_media")
179
+ .insert(insertData)
180
+ .select("id")
181
+ .single();
182
+ if (mediaErr)
183
+ console.error("[media] bulk insert error:", mediaErr.message);
184
+ return {
185
+ media_id: mediaRow?.id || fid, file_url: cdnUrl, file_name: fName,
186
+ file_size: buffer.length, mime_type: mime, storage_path: storagePath,
187
+ };
188
+ }));
189
+ const uploaded = [];
190
+ const failed = [];
191
+ results.forEach((r, i) => {
192
+ if (r.status === "fulfilled") {
193
+ uploaded.push(r.value);
194
+ }
195
+ else {
196
+ failed.push({ file_name: files[i].file_name || `file_${i}`, error: r.reason?.message || String(r.reason) });
197
+ }
198
+ });
199
+ for (const e of readErrors)
200
+ failed.push({ file_name: "(read error)", error: e });
201
+ return {
202
+ success: uploaded.length > 0,
203
+ data: {
204
+ uploaded_count: uploaded.length,
205
+ failed_count: failed.length,
206
+ total_size: uploaded.reduce((sum, u) => sum + u.file_size, 0),
207
+ files: uploaded,
208
+ ...(failed.length > 0 ? { errors: failed } : {}),
209
+ },
210
+ };
211
+ }
212
+ // ── LIST ───────────────────────────────────────────────────────────────────
213
+ case "list": {
214
+ const limit = Math.min(Number(args.limit) || 50, 200);
215
+ const offset = Number(args.offset) || 0;
216
+ const sortBy = VALID_SORT_COLUMNS.includes(args.sort_by) ? args.sort_by : "created_at";
217
+ const sortOrder = args.sort_order === "asc";
218
+ let query = sb.from("store_media")
219
+ .select(LIST_COLUMNS, { count: "exact" })
220
+ .eq("store_id", storeId)
221
+ .order(sortBy, { ascending: sortOrder })
222
+ .range(offset, offset + limit - 1);
223
+ // Default: exclude archived unless explicitly requested
224
+ if (args.status) {
225
+ query = query.eq("status", args.status);
226
+ }
227
+ else if (!args.include_archived) {
228
+ query = query.neq("status", "archived");
229
+ }
230
+ if (args.folder)
231
+ query = query.eq("folder", args.folder);
232
+ if (args.category)
233
+ query = query.eq("category", args.category);
234
+ if (args.file_type) {
235
+ const ft = sanitizeFilterValue(args.file_type);
236
+ query = query.ilike("file_type", `${ft}%`);
237
+ }
238
+ if (args.tags && Array.isArray(args.tags)) {
239
+ query = query.overlaps("custom_tags", args.tags);
240
+ }
241
+ const { data, error, count } = await query;
242
+ if (error)
243
+ return { success: false, error: error.message };
244
+ return {
245
+ success: true,
246
+ data: { total: count || 0, count: data?.length || 0, offset, files: data },
247
+ };
248
+ }
249
+ // ── GET ────────────────────────────────────────────────────────────────────
250
+ case "get": {
251
+ const mediaId = args.media_id;
252
+ if (!mediaId)
253
+ return { success: false, error: "media_id required" };
254
+ const { data, error } = await sb.from("store_media").select("*").eq("id", mediaId).eq("store_id", storeId).single();
255
+ if (error)
256
+ return { success: false, error: error.message };
257
+ return { success: true, data };
258
+ }
259
+ // ── UPDATE ─────────────────────────────────────────────────────────────────
260
+ case "update": {
261
+ const mediaId = args.media_id;
262
+ if (!mediaId)
263
+ return { success: false, error: "media_id required" };
264
+ const updates = {};
265
+ for (const key of UPDATE_WHITELIST) {
266
+ if (args[key] !== undefined)
267
+ updates[key] = args[key];
268
+ }
269
+ // Accept description as alias for notes
270
+ if (args.description !== undefined && updates.notes === undefined) {
271
+ updates.notes = args.description;
272
+ }
273
+ // Accept tags as alias for custom_tags
274
+ if (args.tags !== undefined && updates.custom_tags === undefined) {
275
+ updates.custom_tags = args.tags;
276
+ }
277
+ if (Object.keys(updates).length === 0) {
278
+ return { success: false, error: `Nothing to update. Updatable fields: ${UPDATE_WHITELIST.join(", ")}` };
279
+ }
280
+ // Validate enums
281
+ if (updates.category && !VALID_CATEGORIES.includes(updates.category)) {
282
+ return { success: false, error: `Invalid category. Valid: ${VALID_CATEGORIES.join(", ")}` };
283
+ }
284
+ if (updates.status && !VALID_STATUSES.includes(updates.status)) {
285
+ return { success: false, error: `Invalid status. Valid: ${VALID_STATUSES.join(", ")}` };
286
+ }
287
+ const { data, error } = await sb.from("store_media")
288
+ .update(updates)
289
+ .eq("id", mediaId)
290
+ .eq("store_id", storeId)
291
+ .select("*")
292
+ .single();
293
+ if (error)
294
+ return { success: false, error: error.message };
295
+ return { success: true, data };
296
+ }
297
+ // ── BULK UPDATE ────────────────────────────────────────────────────────────
298
+ // Two modes:
299
+ // 1. Uniform: media_ids[] + shared fields → applies same update to all
300
+ // 2. Per-item: items[] → each item has {id, title?, alt_text?, tags?, ...}
301
+ case "bulk_update": {
302
+ const items = args.items;
303
+ // ── Per-item mode ──
304
+ if (items && Array.isArray(items) && items.length > 0) {
305
+ if (items.length > 500) {
306
+ return { success: false, error: `Too many items (${items.length}). Max 500 per call.` };
307
+ }
308
+ // Validate all items up-front
309
+ for (let i = 0; i < items.length; i++) {
310
+ const item = items[i];
311
+ if (!item.id && !item.media_id) {
312
+ return { success: false, error: `items[${i}] missing id` };
313
+ }
314
+ if (item.category && !VALID_CATEGORIES.includes(item.category)) {
315
+ return { success: false, error: `items[${i}] invalid category: ${item.category}` };
316
+ }
317
+ if (item.status && !VALID_STATUSES.includes(item.status)) {
318
+ return { success: false, error: `items[${i}] invalid status: ${item.status}` };
319
+ }
320
+ }
321
+ // Process in parallel batches of 25
322
+ const BATCH_SIZE = 25;
323
+ let updatedCount = 0;
324
+ let failedCount = 0;
325
+ const errors = [];
326
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
327
+ const batch = items.slice(i, i + BATCH_SIZE);
328
+ const results = await Promise.allSettled(batch.map(async (item) => {
329
+ const itemId = (item.id || item.media_id);
330
+ const updates = {};
331
+ for (const key of UPDATE_WHITELIST) {
332
+ if (item[key] !== undefined)
333
+ updates[key] = item[key];
334
+ }
335
+ if (item.description !== undefined && updates.notes === undefined) {
336
+ updates.notes = item.description;
337
+ }
338
+ if (item.tags !== undefined && updates.custom_tags === undefined) {
339
+ updates.custom_tags = item.tags;
340
+ }
341
+ if (Object.keys(updates).length === 0)
342
+ return null; // skip no-ops
343
+ const { error } = await sb.from("store_media")
344
+ .update(updates)
345
+ .eq("id", itemId)
346
+ .eq("store_id", storeId);
347
+ if (error)
348
+ throw new Error(`${itemId}: ${error.message}`);
349
+ return itemId;
350
+ }));
351
+ for (const r of results) {
352
+ if (r.status === "fulfilled" && r.value !== null)
353
+ updatedCount++;
354
+ else if (r.status === "rejected") {
355
+ failedCount++;
356
+ errors.push(r.reason?.message || String(r.reason));
357
+ }
358
+ }
359
+ }
360
+ return {
361
+ success: updatedCount > 0,
362
+ data: {
363
+ updated_count: updatedCount,
364
+ failed_count: failedCount,
365
+ ...(errors.length > 0 ? { errors: errors.slice(0, 10) } : {}),
366
+ },
367
+ };
368
+ }
369
+ // ── Uniform mode ──
370
+ const ids = args.media_ids;
371
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
372
+ return { success: false, error: "Provide items[] (per-item updates) or media_ids[] + shared fields (uniform update)" };
373
+ }
374
+ const updates = {};
375
+ for (const key of UPDATE_WHITELIST) {
376
+ if (args[key] !== undefined)
377
+ updates[key] = args[key];
378
+ }
379
+ if (args.description !== undefined && updates.notes === undefined) {
380
+ updates.notes = args.description;
381
+ }
382
+ if (args.tags !== undefined && updates.custom_tags === undefined) {
383
+ updates.custom_tags = args.tags;
384
+ }
385
+ if (Object.keys(updates).length === 0) {
386
+ return { success: false, error: `Nothing to update. Updatable fields: ${UPDATE_WHITELIST.join(", ")}` };
387
+ }
388
+ if (updates.category && !VALID_CATEGORIES.includes(updates.category)) {
389
+ return { success: false, error: `Invalid category. Valid: ${VALID_CATEGORIES.join(", ")}` };
390
+ }
391
+ if (updates.status && !VALID_STATUSES.includes(updates.status)) {
392
+ return { success: false, error: `Invalid status. Valid: ${VALID_STATUSES.join(", ")}` };
393
+ }
394
+ const { data, error } = await sb.from("store_media")
395
+ .update(updates)
396
+ .in("id", ids)
397
+ .eq("store_id", storeId)
398
+ .select("id");
399
+ if (error)
400
+ return { success: false, error: error.message };
401
+ return { success: true, data: { updated_count: data?.length || 0 } };
402
+ }
403
+ // ── DELETE ─────────────────────────────────────────────────────────────────
404
+ case "delete": {
405
+ const mediaId = args.media_id;
406
+ if (!mediaId)
407
+ return { success: false, error: "media_id required" };
408
+ const { data: row } = await sb.from("store_media").select("file_path").eq("id", mediaId).eq("store_id", storeId).single();
409
+ if (row?.file_path) {
410
+ await sb.storage.from(BUCKET).remove([row.file_path]);
411
+ }
412
+ const { error } = await sb.from("store_media").delete().eq("id", mediaId).eq("store_id", storeId);
413
+ if (error)
414
+ return { success: false, error: error.message };
415
+ return { success: true, data: { deleted: mediaId } };
416
+ }
417
+ // ── BULK DELETE ────────────────────────────────────────────────────────────
418
+ case "bulk_delete": {
419
+ const ids = args.media_ids;
420
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
421
+ return { success: false, error: "media_ids[] required" };
422
+ }
423
+ // Fetch file paths for storage cleanup
424
+ const { data: rows } = await sb.from("store_media")
425
+ .select("id, file_path")
426
+ .in("id", ids)
427
+ .eq("store_id", storeId);
428
+ const filePaths = (rows || []).map(r => r.file_path).filter(Boolean);
429
+ if (filePaths.length > 0) {
430
+ await sb.storage.from(BUCKET).remove(filePaths);
431
+ }
432
+ const { error } = await sb.from("store_media").delete().in("id", ids).eq("store_id", storeId);
433
+ if (error)
434
+ return { success: false, error: error.message };
435
+ return { success: true, data: { deleted_count: rows?.length || 0 } };
436
+ }
437
+ // ── SEARCH ─────────────────────────────────────────────────────────────────
438
+ case "search": {
439
+ const query = args.query;
440
+ if (!query)
441
+ return { success: false, error: "query required for search" };
442
+ const sq = sanitizeFilterValue(query);
443
+ const limit = Math.min(Number(args.limit) || 50, 200);
444
+ const offset = Number(args.offset) || 0;
445
+ const sortBy = VALID_SORT_COLUMNS.includes(args.sort_by) ? args.sort_by : "created_at";
446
+ const sortOrder = args.sort_order === "asc";
447
+ let q = sb.from("store_media")
448
+ .select(LIST_COLUMNS, { count: "exact" })
449
+ .eq("store_id", storeId)
450
+ .or(`file_name.ilike.%${sq}%,title.ilike.%${sq}%,alt_text.ilike.%${sq}%,notes.ilike.%${sq}%`)
451
+ .order(sortBy, { ascending: sortOrder })
452
+ .range(offset, offset + limit - 1);
453
+ // Filters
454
+ if (args.status) {
455
+ q = q.eq("status", args.status);
456
+ }
457
+ else if (!args.include_archived) {
458
+ q = q.neq("status", "archived");
459
+ }
460
+ if (args.folder)
461
+ q = q.eq("folder", args.folder);
462
+ if (args.category)
463
+ q = q.eq("category", args.category);
464
+ if (args.file_type) {
465
+ const ft = sanitizeFilterValue(args.file_type);
466
+ q = q.ilike("file_type", `${ft}%`);
467
+ }
468
+ if (args.tags && Array.isArray(args.tags)) {
469
+ q = q.overlaps("custom_tags", args.tags);
470
+ }
471
+ if (args.date_from)
472
+ q = q.gte("created_at", args.date_from);
473
+ if (args.date_to)
474
+ q = q.lte("created_at", args.date_to);
475
+ if (args.min_usage !== undefined)
476
+ q = q.gte("usage_count", Number(args.min_usage));
477
+ if (args.max_usage !== undefined)
478
+ q = q.lte("usage_count", Number(args.max_usage));
479
+ const { data, error, count } = await q;
480
+ if (error)
481
+ return { success: false, error: error.message };
482
+ return {
483
+ success: true,
484
+ data: { total: count || 0, count: data?.length || 0, offset, query, files: data },
485
+ };
486
+ }
487
+ // ── MOVE ───────────────────────────────────────────────────────────────────
488
+ case "move": {
489
+ const folder = args.folder;
490
+ if (!folder)
491
+ return { success: false, error: "folder required" };
492
+ const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
493
+ if (ids.length === 0)
494
+ return { success: false, error: "media_id or media_ids[] required" };
495
+ const { data, error } = await sb.from("store_media")
496
+ .update({ folder })
497
+ .in("id", ids)
498
+ .eq("store_id", storeId)
499
+ .select("id");
500
+ if (error)
501
+ return { success: false, error: error.message };
502
+ return { success: true, data: { moved_count: data?.length || 0, folder } };
503
+ }
504
+ // ── ARCHIVE ────────────────────────────────────────────────────────────────
505
+ case "archive": {
506
+ const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
507
+ if (ids.length === 0)
508
+ return { success: false, error: "media_id or media_ids[] required" };
509
+ const { data, error } = await sb.from("store_media")
510
+ .update({ status: "archived" })
511
+ .in("id", ids)
512
+ .eq("store_id", storeId)
513
+ .select("id");
514
+ if (error)
515
+ return { success: false, error: error.message };
516
+ return { success: true, data: { archived_count: data?.length || 0 } };
517
+ }
518
+ // ── UNARCHIVE ──────────────────────────────────────────────────────────────
519
+ case "unarchive": {
520
+ const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
521
+ if (ids.length === 0)
522
+ return { success: false, error: "media_id or media_ids[] required" };
523
+ const { data, error } = await sb.from("store_media")
524
+ .update({ status: "active" })
525
+ .in("id", ids)
526
+ .eq("store_id", storeId)
527
+ .select("id");
528
+ if (error)
529
+ return { success: false, error: error.message };
530
+ return { success: true, data: { unarchived_count: data?.length || 0 } };
531
+ }
532
+ // ── TAG ────────────────────────────────────────────────────────────────────
533
+ case "tag": {
534
+ const ids = args.media_ids || (args.media_id ? [args.media_id] : []);
535
+ if (ids.length === 0)
536
+ return { success: false, error: "media_id or media_ids[] required" };
537
+ const setTags = args.set;
538
+ const addTags = args.add;
539
+ const removeTags = args.remove;
540
+ if (!setTags && !addTags && !removeTags) {
541
+ return { success: false, error: "Provide set (replace all), add (append), or remove (delete specific tags)" };
542
+ }
543
+ if (setTags) {
544
+ // Direct replacement
545
+ const { data, error } = await sb.from("store_media")
546
+ .update({ custom_tags: setTags })
547
+ .in("id", ids)
548
+ .eq("store_id", storeId)
549
+ .select("id, custom_tags");
550
+ if (error)
551
+ return { success: false, error: error.message };
552
+ return { success: true, data: { updated_count: data?.length || 0, tags: setTags } };
553
+ }
554
+ // Incremental add/remove — read-modify-write per item
555
+ const { data: rows, error: fetchErr } = await sb.from("store_media")
556
+ .select("id, custom_tags")
557
+ .in("id", ids)
558
+ .eq("store_id", storeId);
559
+ if (fetchErr)
560
+ return { success: false, error: fetchErr.message };
561
+ let updatedCount = 0;
562
+ for (const row of rows || []) {
563
+ let tags = row.custom_tags || [];
564
+ if (addTags) {
565
+ const newTags = addTags.filter(t => !tags.includes(t));
566
+ tags = [...tags, ...newTags];
567
+ }
568
+ if (removeTags) {
569
+ tags = tags.filter(t => !removeTags.includes(t));
570
+ }
571
+ const { error: upErr } = await sb.from("store_media")
572
+ .update({ custom_tags: tags })
573
+ .eq("id", row.id)
574
+ .eq("store_id", storeId);
575
+ if (!upErr)
576
+ updatedCount++;
577
+ }
578
+ return { success: true, data: { updated_count: updatedCount } };
579
+ }
580
+ // ── FOLDERS LIST ───────────────────────────────────────────────────────────
581
+ case "folders_list": {
582
+ const { data, error } = await sb.from("media_folders")
583
+ .select("*")
584
+ .eq("store_id", storeId)
585
+ .order("name", { ascending: true });
586
+ if (error)
587
+ return { success: false, error: error.message };
588
+ return { success: true, data: { count: data?.length || 0, folders: data } };
589
+ }
590
+ // ── FOLDERS CREATE ─────────────────────────────────────────────────────────
591
+ case "folders_create": {
592
+ const name = args.name;
593
+ if (!name)
594
+ return { success: false, error: "name required" };
595
+ const insertData = { store_id: storeId, name };
596
+ if (args.parent_folder_id)
597
+ insertData.parent_folder_id = args.parent_folder_id;
598
+ if (args.color)
599
+ insertData.color = args.color;
600
+ if (args.icon)
601
+ insertData.icon = args.icon;
602
+ const { data, error } = await sb.from("media_folders")
603
+ .insert(insertData)
604
+ .select("*")
605
+ .single();
606
+ if (error)
607
+ return { success: false, error: error.message };
608
+ return { success: true, data };
609
+ }
610
+ // ── FOLDERS UPDATE ─────────────────────────────────────────────────────────
611
+ case "folders_update": {
612
+ const folderId = args.folder_id;
613
+ if (!folderId)
614
+ return { success: false, error: "folder_id required" };
615
+ const updates = {};
616
+ if (args.name !== undefined)
617
+ updates.name = args.name;
618
+ if (args.parent_folder_id !== undefined)
619
+ updates.parent_folder_id = args.parent_folder_id;
620
+ if (args.color !== undefined)
621
+ updates.color = args.color;
622
+ if (args.icon !== undefined)
623
+ updates.icon = args.icon;
624
+ if (Object.keys(updates).length === 0) {
625
+ return { success: false, error: "Nothing to update. Fields: name, parent_folder_id, color, icon" };
626
+ }
627
+ const { data, error } = await sb.from("media_folders")
628
+ .update(updates)
629
+ .eq("id", folderId)
630
+ .eq("store_id", storeId)
631
+ .select("*")
632
+ .single();
633
+ if (error)
634
+ return { success: false, error: error.message };
635
+ return { success: true, data };
636
+ }
637
+ // ── FOLDERS DELETE ─────────────────────────────────────────────────────────
638
+ case "folders_delete": {
639
+ const folderId = args.folder_id;
640
+ if (!folderId)
641
+ return { success: false, error: "folder_id required" };
642
+ // Optionally move orphaned media to another folder
643
+ if (args.move_contents_to) {
644
+ await sb.from("store_media")
645
+ .update({ folder: args.move_contents_to })
646
+ .eq("folder_id", folderId)
647
+ .eq("store_id", storeId);
648
+ }
649
+ // FK is ON DELETE SET NULL — safe to delete
650
+ const { error } = await sb.from("media_folders")
651
+ .delete()
652
+ .eq("id", folderId)
653
+ .eq("store_id", storeId);
654
+ if (error)
655
+ return { success: false, error: error.message };
656
+ return { success: true, data: { deleted: folderId } };
657
+ }
658
+ // ── LINK PRODUCT ───────────────────────────────────────────────────────────
659
+ case "link_product": {
660
+ const mediaId = args.media_id;
661
+ const productId = args.product_id;
662
+ if (!mediaId || !productId)
663
+ return { success: false, error: "media_id and product_id required" };
664
+ const { error } = await sb.rpc("link_media_to_product", {
665
+ p_media_id: mediaId,
666
+ p_product_id: productId,
667
+ });
668
+ if (error)
669
+ return { success: false, error: error.message };
670
+ return { success: true, data: { linked: true, media_id: mediaId, product_id: productId } };
671
+ }
672
+ // ── UNLINK PRODUCT ─────────────────────────────────────────────────────────
673
+ case "unlink_product": {
674
+ const mediaId = args.media_id;
675
+ const productId = args.product_id;
676
+ if (!mediaId || !productId)
677
+ return { success: false, error: "media_id and product_id required" };
678
+ const { error } = await sb.rpc("unlink_media_from_product", {
679
+ p_media_id: mediaId,
680
+ p_product_id: productId,
681
+ });
682
+ if (error)
683
+ return { success: false, error: error.message };
684
+ return { success: true, data: { unlinked: true, media_id: mediaId, product_id: productId } };
685
+ }
686
+ // ── USAGE ──────────────────────────────────────────────────────────────────
687
+ case "usage": {
688
+ const mediaId = args.media_id;
689
+ if (!mediaId)
690
+ return { success: false, error: "media_id required" };
691
+ // Get the media's file_url
692
+ const { data: media, error: mediaErr } = await sb.from("store_media")
693
+ .select("file_url")
694
+ .eq("id", mediaId)
695
+ .eq("store_id", storeId)
696
+ .single();
697
+ if (mediaErr)
698
+ return { success: false, error: mediaErr.message };
699
+ if (!media?.file_url)
700
+ return { success: false, error: "Media not found or has no file_url" };
701
+ // Query media_references for this URL
702
+ const { data: refs, error: refErr } = await sb.from("media_references")
703
+ .select("entity_type, entity_id, entity_name, field_name, created_at")
704
+ .eq("media_url", media.file_url)
705
+ .eq("store_id", storeId)
706
+ .order("created_at", { ascending: false });
707
+ if (refErr)
708
+ return { success: false, error: refErr.message };
709
+ return {
710
+ success: true,
711
+ data: {
712
+ media_id: mediaId,
713
+ reference_count: refs?.length || 0,
714
+ references: refs,
715
+ },
716
+ };
717
+ }
718
+ // ── ANALYTICS ──────────────────────────────────────────────────────────────
719
+ case "analytics": {
720
+ // Run parallel aggregate queries
721
+ const [activeRes, archivedRes, allRes, orphanRes, typeRes, catRes, topRes] = await Promise.all([
722
+ sb.from("store_media").select("id", { count: "exact", head: true }).eq("store_id", storeId).eq("status", "active"),
723
+ sb.from("store_media").select("id", { count: "exact", head: true }).eq("store_id", storeId).eq("status", "archived"),
724
+ sb.from("store_media").select("file_size, file_type, category").eq("store_id", storeId),
725
+ sb.from("store_media").select("id", { count: "exact", head: true }).eq("store_id", storeId).eq("usage_count", 0).neq("status", "archived"),
726
+ sb.from("store_media").select("file_type").eq("store_id", storeId),
727
+ sb.from("store_media").select("category").eq("store_id", storeId),
728
+ sb.from("store_media").select("id, file_name, title, file_url, usage_count").eq("store_id", storeId).order("usage_count", { ascending: false }).limit(10),
729
+ ]);
730
+ // Compute total storage
731
+ const allRows = allRes.data || [];
732
+ const totalBytes = allRows.reduce((sum, r) => sum + (r.file_size || 0), 0);
733
+ // Type breakdown (group by prefix before /)
734
+ const typeBreakdown = {};
735
+ for (const row of typeRes.data || []) {
736
+ const prefix = (row.file_type || "unknown").split("/")[0];
737
+ typeBreakdown[prefix] = (typeBreakdown[prefix] || 0) + 1;
738
+ }
739
+ // Category breakdown
740
+ const catBreakdown = {};
741
+ for (const row of catRes.data || []) {
742
+ const cat = row.category || "unknown";
743
+ catBreakdown[cat] = (catBreakdown[cat] || 0) + 1;
744
+ }
745
+ return {
746
+ success: true,
747
+ data: {
748
+ total_items: (activeRes.count || 0) + (archivedRes.count || 0),
749
+ active_count: activeRes.count || 0,
750
+ archived_count: archivedRes.count || 0,
751
+ orphan_count: orphanRes.count || 0,
752
+ total_storage_bytes: totalBytes,
753
+ total_storage_mb: Math.round(totalBytes / 1_048_576 * 100) / 100,
754
+ type_breakdown: typeBreakdown,
755
+ category_breakdown: catBreakdown,
756
+ top_used: topRes.data || [],
757
+ },
758
+ };
759
+ }
760
+ // ── DUPLICATE ──────────────────────────────────────────────────────────────
761
+ case "duplicate": {
762
+ const mediaId = args.media_id;
763
+ if (!mediaId)
764
+ return { success: false, error: "media_id required" };
765
+ // Fetch source row
766
+ const { data: source, error: srcErr } = await sb.from("store_media")
767
+ .select("*")
768
+ .eq("id", mediaId)
769
+ .eq("store_id", storeId)
770
+ .single();
771
+ if (srcErr)
772
+ return { success: false, error: srcErr.message };
773
+ // Download source file from storage
774
+ const { data: fileData, error: dlErr } = await sb.storage
775
+ .from(BUCKET)
776
+ .download(source.file_path);
777
+ if (dlErr)
778
+ return { success: false, error: `Download source failed: ${dlErr.message}` };
779
+ const buffer = Buffer.from(await fileData.arrayBuffer());
780
+ // Upload with new UUID path
781
+ const newId = crypto.randomUUID();
782
+ const ext = source.file_name.split(".").pop() || "bin";
783
+ const newFileName = `${newId}.${ext}`;
784
+ const newPath = `${source.folder || "uploads"}/${storeId.toUpperCase()}/${newFileName}`;
785
+ const { error: upErr } = await sb.storage
786
+ .from(BUCKET)
787
+ .upload(newPath, buffer, { contentType: source.file_type, upsert: true });
788
+ if (upErr)
789
+ return { success: false, error: `Upload duplicate failed: ${upErr.message}` };
790
+ const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(newPath);
791
+ const cdnUrl = urlData.publicUrl;
792
+ const newTitle = args.new_title || (source.title ? `${source.title} (copy)` : null);
793
+ const { data: newRow, error: insertErr } = await sb.from("store_media")
794
+ .insert({
795
+ store_id: storeId,
796
+ file_name: newFileName,
797
+ file_path: newPath,
798
+ file_url: cdnUrl,
799
+ file_size: buffer.length,
800
+ file_type: source.file_type,
801
+ category: source.category,
802
+ source: "media-duplicate",
803
+ folder: source.folder,
804
+ title: newTitle,
805
+ alt_text: source.alt_text,
806
+ notes: source.notes,
807
+ custom_tags: source.custom_tags,
808
+ })
809
+ .select("id, file_name, file_url, title")
810
+ .single();
811
+ if (insertErr)
812
+ return { success: false, error: insertErr.message };
813
+ return {
814
+ success: true,
815
+ data: {
816
+ original_id: mediaId,
817
+ id: newRow.id,
818
+ file_name: newRow.file_name,
819
+ file_url: newRow.file_url,
820
+ title: newRow.title,
821
+ },
822
+ };
823
+ }
824
+ // ── REPLACE ────────────────────────────────────────────────────────────────
825
+ case "replace": {
826
+ const mediaId = args.media_id;
827
+ if (!mediaId)
828
+ return { success: false, error: "media_id required" };
829
+ let base64 = args.base64;
830
+ const fileUrl = args.file_url;
831
+ if (!base64 && !fileUrl) {
832
+ return { success: false, error: "Provide base64, file_url, or file_path (local — handled by CLI)" };
833
+ }
834
+ if (base64)
835
+ base64 = base64.replace(/^data:[^;]+;base64,/, "").replace(/\s/g, "");
836
+ if (!base64 && fileUrl) {
837
+ if (fileUrl.startsWith("/") || /^[A-Z]:\\/i.test(fileUrl)) {
838
+ return { success: false, error: `Use file_path for local files: media(action="replace", media_id="...", file_path="${fileUrl}")` };
839
+ }
840
+ let fetchUrl = fileUrl;
841
+ if (!fetchUrl.startsWith("http://") && !fetchUrl.startsWith("https://")) {
842
+ fetchUrl = `https://${fetchUrl}`;
843
+ }
844
+ const resp = await fetch(fetchUrl);
845
+ if (!resp.ok)
846
+ return { success: false, error: `Failed to fetch file_url: ${resp.status}` };
847
+ const buf = await resp.arrayBuffer();
848
+ base64 = Buffer.from(buf).toString("base64");
849
+ }
850
+ // Fetch existing row
851
+ const { data: existing, error: getErr } = await sb.from("store_media")
852
+ .select("file_path")
853
+ .eq("id", mediaId)
854
+ .eq("store_id", storeId)
855
+ .single();
856
+ if (getErr)
857
+ return { success: false, error: getErr.message };
858
+ // Delete old storage file
859
+ if (existing.file_path) {
860
+ await sb.storage.from(BUCKET).remove([existing.file_path]);
861
+ }
862
+ const mime = args.mime_type || detectMime(base64);
863
+ const ext = mimeToExt(mime);
864
+ const buffer = Buffer.from(base64, "base64");
865
+ const newId = crypto.randomUUID();
866
+ const originalName = args.file_name || "";
867
+ const newFileName = originalName
868
+ ? `${newId}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`
869
+ : `${newId}.${ext}`;
870
+ const folder = args.folder || "uploads";
871
+ const newPath = `${folder}/${storeId.toUpperCase()}/${newFileName}`;
872
+ const { error: upErr } = await sb.storage
873
+ .from(BUCKET)
874
+ .upload(newPath, buffer, { contentType: mime, upsert: true });
875
+ if (upErr)
876
+ return { success: false, error: `Upload replacement failed: ${upErr.message}` };
877
+ const { data: urlData } = sb.storage.from(BUCKET).getPublicUrl(newPath);
878
+ const cdnUrl = urlData.publicUrl;
879
+ const { data, error } = await sb.from("store_media")
880
+ .update({
881
+ file_name: newFileName,
882
+ file_path: newPath,
883
+ file_url: cdnUrl,
884
+ file_size: buffer.length,
885
+ file_type: mime,
886
+ })
887
+ .eq("id", mediaId)
888
+ .eq("store_id", storeId)
889
+ .select("*")
890
+ .single();
891
+ if (error)
892
+ return { success: false, error: error.message };
893
+ return { success: true, data };
894
+ }
895
+ // ── DEFAULT ────────────────────────────────────────────────────────────────
896
+ default:
897
+ return {
898
+ success: false,
899
+ error: `Unknown action: ${action}. Available actions: ${ALL_ACTIONS.join(", ")}`,
900
+ };
901
+ }
902
+ }