diff options
| author | Dhravya Shah <[email protected]> | 2025-01-22 17:23:22 -0700 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2025-01-22 17:23:22 -0700 |
| commit | 0c0ea871c3d8ffa7c81af32d0e7a40e8939aa731 (patch) | |
| tree | 393176aba8d32a4828390039fb9bb295ef8c87d0 /apps | |
| parent | Merge branch 'main' of github.com:supermemoryai/supermemory (diff) | |
| download | supermemory-0c0ea871c3d8ffa7c81af32d0e7a40e8939aa731.tar.xz supermemory-0c0ea871c3d8ffa7c81af32d0e7a40e8939aa731.zip | |
fix: postgres error
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/backend/src/routes/actions.ts | 269 | ||||
| -rw-r--r-- | apps/web/app/components/memories/Integrations.tsx | 24 | ||||
| -rw-r--r-- | apps/web/package.json | 2 | ||||
| -rw-r--r-- | apps/web/vite.config.ts | 2 |
4 files changed, 281 insertions, 16 deletions
diff --git a/apps/backend/src/routes/actions.ts b/apps/backend/src/routes/actions.ts index 0c0d32f0..51a01293 100644 --- a/apps/backend/src/routes/actions.ts +++ b/apps/backend/src/routes/actions.ts @@ -20,10 +20,19 @@ import { chunk, spaces, spaceAccess, + type Space, } from "@supermemory/db/schema"; import { google, openai } from "../providers"; import { randomId } from "@supermemory/shared"; -import { and, cosineDistance, database, desc, eq, or, sql } from "@supermemory/db"; +import { + and, + cosineDistance, + database, + desc, + eq, + or, + sql, +} from "@supermemory/db"; import { typeDecider } from "../utils/typeDecider"; import { isErr, Ok } from "../errors/results"; @@ -527,7 +536,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() const cached = await c.env.MD_CACHE.get(cacheKey); if (cached) { return c.json({ - suggestedLearnings: JSON.parse(cached) as {[x: string]: string}, + suggestedLearnings: JSON.parse(cached) as { [x: string]: string }, }); } @@ -714,7 +723,9 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() const data = encoder.encode(content); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); - const documentHash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + const documentHash = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); // Check for duplicates using hash const existingDocs = await db @@ -727,10 +738,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() eq(documents.contentHash, documentHash), and( eq(documents.type, type.value), - or( - eq(documents.url, body.content), - eq(documents.raw, content) - ) + or(eq(documents.url, body.content), eq(documents.raw, content)) ) ) ) @@ -757,13 +765,15 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() return { spaceId, allowed: false, error: "Space not found" }; } + const spaceData = space[0] as Space; + // If public space, only owner can add content - if (space[0].isPublic) { + if (spaceData.isPublic) { return { spaceId, - allowed: space[0].ownerId === user.id, + allowed: spaceData.ownerId === user.id, error: - space[0].ownerId !== user.id + spaceData.ownerId !== user.id ? "Only space owner can add to public spaces" : null, }; @@ -775,7 +785,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() .from(spaceAccess) .where( and( - eq(spaceAccess.spaceId, space[0].id), + eq(spaceAccess.spaceId, spaceData.id), eq(spaceAccess.userEmail, user.email), eq(spaceAccess.status, "accepted") ) @@ -785,9 +795,9 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() return { spaceId, allowed: - space[0].ownerId === user.id || spaceAccessCheck.length > 0, + spaceData.ownerId === user.id || spaceAccessCheck.length > 0, error: - space[0].ownerId !== user.id && !spaceAccessCheck.length + spaceData.ownerId !== user.id && !spaceAccessCheck.length ? "Not authorized to add to this space" : null, }; @@ -856,6 +866,239 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>() return c.json({ error: "Failed to process content" }, 500); } } + ) + .post( + "/batch-add", + zValidator( + "json", + z.object({ + urls: z.array(z.string()).min(1, "At least one URL is required"), + spaces: z.array(z.string()).max(5).optional(), + }) + ), + async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + const { urls, spaces } = await c.req.valid("json"); + + // Check space permissions if spaces are specified + if (spaces && spaces.length > 0) { + const db = database(c.env.HYPERDRIVE.connectionString); + const spacePermissions = await Promise.all( + spaces.map(async (spaceId) => { + const space = await db + .select() + .from(spaces) + .where(eq(spaces.uuid, spaceId)) + .limit(1); + + if (!space[0]) { + return { spaceId, allowed: false, error: "Space not found" }; + } + + const spaceData = space[0] as Space; + + // If public space, only owner can add content + if (spaceData.isPublic) { + return { + spaceId, + allowed: spaceData.ownerId === user.id, + error: + spaceData.ownerId !== user.id + ? "Only space owner can add to public spaces" + : null, + }; + } + + // For private spaces, check if user is owner or in allowlist + const spaceAccessCheck = await db + .select() + .from(spaceAccess) + .where( + and( + eq(spaceAccess.spaceId, spaceData.id), + eq(spaceAccess.userEmail, user.email), + eq(spaceAccess.status, "accepted") + ) + ) + .limit(1); + + return { + spaceId, + allowed: + spaceData.ownerId === user.id || spaceAccessCheck.length > 0, + error: + spaceData.ownerId !== user.id && !spaceAccessCheck.length + ? "Not authorized to add to this space" + : null, + }; + }) + ); + + const unauthorized = spacePermissions.filter((p) => !p.allowed); + if (unauthorized.length > 0) { + return c.json( + { + error: "Space permission denied", + details: unauthorized + .map((u) => `${u.spaceId}: ${u.error}`) + .join(", "), + }, + 403 + ); + } + } + + // Create a new ReadableStream for progress updates + const stream = new ReadableStream({ + async start(controller) { + const db = database(c.env.HYPERDRIVE.connectionString); + const total = urls.length; + let processed = 0; + let failed = 0; + let succeeded = 0; + + for (const url of urls) { + try { + processed++; + + // Calculate document hash for duplicate detection + const encoder = new TextEncoder(); + const data = encoder.encode(url); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const documentHash = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + // Check for duplicates + const existingDocs = await db + .select() + .from(documents) + .where( + and( + eq(documents.userId, user.id), + or( + eq(documents.contentHash, documentHash), + eq(documents.url, url) + ) + ) + ); + + if (existingDocs.length > 0) { + failed++; + controller.enqueue( + `data: ${JSON.stringify({ + progress: Math.round((processed / total) * 100), + status: "duplicate", + url, + processed, + total, + succeeded, + failed, + })}\n\n` + ); + continue; + } + + const contentId = `add-${user.id}-${randomId()}`; + const type = typeDecider(url); + + if (isErr(type)) { + failed++; + controller.enqueue( + `data: ${JSON.stringify({ + progress: Math.round((processed / total) * 100), + status: "error", + url, + error: type.error.message, + processed, + total, + succeeded, + failed, + })}\n\n` + ); + continue; + } + + // Insert into documents table + await db.insert(documents).values({ + uuid: contentId, + userId: user.id, + type: type.value, + url, + contentHash: documentHash, + raw: url + "\n\n" + spaces?.join(" "), + }); + + // Create workflow for processing + await c.env.CONTENT_WORKFLOW.create({ + params: { + userId: user.id, + content: url, + spaces, + type: type.value, + uuid: contentId, + url, + }, + id: contentId, + }); + + succeeded++; + controller.enqueue( + `data: ${JSON.stringify({ + progress: Math.round((processed / total) * 100), + status: "success", + url, + processed, + total, + succeeded, + failed, + })}\n\n` + ); + } catch (error) { + failed++; + controller.enqueue( + `data: ${JSON.stringify({ + progress: Math.round((processed / total) * 100), + status: "error", + url, + error: + error instanceof Error ? error.message : "Unknown error", + processed, + total, + succeeded, + failed, + })}\n\n` + ); + } + } + + controller.enqueue( + `data: ${JSON.stringify({ + progress: 100, + status: "complete", + processed, + total, + succeeded, + failed, + })}\n\n` + ); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } ); export default actions; diff --git a/apps/web/app/components/memories/Integrations.tsx b/apps/web/app/components/memories/Integrations.tsx index 74c6e882..3825980c 100644 --- a/apps/web/app/components/memories/Integrations.tsx +++ b/apps/web/app/components/memories/Integrations.tsx @@ -7,12 +7,13 @@ import { Card } from "../ui/card"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog"; import { motion } from "framer-motion"; -import { AlertCircle, CheckCircle, Clipboard, ClipboardCheckIcon, X } from "lucide-react"; +import { AlertCircle, CheckCircle, Clipboard, ClipboardCheckIcon, X, FileUpIcon } from "lucide-react"; import { toast } from "sonner"; import { type IntegrationConfig, getIntegrations } from "~/config/integrations"; import { getChromeExtensionId } from "~/config/util"; import { cn } from "~/lib/utils"; import { loader } from "~/root"; +import { CSVUploadModal } from "./CSVUploadModal"; function IntegrationButton({ integration, @@ -150,6 +151,7 @@ function Integrations() { const [loadingIntegration, setLoadingIntegration] = useState<IntegrationConfig | null>(null); const [apiKey, setApiKey] = useState<string | null>(null); const [copied, setCopied] = useState(false); + const [isCSVModalOpen, setIsCSVModalOpen] = useState(false); const handleIntegrationClick = (integration: IntegrationConfig) => { setLoadingIntegration(integration); @@ -254,6 +256,24 @@ function Integrations() { </div> <div className="flex flex-wrap gap-4 overflow-x-auto"> + <Card + className="group relative overflow-hidden transition-all hover:shadow-lg flex-1 basis-[calc(33.333%-1rem)]" + onClick={() => setIsCSVModalOpen(true)} + > + <div className="absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100 bg-gradient-to-r from-blue-500/10 to-blue-600/10" /> + <div className="relative z-10 flex flex-col items-center gap-4 p-6"> + <div className="rounded-full bg-white/10 p-3"> + <FileUpIcon className="transition-transform group-hover:scale-110 text-blue-500" /> + </div> + <div className="text-center"> + <h3 className="font-semibold text-neutral-900 dark:text-white">CSV Upload</h3> + <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400 hidden md:block"> + Bulk import URLs from a CSV file + </p> + </div> + </div> + </Card> + {Object.entries(integrations).map(([key, integration]) => ( <IntegrationButton key={key} @@ -270,6 +290,8 @@ function Integrations() { /> )} + <CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} /> + <div className="mt-8 md:mt-12 text-center"> <p className="text-sm text-neutral-600 dark:text-neutral-400"> More integrations coming soon.{" "} diff --git a/apps/web/package.json b/apps/web/package.json index 673fa345..1635235d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -119,7 +119,7 @@ "isbot": "^5.1.17", "lucide-react": "^0.456.0", "masonic": "^4.0.1", - "postgres": "^3.4.4", + "postgres": "^3.4.5", "prettier-eslint": "^16.3.0", "prismjs": "^1.29.0", "react": "^18.3.1", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a20e1d55..30a24be6 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -40,7 +40,7 @@ export default defineConfig((mode) => { resolve: { alias: { ...(mode.mode === "development" && { - postgres: path.resolve(__dirname, "node_modules/postgres/src/index.js"), + postgres: path.resolve(__dirname, "../../node_modules/postgres/src/index.js"), }), }, extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".css"], |