Преглед изворни кода

feat: add Custom Content RAG system with WYSIWYG editor

Add comprehensive Custom Content management system for AI knowledge base:

Frontend:
- Custom Content page with tabs (All, PDFs, Text Entries)
- PDF upload with drag-and-drop support
- Text entry with three format options:
  * Plain Text
  * Markdown
  * WYSIWYG editor (TipTap) with Word paste support
- Real-time status tracking with polling
- Content viewer with PDF preview and text view
- URL and image validation
- HTML to Markdown conversion
- Text cleanup (tabs, spaces, newlines)
- Character counter includes title + content

Backend (8 Edge Functions):
- custom-content-upload: Handle PDF uploads
- custom-content-process: Extract text, chunk, embed, sync to Qdrant
- custom-content-create: Create text entries with title prepended
- custom-content-list: List content with filtering
- custom-content-view: View content with signed PDF URLs
- custom-content-delete: Delete content and Qdrant points
- custom-content-sync-status: Poll processing status
- custom-content-retry: Retry failed processing

Features:
- Hybrid text chunking (paragraph/sentence-based)
- PDF text extraction with validation
- Automatic Qdrant sync with embeddings
- Content validation (rejects code, structured data)
- Dark theme UI with responsive design
- Bilingual support (EN/HU)

Database:
- custom_content table with RLS policies
- Sync status tracking (pending/processing/completed/failed)
- Qdrant point ID storage for cleanup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh пре 4 месеци
родитељ
комит
bc7d492b9d
32 измењених фајлова са 6260 додато и 8 уклоњено
  1. 1198 0
      CUSTOM_CONTENT_IMPLEMENTATION_GUIDE.md
  2. 6 0
      shopcall.ai-main/package.json
  3. 2 0
      shopcall.ai-main/src/App.tsx
  4. 24 2
      shopcall.ai-main/src/components/AppSidebar.tsx
  5. 345 0
      shopcall.ai-main/src/components/CustomContentList.tsx
  6. 106 0
      shopcall.ai-main/src/components/CustomContentManager.tsx
  7. 127 0
      shopcall.ai-main/src/components/CustomContentStatusBadge.tsx
  8. 300 0
      shopcall.ai-main/src/components/CustomContentTextEntry.tsx
  9. 226 0
      shopcall.ai-main/src/components/CustomContentUpload.tsx
  10. 202 0
      shopcall.ai-main/src/components/CustomContentViewer.tsx
  11. 105 1
      shopcall.ai-main/src/i18n/locales/en.json
  12. 105 1
      shopcall.ai-main/src/i18n/locales/hu.json
  13. 125 0
      shopcall.ai-main/src/index.css
  14. 27 0
      shopcall.ai-main/src/pages/CustomContent.tsx
  15. 351 0
      supabase/functions/_shared/content-validator.ts
  16. 67 1
      supabase/functions/_shared/mcp-qdrant-helpers.ts
  17. 224 0
      supabase/functions/_shared/pdf-processor.ts
  18. 182 0
      supabase/functions/_shared/qdrant-client.ts
  19. 394 0
      supabase/functions/_shared/text-chunker.ts
  20. 218 0
      supabase/functions/custom-content-create/index.ts
  21. 170 0
      supabase/functions/custom-content-delete/index.ts
  22. 128 0
      supabase/functions/custom-content-list/index.ts
  23. 244 0
      supabase/functions/custom-content-process/index.ts
  24. 126 0
      supabase/functions/custom-content-retry/index.ts
  25. 140 0
      supabase/functions/custom-content-sync-status/index.ts
  26. 259 0
      supabase/functions/custom-content-upload/index.ts
  27. 130 0
      supabase/functions/custom-content-view/index.ts
  28. 113 1
      supabase/functions/mcp-shopify/index.ts
  29. 113 1
      supabase/functions/mcp-shoprenter/index.ts
  30. 113 1
      supabase/functions/mcp-woocommerce/index.ts
  31. 229 0
      supabase/migrations/20251125_custom_content.sql
  32. 161 0
      supabase/migrations/20251125_restore_sync_config.sql

+ 1198 - 0
CUSTOM_CONTENT_IMPLEMENTATION_GUIDE.md

@@ -0,0 +1,1198 @@
+# Custom Content RAG Feature - Implementation Guide
+
+## Overview
+
+This guide covers the remaining implementation steps for the Custom Content RAG feature, which allows users to upload PDFs and create text entries that are stored in Qdrant for AI-powered semantic search.
+
+## ✅ Completed Backend Implementation
+
+### 1. Database & Storage
+- **Migration applied**: `custom_content` table created with all fields
+- **Storage bucket**: `custom-content` bucket configured with RLS policies
+- **Indexes**: Performance indexes on store_id, user_id, content_type, sync_status
+- **Functions**: `get_custom_content_stats()` helper function
+
+### 2. Shared Utilities
+- `_shared/pdf-processor.ts` - PDF text extraction, SHA-256 checksum, validation
+- `_shared/text-chunker.ts` - Hybrid chunking (single vs overlapping chunks)
+- `_shared/qdrant-client.ts` - Extended with custom content methods:
+  - `syncCustomContent()` - Stores chunks in Qdrant
+  - `deleteCustomContent()` - Removes from Qdrant
+  - `searchCustomContent()` - Semantic search
+  - `getCustomContentCollectionName()` - Collection naming
+- `_shared/mcp-qdrant-helpers.ts` - Extended with `queryQdrantCustomContent()`
+
+### 3. Edge Functions (All Deployed ✅)
+- `custom-content-upload` - Multipart file upload with deduplication
+- `custom-content-process` - Async PDF processing pipeline
+- `custom-content-create` - Text entry creation
+- `custom-content-list` - List all content for a store
+- `custom-content-delete` - Hard delete from all systems
+- `custom-content-sync-status` - Real-time status polling
+
+All functions are deployed and accessible at:
+`https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1/custom-content-*`
+
+---
+
+## 🔄 Remaining Backend Work
+
+### MCP Tool Integration
+
+The `shopify_search_custom_content` tool has been added to `mcp-shopify`. The same needs to be done for `mcp-woocommerce` and `mcp-shoprenter`.
+
+#### For `mcp-woocommerce/index.ts`:
+
+**1. Update imports** (around line 40):
+```typescript
+import {
+  getStoreQdrantConfig,
+  queryQdrantProducts,
+  queryQdrantOrders,
+  queryQdrantCustomers,
+  queryQdrantCustomContent  // ADD THIS LINE
+} from '../_shared/mcp-qdrant-helpers.ts';
+```
+
+**2. Add tool definition to TOOLS array** (before closing `]`):
+```typescript
+  {
+    name: 'woocommerce_search_custom_content',
+    description: 'Search custom knowledge base (uploaded PDFs and text entries) for relevant information using semantic search. Returns matching content with titles, excerpts, and relevance scores. Use this to find custom documentation, policies, FAQs, or any store-specific information that has been uploaded.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        shop_id: {
+          type: 'string',
+          description: 'The UUID of the WooCommerce store from the stores table'
+        },
+        query: {
+          type: 'string',
+          description: 'The search query to find relevant content'
+        },
+        limit: {
+          type: 'number',
+          description: 'Number of results to return (default: 5, max: 10)'
+        }
+      },
+      required: ['shop_id', 'query']
+    }
+  }
+```
+
+**3. Add handler function** (before `handleToolCall` function):
+```typescript
+/**
+ * Handle custom content search
+ */
+async function handleSearchCustomContent(args: Record<string, any>): Promise<ToolCallResult> {
+  const { shop_id, query, limit = 5 } = args;
+
+  try {
+    // Get store config for Qdrant connection
+    const qdrantConfig = await getStoreQdrantConfig(shop_id);
+
+    if (!qdrantConfig) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            error: 'Store configuration not found'
+          })
+        }],
+        isError: true
+      };
+    }
+
+    // Query custom content from Qdrant
+    const results = await queryQdrantCustomContent(
+      shop_id,
+      qdrantConfig.shopname,
+      query,
+      Math.min(limit, 10) // Max 10 results
+    );
+
+    if (results.length === 0) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            message: 'No custom content found matching your query',
+            results: []
+          })
+        }],
+        isError: false
+      };
+    }
+
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({
+          results: results.map(r => ({
+            id: r.id,
+            title: r.title,
+            content_type: r.contentType,
+            excerpt: r.excerpt,
+            chunk_info: `Chunk ${r.chunkIndex + 1} of ${r.totalChunks}`,
+            relevance_score: Math.round(r.relevanceScore * 100) / 100,
+            original_filename: r.originalFilename,
+            page_count: r.pageCount
+          })),
+          total: results.length
+        })
+      }],
+      isError: false
+    };
+  } catch (error: any) {
+    console.error('[MCP WooCommerce] Error searching custom content:', error);
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({ error: `Failed to search custom content: ${error instanceof Error ? error.message : 'Unknown error'}` })
+      }],
+      isError: true
+    };
+  }
+}
+```
+
+**4. Add case to switch statement** (in `handleToolCall`, before default case):
+```typescript
+    case 'woocommerce_search_custom_content':
+      const customContentValidation = validateParams(args, ['shop_id', 'query']);
+      if (!customContentValidation.valid) {
+        return {
+          content: [{
+            type: 'text',
+            text: JSON.stringify({
+              error: `Missing required parameters: ${customContentValidation.missing?.join(', ')}`
+            })
+          }],
+          isError: true
+        };
+      }
+      return await handleSearchCustomContent(args);
+```
+
+#### For `mcp-shoprenter/index.ts`:
+
+Apply the **exact same changes** as WooCommerce, but replace:
+- `woocommerce_search_custom_content` → `shoprenter_search_custom_content`
+- `'WooCommerce store'` → `'ShopRenter store'`
+- `'[MCP WooCommerce]'` → `'[MCP ShopRenter]'`
+
+#### Deploy MCP Changes:
+```bash
+cd /data/shopcall/supabase
+npx supabase functions deploy mcp-shopify mcp-woocommerce mcp-shoprenter --project-ref ztklqodcdjeqpsvhlpud
+```
+
+---
+
+## 📱 Frontend Implementation Guide
+
+### Overview
+Create a new page `/custom-content` under the AI Configuration menu where users can:
+- Upload PDF files (with duplicate detection)
+- Create text entries
+- View all custom content with sync status
+- Delete content
+- See real-time processing status
+
+### File Structure
+```
+shopcall.ai-main/src/
+├── pages/
+│   └── CustomContent.tsx (main page)
+├── components/
+│   ├── CustomContentManager.tsx (container component)
+│   ├── CustomContentUpload.tsx (file upload UI)
+│   ├── CustomContentTextEntry.tsx (text entry form)
+│   ├── CustomContentList.tsx (content table/list)
+│   └── CustomContentStatusBadge.tsx (status indicator)
+└── App.tsx (add route)
+```
+
+### Step 1: Create Main Page Component
+
+**File**: `shopcall.ai-main/src/pages/CustomContent.tsx`
+
+```typescript
+import { CustomContentManager } from "@/components/CustomContentManager";
+
+export default function CustomContent() {
+  return (
+    <div className="flex flex-col gap-6 p-6">
+      <div>
+        <h1 className="text-3xl font-bold">Custom Content</h1>
+        <p className="text-muted-foreground mt-2">
+          Upload PDFs and create text entries for your AI assistant's knowledge base
+        </p>
+      </div>
+      <CustomContentManager />
+    </div>
+  );
+}
+```
+
+### Step 2: Create Manager Component
+
+**File**: `shopcall.ai-main/src/components/CustomContentManager.tsx`
+
+```typescript
+import { useState } from "react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { FileUp, FileText, Plus } from "lucide-react";
+import { CustomContentUpload } from "./CustomContentUpload";
+import { CustomContentTextEntry } from "./CustomContentTextEntry";
+import { CustomContentList } from "./CustomContentList";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+
+export function CustomContentManager() {
+  const [showUpload, setShowUpload] = useState(false);
+  const [showTextEntry, setShowTextEntry] = useState(false);
+  const [activeTab, setActiveTab] = useState<string>("all");
+  const [refreshKey, setRefreshKey] = useState(0);
+
+  const handleUploadSuccess = () => {
+    setShowUpload(false);
+    setRefreshKey(prev => prev + 1); // Trigger list refresh
+  };
+
+  const handleTextEntrySuccess = () => {
+    setShowTextEntry(false);
+    setRefreshKey(prev => prev + 1); // Trigger list refresh
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Action Buttons */}
+      <div className="flex gap-2">
+        <Dialog open={showUpload} onOpenChange={setShowUpload}>
+          <DialogTrigger asChild>
+            <Button>
+              <FileUp className="mr-2 h-4 w-4" />
+              Upload PDF
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-2xl">
+            <CustomContentUpload onSuccess={handleUploadSuccess} />
+          </DialogContent>
+        </Dialog>
+
+        <Dialog open={showTextEntry} onOpenChange={setShowTextEntry}>
+          <DialogTrigger asChild>
+            <Button variant="outline">
+              <Plus className="mr-2 h-4 w-4" />
+              Add Text Entry
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-2xl">
+            <CustomContentTextEntry onSuccess={handleTextEntrySuccess} />
+          </DialogContent>
+        </Dialog>
+      </div>
+
+      {/* Tabs */}
+      <Tabs value={activeTab} onValueChange={setActiveTab}>
+        <TabsList>
+          <TabsTrigger value="all">All Content</TabsTrigger>
+          <TabsTrigger value="pdf_upload">PDFs</TabsTrigger>
+          <TabsTrigger value="text_entry">Text Entries</TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="all" className="mt-4">
+          <CustomContentList
+            contentType={undefined}
+            refreshKey={refreshKey}
+            onDelete={() => setRefreshKey(prev => prev + 1)}
+          />
+        </TabsContent>
+
+        <TabsContent value="pdf_upload" className="mt-4">
+          <CustomContentList
+            contentType="pdf_upload"
+            refreshKey={refreshKey}
+            onDelete={() => setRefreshKey(prev => prev + 1)}
+          />
+        </TabsContent>
+
+        <TabsContent value="text_entry" className="mt-4">
+          <CustomContentList
+            contentType="text_entry"
+            refreshKey={refreshKey}
+            onDelete={() => setRefreshKey(prev => prev + 1)}
+          />
+        </TabsContent>
+      </Tabs>
+    </div>
+  );
+}
+```
+
+### Step 3: Create File Upload Component
+
+**File**: `shopcall.ai-main/src/components/CustomContentUpload.tsx`
+
+```typescript
+import { useState, useCallback } from "react";
+import { useDropzone } from "react-dropzone";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Progress } from "@/components/ui/progress";
+import { Upload, FileText, AlertCircle, CheckCircle2 } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+
+interface CustomContentUploadProps {
+  onSuccess: () => void;
+}
+
+export function CustomContentUpload({ onSuccess }: CustomContentUploadProps) {
+  const [file, setFile] = useState<File | null>(null);
+  const [title, setTitle] = useState("");
+  const [uploading, setUploading] = useState(false);
+  const [progress, setProgress] = useState(0);
+  const [error, setError] = useState<string | null>(null);
+  const { toast } = useToast();
+
+  // Get selected shop from context (implement based on your app's shop selector)
+  const selectedShop = "your-shop-id"; // TODO: Get from context
+
+  const onDrop = useCallback((acceptedFiles: File[]) => {
+    if (acceptedFiles.length > 0) {
+      const uploadedFile = acceptedFiles[0];
+      setFile(uploadedFile);
+      // Auto-fill title with filename (without extension)
+      const fileName = uploadedFile.name.replace(/\\.pdf$/i, "");
+      setTitle(fileName);
+      setError(null);
+    }
+  }, []);
+
+  const { getRootProps, getInputProps, isDragActive } = useDropzone({
+    onDrop,
+    accept: {
+      'application/pdf': ['.pdf']
+    },
+    maxFiles: 1,
+    maxSize: 10 * 1024 * 1024, // 10MB
+  });
+
+  const handleUpload = async () => {
+    if (!file || !title.trim()) {
+      setError("Please select a file and provide a title");
+      return;
+    }
+
+    setUploading(true);
+    setError(null);
+    setProgress(10);
+
+    try {
+      // Create FormData
+      const formData = new FormData();
+      formData.append("file", file);
+      formData.append("title", title.trim());
+      formData.append("store_id", selectedShop);
+
+      setProgress(30);
+
+      // Get auth token
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) {
+        throw new Error("Not authenticated");
+      }
+
+      setProgress(50);
+
+      // Upload to Edge Function
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-upload`,
+        {
+          method: "POST",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+          },
+          body: formData,
+        }
+      );
+
+      setProgress(80);
+
+      const result = await response.json();
+
+      if (!response.ok) {
+        if (result.duplicate) {
+          throw new Error(
+            `This file already exists as "${result.existingContent.title}" (uploaded on ${result.existingContent.uploadedAt})`
+          );
+        }
+        throw new Error(result.error || "Upload failed");
+      }
+
+      setProgress(100);
+
+      toast({
+        title: "Upload successful",
+        description: `${result.fileName} is being processed. This may take a few moments.`,
+      });
+
+      onSuccess();
+    } catch (err: any) {
+      console.error("Upload error:", err);
+      setError(err.message || "Failed to upload file");
+      toast({
+        title: "Upload failed",
+        description: err.message || "An error occurred during upload",
+        variant: "destructive",
+      });
+    } finally {
+      setUploading(false);
+      setProgress(0);
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <div>
+        <h2 className="text-lg font-semibold">Upload PDF</h2>
+        <p className="text-sm text-muted-foreground">
+          Upload a PDF file to add to your AI assistant's knowledge base
+        </p>
+      </div>
+
+      {/* File Dropzone */}
+      <div
+        {...getRootProps()}
+        className={`
+          border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
+          transition-colors
+          ${isDragActive ? "border-primary bg-primary/5" : "border-gray-300"}
+          ${file ? "bg-muted" : ""}
+        `}
+      >
+        <input {...getInputProps()} />
+        {file ? (
+          <div className="flex items-center justify-center gap-2">
+            <FileText className="h-8 w-8 text-primary" />
+            <div className="text-left">
+              <p className="font-medium">{file.name}</p>
+              <p className="text-sm text-muted-foreground">
+                {(file.size / 1024 / 1024).toFixed(2)} MB
+              </p>
+            </div>
+          </div>
+        ) : (
+          <div>
+            <Upload className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
+            <p className="text-sm">
+              {isDragActive
+                ? "Drop the PDF file here"
+                : "Drag and drop a PDF file here, or click to select"}
+            </p>
+            <p className="text-xs text-muted-foreground mt-1">
+              Maximum file size: 10MB
+            </p>
+          </div>
+        )}
+      </div>
+
+      {/* Title Input */}
+      <div className="space-y-2">
+        <Label htmlFor="title">Title *</Label>
+        <Input
+          id="title"
+          value={title}
+          onChange={(e) => setTitle(e.target.value)}
+          placeholder="Enter a descriptive title"
+          disabled={uploading}
+        />
+      </div>
+
+      {/* Progress Bar */}
+      {uploading && (
+        <div className="space-y-2">
+          <Progress value={progress} />
+          <p className="text-sm text-center text-muted-foreground">
+            Uploading... {progress}%
+          </p>
+        </div>
+      )}
+
+      {/* Error Alert */}
+      {error && (
+        <Alert variant="destructive">
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription>{error}</AlertDescription>
+        </Alert>
+      )}
+
+      {/* Action Buttons */}
+      <div className="flex justify-end gap-2">
+        <Button
+          variant="outline"
+          onClick={() => onSuccess()}
+          disabled={uploading}
+        >
+          Cancel
+        </Button>
+        <Button
+          onClick={handleUpload}
+          disabled={!file || !title.trim() || uploading}
+        >
+          {uploading ? "Uploading..." : "Upload"}
+        </Button>
+      </div>
+    </div>
+  );
+}
+```
+
+### Step 4: Create Text Entry Component
+
+**File**: `shopcall.ai-main/src/components/CustomContentTextEntry.tsx`
+
+```typescript
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertCircle } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+
+interface CustomContentTextEntryProps {
+  onSuccess: () => void;
+}
+
+export function CustomContentTextEntry({ onSuccess }: CustomContentTextEntryProps) {
+  const [title, setTitle] = useState("");
+  const [content, setContent] = useState("");
+  const [saving, setSaving] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const { toast } = useToast();
+
+  // Get selected shop from context
+  const selectedShop = "your-shop-id"; // TODO: Get from context
+
+  const handleSave = async () => {
+    if (!title.trim() || !content.trim()) {
+      setError("Please provide both title and content");
+      return;
+    }
+
+    if (content.length > 50000) {
+      setError("Content exceeds maximum length of 50,000 characters");
+      return;
+    }
+
+    setSaving(true);
+    setError(null);
+
+    try {
+      // Get auth token
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) {
+        throw new Error("Not authenticated");
+      }
+
+      // Create entry via Edge Function
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-create`,
+        {
+          method: "POST",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({
+            title: title.trim(),
+            content_text: content.trim(),
+            store_id: selectedShop,
+          }),
+        }
+      );
+
+      const result = await response.json();
+
+      if (!response.ok) {
+        throw new Error(result.error || "Failed to create entry");
+      }
+
+      toast({
+        title: "Entry created",
+        description: `"${title}" has been added to your knowledge base`,
+      });
+
+      onSuccess();
+    } catch (err: any) {
+      console.error("Create error:", err);
+      setError(err.message || "Failed to create entry");
+      toast({
+        title: "Creation failed",
+        description: err.message || "An error occurred",
+        variant: "destructive",
+      });
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <div>
+        <h2 className="text-lg font-semibold">Add Text Entry</h2>
+        <p className="text-sm text-muted-foreground">
+          Create a custom text entry for your AI assistant's knowledge base
+        </p>
+      </div>
+
+      {/* Title Input */}
+      <div className="space-y-2">
+        <Label htmlFor="entry-title">Title *</Label>
+        <Input
+          id="entry-title"
+          value={title}
+          onChange={(e) => setTitle(e.target.value)}
+          placeholder="Enter a title"
+          disabled={saving}
+        />
+      </div>
+
+      {/* Content Textarea */}
+      <div className="space-y-2">
+        <Label htmlFor="entry-content">Content *</Label>
+        <Textarea
+          id="entry-content"
+          value={content}
+          onChange={(e) => setContent(e.target.value)}
+          placeholder="Enter your content here..."
+          rows={12}
+          disabled={saving}
+          className="resize-none"
+        />
+        <p className="text-xs text-muted-foreground text-right">
+          {content.length.toLocaleString()} / 50,000 characters
+        </p>
+      </div>
+
+      {/* Error Alert */}
+      {error && (
+        <Alert variant="destructive">
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription>{error}</AlertDescription>
+        </Alert>
+      )}
+
+      {/* Action Buttons */}
+      <div className="flex justify-end gap-2">
+        <Button
+          variant="outline"
+          onClick={() => onSuccess()}
+          disabled={saving}
+        >
+          Cancel
+        </Button>
+        <Button
+          onClick={handleSave}
+          disabled={!title.trim() || !content.trim() || saving}
+        >
+          {saving ? "Saving..." : "Save"}
+        </Button>
+      </div>
+    </div>
+  );
+}
+```
+
+### Step 5: Create Content List Component
+
+**File**: `shopcall.ai-main/src/components/CustomContentList.tsx`
+
+```typescript
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Trash2, FileText, File } from "lucide-react";
+import { CustomContentStatusBadge } from "./CustomContentStatusBadge";
+import { useToast } from "@/hooks/use-toast";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+interface CustomContentListProps {
+  contentType?: "text_entry" | "pdf_upload";
+  refreshKey: number;
+  onDelete: () => void;
+}
+
+interface ContentItem {
+  id: string;
+  title: string;
+  contentType: string;
+  originalFilename?: string;
+  fileSize?: number;
+  pageCount?: number;
+  chunkCount: number;
+  syncStatus: string;
+  syncError?: string;
+  createdAt: string;
+}
+
+export function CustomContentList({
+  contentType,
+  refreshKey,
+  onDelete
+}: CustomContentListProps) {
+  const [deleteId, setDeleteId] = useState<string | null>(null);
+  const { toast } = useToast();
+
+  // Get selected shop from context
+  const selectedShop = "your-shop-id"; // TODO: Get from context
+
+  // Fetch content list
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ["custom-content", selectedShop, contentType, refreshKey],
+    queryFn: async () => {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      const params = new URLSearchParams({
+        store_id: selectedShop,
+        ...(contentType && { content_type: contentType }),
+      });
+
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-list?${params}`,
+        {
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+          },
+        }
+      );
+
+      if (!response.ok) {
+        throw new Error("Failed to fetch content");
+      }
+
+      const result = await response.json();
+      return result.content as ContentItem[];
+    },
+    enabled: !!selectedShop,
+  });
+
+  // Refetch on refreshKey change
+  useEffect(() => {
+    if (refreshKey > 0) {
+      refetch();
+    }
+  }, [refreshKey, refetch]);
+
+  const handleDelete = async (id: string) => {
+    try {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-delete`,
+        {
+          method: "DELETE",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({
+            content_id: id,
+            store_id: selectedShop,
+          }),
+        }
+      );
+
+      if (!response.ok) {
+        throw new Error("Failed to delete content");
+      }
+
+      toast({
+        title: "Content deleted",
+        description: "The content has been removed from your knowledge base",
+      });
+
+      onDelete();
+    } catch (err: any) {
+      toast({
+        title: "Deletion failed",
+        description: err.message || "An error occurred",
+        variant: "destructive",
+      });
+    } finally {
+      setDeleteId(null);
+    }
+  };
+
+  if (isLoading) {
+    return <div className="text-center py-8">Loading...</div>;
+  }
+
+  if (!data || data.length === 0) {
+    return (
+      <div className="text-center py-8 text-muted-foreground">
+        No custom content yet. Upload a PDF or create a text entry to get started.
+      </div>
+    );
+  }
+
+  return (
+    <>
+      <Table>
+        <TableHeader>
+          <TableRow>
+            <TableHead>Type</TableHead>
+            <TableHead>Title</TableHead>
+            <TableHead>Details</TableHead>
+            <TableHead>Status</TableHead>
+            <TableHead>Chunks</TableHead>
+            <TableHead>Created</TableHead>
+            <TableHead className="text-right">Actions</TableHead>
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {data.map((item) => (
+            <TableRow key={item.id}>
+              <TableCell>
+                {item.contentType === "pdf_upload" ? (
+                  <File className="h-4 w-4 text-red-500" />
+                ) : (
+                  <FileText className="h-4 w-4 text-blue-500" />
+                )}
+              </TableCell>
+              <TableCell className="font-medium">{item.title}</TableCell>
+              <TableCell className="text-sm text-muted-foreground">
+                {item.contentType === "pdf_upload" ? (
+                  <div>
+                    <div>{item.originalFilename}</div>
+                    <div>
+                      {item.pageCount} pages • {(item.fileSize! / 1024).toFixed(0)} KB
+                    </div>
+                  </div>
+                ) : (
+                  <div>Text entry</div>
+                )}
+              </TableCell>
+              <TableCell>
+                <CustomContentStatusBadge
+                  contentId={item.id}
+                  status={item.syncStatus}
+                  error={item.syncError}
+                />
+              </TableCell>
+              <TableCell>{item.chunkCount || 0}</TableCell>
+              <TableCell>
+                {new Date(item.createdAt).toLocaleDateString()}
+              </TableCell>
+              <TableCell className="text-right">
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  onClick={() => setDeleteId(item.id)}
+                >
+                  <Trash2 className="h-4 w-4" />
+                </Button>
+              </TableCell>
+            </TableRow>
+          ))}
+        </TableBody>
+      </Table>
+
+      {/* Delete Confirmation Dialog */}
+      <AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>Delete content?</AlertDialogTitle>
+            <AlertDialogDescription>
+              This will permanently remove the content from your knowledge base and cannot be undone.
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>Cancel</AlertDialogCancel>
+            <AlertDialogAction
+              onClick={() => deleteId && handleDelete(deleteId)}
+              className="bg-destructive text-destructive-foreground"
+            >
+              Delete
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+    </>
+  );
+}
+```
+
+### Step 6: Create Status Badge Component
+
+**File**: `shopcall.ai-main/src/components/CustomContentStatusBadge.tsx`
+
+```typescript
+import { useEffect, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+interface CustomContentStatusBadgeProps {
+  contentId: string;
+  status: string;
+  error?: string;
+}
+
+export function CustomContentStatusBadge({
+  contentId,
+  status: initialStatus,
+  error,
+}: CustomContentStatusBadgeProps) {
+  const [status, setStatus] = useState(initialStatus);
+  const [polling, setPolling] = useState(
+    initialStatus === "pending" || initialStatus === "processing"
+  );
+
+  useEffect(() => {
+    if (!polling) return;
+
+    const pollStatus = async () => {
+      try {
+        const { data: { session } } = await supabase.auth.getSession();
+        if (!session) return;
+
+        const response = await fetch(
+          `${import.meta.env.VITE_API_URL}/custom-content-sync-status?content_id=${contentId}`,
+          {
+            headers: {
+              Authorization: `Bearer ${session.access_token}`,
+            },
+          }
+        );
+
+        if (response.ok) {
+          const result = await response.json();
+          setStatus(result.status);
+
+          // Stop polling if completed or failed
+          if (result.status === "completed" || result.status === "failed") {
+            setPolling(false);
+          }
+        }
+      } catch (err) {
+        console.error("Status poll error:", err);
+      }
+    };
+
+    // Poll every 3 seconds
+    const interval = setInterval(pollStatus, 3000);
+    pollStatus(); // Initial poll
+
+    return () => clearInterval(interval);
+  }, [contentId, polling]);
+
+  const getStatusDisplay = () => {
+    switch (status) {
+      case "pending":
+        return {
+          label: "Pending",
+          icon: <Clock className="h-3 w-3" />,
+          variant: "secondary" as const,
+          tooltip: "Waiting to start processing",
+        };
+      case "processing":
+        return {
+          label: "Processing",
+          icon: <Loader2 className="h-3 w-3 animate-spin" />,
+          variant: "default" as const,
+          tooltip: "Extracting text and generating embeddings",
+        };
+      case "completed":
+        return {
+          label: "Ready",
+          icon: <CheckCircle2 className="h-3 w-3" />,
+          variant: "success" as const,
+          tooltip: "Successfully added to knowledge base",
+        };
+      case "failed":
+        return {
+          label: "Failed",
+          icon: <AlertCircle className="h-3 w-3" />,
+          variant: "destructive" as const,
+          tooltip: error || "Processing failed",
+        };
+      default:
+        return {
+          label: status,
+          icon: null,
+          variant: "secondary" as const,
+          tooltip: "",
+        };
+    }
+  };
+
+  const statusDisplay = getStatusDisplay();
+
+  return (
+    <TooltipProvider>
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <Badge variant={statusDisplay.variant} className="gap-1">
+            {statusDisplay.icon}
+            {statusDisplay.label}
+          </Badge>
+        </TooltipTrigger>
+        <TooltipContent>
+          <p>{statusDisplay.tooltip}</p>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  );
+}
+```
+
+### Step 7: Update Navigation
+
+**File**: `shopcall.ai-main/src/components/ui/app-sidebar.tsx`
+
+Find the AI Configuration menu items and add:
+
+```typescript
+{
+  title: "Custom Content",
+  url: "/custom-content",
+  icon: Database, // or FileText
+},
+```
+
+**File**: `shopcall.ai-main/src/App.tsx`
+
+Add the route inside the PrivateRoute wrapper:
+
+```typescript
+<Route path="/custom-content" element={<CustomContent />} />
+```
+
+---
+
+## 🧪 Testing Checklist
+
+### Backend Testing:
+- [ ] Run migration and verify `custom_content` table exists
+- [ ] Verify Storage bucket `custom-content` is created
+- [ ] Test PDF upload endpoint (duplicate detection)
+- [ ] Test text entry creation endpoint
+- [ ] Test list endpoint with filters
+- [ ] Test delete endpoint (verify Qdrant + Storage cleanup)
+- [ ] Test MCP tool: `shopify_search_custom_content`
+- [ ] Test MCP tool: `woocommerce_search_custom_content`
+- [ ] Test MCP tool: `shoprenter_search_custom_content`
+
+### Frontend Testing:
+- [ ] Navigate to `/custom-content` page
+- [ ] Upload small PDF (<1MB)
+- [ ] Upload large PDF (>5MB)
+- [ ] Test duplicate file upload (should reject)
+- [ ] Create text entry
+- [ ] View all content (tabs work)
+- [ ] Watch real-time status updates
+- [ ] Delete content (confirm dialog)
+- [ ] Verify content appears in AI assistant responses
+
+---
+
+## 📚 API Endpoints Reference
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/custom-content-upload` | POST | Upload PDF file |
+| `/custom-content-process` | POST | Process PDF (internal) |
+| `/custom-content-create` | POST | Create text entry |
+| `/custom-content-list` | GET | List all content |
+| `/custom-content-delete` | DELETE | Delete content |
+| `/custom-content-sync-status` | GET | Get sync status |
+
+All endpoints require `Authorization: Bearer <token>` header.
+
+---
+
+## 🔍 Troubleshooting
+
+### PDF Processing Fails
+- Check Edge Function logs: `npx supabase functions logs custom-content-process`
+- Verify `OPENROUTER_API_KEY` is set in Supabase secrets
+- Check PDF is valid (not corrupted or password-protected)
+
+### Qdrant Sync Fails
+- Verify Qdrant URL and API key in `stores` table or environment
+- Check Qdrant collection exists: `{shopname}-custom-content`
+- Review Qdrant logs for connection errors
+
+### Upload Returns 413 Error
+- File exceeds 10MB limit
+- Adjust `file_size_limit` in Storage bucket settings
+
+### Status Never Updates
+- Frontend not polling (check interval is running)
+- Edge Function crashed during processing
+- Check sync status endpoint returns data
+
+---
+
+## 📊 Database Queries for Debugging
+
+```sql
+-- View all custom content
+SELECT * FROM custom_content ORDER BY created_at DESC;
+
+-- View content by status
+SELECT title, content_type, sync_status, created_at
+FROM custom_content
+WHERE sync_status = 'failed';
+
+-- Get content statistics
+SELECT * FROM get_custom_content_stats('store-uuid-here');
+
+-- View storage files
+SELECT * FROM storage.objects
+WHERE bucket_id = 'custom-content';
+```
+
+---
+
+## 🎉 Summary
+
+Once completed, users will be able to:
+1. ✅ Upload PDF files with automatic deduplication
+2. ✅ Create text entries for custom knowledge
+3. ✅ View all content with real-time sync status
+4. ✅ Delete content (hard delete from all systems)
+5. ✅ AI assistants can search this content via MCP tools
+
+The backend is **fully deployed and operational**. The frontend implementation should take approximately 4-6 hours for an experienced React developer.

+ 6 - 0
shopcall.ai-main/package.json

@@ -42,6 +42,10 @@
     "@radix-ui/react-tooltip": "^1.1.4",
     "@radix-ui/react-tooltip": "^1.1.4",
     "@supabase/supabase-js": "^2.84.0",
     "@supabase/supabase-js": "^2.84.0",
     "@tanstack/react-query": "^5.56.2",
     "@tanstack/react-query": "^5.56.2",
+    "@tiptap/extension-placeholder": "^3.11.0",
+    "@tiptap/react": "^3.11.0",
+    "@tiptap/starter-kit": "^3.11.0",
+    "@types/turndown": "^5.0.6",
     "class-variance-authority": "^0.7.1",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "clsx": "^2.1.1",
     "cmdk": "^1.0.0",
     "cmdk": "^1.0.0",
@@ -55,6 +59,7 @@
     "react": "^18.3.1",
     "react": "^18.3.1",
     "react-day-picker": "^8.10.1",
     "react-day-picker": "^8.10.1",
     "react-dom": "^18.3.1",
     "react-dom": "^18.3.1",
+    "react-dropzone": "^14.3.8",
     "react-hook-form": "^7.53.0",
     "react-hook-form": "^7.53.0",
     "react-i18next": "^16.2.4",
     "react-i18next": "^16.2.4",
     "react-markdown": "^10.1.0",
     "react-markdown": "^10.1.0",
@@ -64,6 +69,7 @@
     "sonner": "^1.5.0",
     "sonner": "^1.5.0",
     "tailwind-merge": "^2.5.2",
     "tailwind-merge": "^2.5.2",
     "tailwindcss-animate": "^1.0.7",
     "tailwindcss-animate": "^1.0.7",
+    "turndown": "^7.2.2",
     "vaul": "^0.9.3",
     "vaul": "^0.9.3",
     "zod": "^3.23.8"
     "zod": "^3.23.8"
   },
   },

+ 2 - 0
shopcall.ai-main/src/App.tsx

@@ -37,6 +37,7 @@ const ShopRenterIntegration = lazy(() => import("./pages/ShopRenterIntegration")
 const IntegrationsRedirect = lazy(() => import("./pages/IntegrationsRedirect"));
 const IntegrationsRedirect = lazy(() => import("./pages/IntegrationsRedirect"));
 const Products = lazy(() => import("./pages/Products"));
 const Products = lazy(() => import("./pages/Products"));
 const WebsiteContent = lazy(() => import("./pages/WebsiteContent"));
 const WebsiteContent = lazy(() => import("./pages/WebsiteContent"));
+const CustomContent = lazy(() => import("./pages/CustomContent"));
 
 
 // Loading component for lazy-loaded routes
 // Loading component for lazy-loaded routes
 const PageLoader = () => (
 const PageLoader = () => (
@@ -76,6 +77,7 @@ const App = () => (
                 <Route path="/ai-config" element={<AIConfig />} />
                 <Route path="/ai-config" element={<AIConfig />} />
                 <Route path="/products" element={<Products />} />
                 <Route path="/products" element={<Products />} />
                 <Route path="/website-content" element={<WebsiteContent />} />
                 <Route path="/website-content" element={<WebsiteContent />} />
+                <Route path="/custom-content" element={<CustomContent />} />
                 <Route path="/manage-store-data" element={<ManageStoreData />} />
                 <Route path="/manage-store-data" element={<ManageStoreData />} />
                 <Route path="/api-keys" element={<APIKeys />} />
                 <Route path="/api-keys" element={<APIKeys />} />
                 <Route path="/onboarding" element={<Onboarding />} />
                 <Route path="/onboarding" element={<Onboarding />} />

+ 24 - 2
shopcall.ai-main/src/components/AppSidebar.tsx

@@ -15,7 +15,7 @@ import {
   SidebarFooter,
   SidebarFooter,
 } from "@/components/ui/sidebar";
 } from "@/components/ui/sidebar";
 import { useNavigate, useSearchParams } from "react-router-dom";
 import { useNavigate, useSearchParams } from "react-router-dom";
-import { LayoutDashboard, Phone, BarChart3, Settings, CreditCard, Layers3, PhoneCall, LogOut, Brain, Database, Store, ChevronDown, Package, Globe } from "lucide-react";
+import { LayoutDashboard, Phone, BarChart3, Settings, CreditCard, Layers3, PhoneCall, LogOut, Brain, Database, Store, ChevronDown, Package, Globe, FileText } from "lucide-react";
 import { useAuth } from "@/components/context/AuthContext";
 import { useAuth } from "@/components/context/AuthContext";
 import { useShop } from "@/components/context/ShopContext";
 import { useShop } from "@/components/context/ShopContext";
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -32,7 +32,7 @@ export function AppSidebar() {
   const { logout } = useAuth();
   const { logout } = useAuth();
   const { selectedShop, setSelectedShop, stores, setStores } = useShop();
   const { selectedShop, setSelectedShop, stores, setStores } = useShop();
   // Keep AI menu open when on any AI submenu page
   // Keep AI menu open when on any AI submenu page
-  const aiMenuPaths = ['/ai-config', '/products', '/website-content'];
+  const aiMenuPaths = ['/ai-config', '/products', '/website-content', '/custom-content'];
   const [isAIMenuOpen, setIsAIMenuOpen] = useState(aiMenuPaths.includes(currentPath));
   const [isAIMenuOpen, setIsAIMenuOpen] = useState(aiMenuPaths.includes(currentPath));
 
 
   // Update AI menu state when path changes
   // Update AI menu state when path changes
@@ -260,6 +260,28 @@ export function AppSidebar() {
                           </a>
                           </a>
                         </SidebarMenuSubButton>
                         </SidebarMenuSubButton>
                       </SidebarMenuSubItem>
                       </SidebarMenuSubItem>
+                      <SidebarMenuSubItem>
+                        <SidebarMenuSubButton
+                          asChild
+                          className={`text-slate-300 hover:text-white hover:bg-slate-800/50 ${
+                            currentPath === '/custom-content' ? 'bg-cyan-500/20 text-cyan-400' : ''
+                          }`}
+                        >
+                          <a
+                            onClick={() => {
+                              if (selectedShop) {
+                                navigate(`/custom-content?shop=${selectedShop.id}`);
+                              } else {
+                                navigate('/custom-content');
+                              }
+                            }}
+                            className="flex items-center gap-2 cursor-pointer"
+                          >
+                            <FileText className="w-3 h-3" />
+                            <span>{t('sidebar.customContent')}</span>
+                          </a>
+                        </SidebarMenuSubButton>
+                      </SidebarMenuSubItem>
                     </SidebarMenuSub>
                     </SidebarMenuSub>
                   </CollapsibleContent>
                   </CollapsibleContent>
                 </SidebarMenuItem>
                 </SidebarMenuItem>

+ 345 - 0
shopcall.ai-main/src/components/CustomContentList.tsx

@@ -0,0 +1,345 @@
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Trash2, FileText, File, Eye, RefreshCw, AlertTriangle } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { CustomContentStatusBadge } from "./CustomContentStatusBadge";
+import { CustomContentViewer } from "./CustomContentViewer";
+import { useToast } from "@/hooks/use-toast";
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { supabase } from "@/lib/supabase";
+import { useShop } from "@/components/context/ShopContext";
+import { useTranslation } from 'react-i18next';
+
+interface CustomContentListProps {
+  contentType?: "text_entry" | "pdf_upload";
+  refreshKey: number;
+  onDelete: () => void;
+}
+
+interface ContentItem {
+  id: string;
+  title: string;
+  contentType: string;
+  originalFilename?: string;
+  fileSize?: number;
+  pageCount?: number;
+  chunkCount: number;
+  syncStatus: string;
+  syncError?: string;
+  createdAt: string;
+}
+
+export function CustomContentList({
+  contentType,
+  refreshKey,
+  onDelete
+}: CustomContentListProps) {
+  const { t } = useTranslation();
+  const [deleteId, setDeleteId] = useState<string | null>(null);
+  const [viewContentId, setViewContentId] = useState<string | null>(null);
+  const [retryingId, setRetryingId] = useState<string | null>(null);
+  const { toast } = useToast();
+  const { selectedShop } = useShop();
+
+  // Fetch content list
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ["custom-content", selectedShop?.id, contentType, refreshKey],
+    queryFn: async () => {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      if (!selectedShop) throw new Error("No shop selected");
+
+      const params = new URLSearchParams({
+        store_id: selectedShop.id,
+        ...(contentType && { content_type: contentType }),
+      });
+
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-list?${params}`,
+        {
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+          },
+        }
+      );
+
+      if (!response.ok) {
+        throw new Error("Failed to fetch content");
+      }
+
+      const result = await response.json();
+      return result.content as ContentItem[];
+    },
+    enabled: !!selectedShop,
+  });
+
+  // Refetch on refreshKey change
+  useEffect(() => {
+    if (refreshKey > 0) {
+      refetch();
+    }
+  }, [refreshKey, refetch]);
+
+  const handleRetry = async (id: string) => {
+    if (!selectedShop) return;
+
+    setRetryingId(id);
+    try {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-retry`,
+        {
+          method: "POST",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({
+            content_id: id,
+            store_id: selectedShop.id,
+          }),
+        }
+      );
+
+      if (!response.ok) {
+        const errorData = await response.json();
+        throw new Error(errorData.error || "Failed to retry processing");
+      }
+
+      toast({
+        title: t('customContent.list.retry.successTitle'),
+        description: t('customContent.list.retry.successDescription'),
+      });
+
+      // Refetch list to update status
+      refetch();
+    } catch (err: any) {
+      toast({
+        title: t('customContent.list.retry.errorTitle'),
+        description: err.message,
+        variant: "destructive",
+      });
+    } finally {
+      setRetryingId(null);
+    }
+  };
+
+  const handleDelete = async (id: string) => {
+    if (!selectedShop) return;
+
+    try {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      console.log('[Delete] Attempting to delete content:', id);
+      console.log('[Delete] API URL:', import.meta.env.VITE_API_URL);
+
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-delete`,
+        {
+          method: "DELETE",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({
+            content_id: id,
+            store_id: selectedShop.id,
+          }),
+        }
+      );
+
+      console.log('[Delete] Response status:', response.status);
+
+      if (!response.ok) {
+        const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
+        throw new Error(errorData.error || `HTTP ${response.status}: Failed to delete content`);
+      }
+
+      toast({
+        title: t('customContent.list.delete.successTitle'),
+        description: t('customContent.list.delete.successDescription'),
+      });
+
+      onDelete();
+    } catch (err: any) {
+      console.error('[Delete] Error:', err);
+
+      // Provide more specific error messages
+      let errorMessage = err.message;
+      if (err.message === 'Failed to fetch') {
+        errorMessage = 'Network error. Please check your connection and try again.';
+      }
+
+      toast({
+        title: t('customContent.list.delete.errorTitle'),
+        description: errorMessage,
+        variant: "destructive",
+      });
+    } finally {
+      setDeleteId(null);
+    }
+  };
+
+  if (isLoading) {
+    return <div className="text-center py-8 text-slate-400">{t('customContent.list.loading')}</div>;
+  }
+
+  if (!data || data.length === 0) {
+    return (
+      <div className="text-center py-8 text-slate-400">
+        {t('customContent.list.noContent')}
+      </div>
+    );
+  }
+
+  // Check for failed items
+  const failedItems = data.filter(item => item.syncStatus === 'failed');
+  const hasFailed = failedItems.length > 0;
+
+  return (
+    <>
+      {/* Warning for failed processing */}
+      {hasFailed && (
+        <Alert variant="destructive" className="mb-4 bg-red-900/20 border-red-800">
+          <AlertTriangle className="h-4 w-4" />
+          <AlertTitle className="text-red-400">
+            {t('customContent.list.failureWarning.title')}
+          </AlertTitle>
+          <AlertDescription className="text-red-300">
+            {t('customContent.list.failureWarning.description')}
+          </AlertDescription>
+        </Alert>
+      )}
+
+      <div className="bg-slate-800 rounded-lg overflow-hidden border border-slate-700">
+        <Table>
+          <TableHeader>
+            <TableRow className="border-slate-700 hover:bg-slate-700/50">
+              <TableHead className="text-slate-300">{t('customContent.list.table.type')}</TableHead>
+              <TableHead className="text-slate-300">{t('customContent.list.table.title')}</TableHead>
+              <TableHead className="text-slate-300">{t('customContent.list.table.details')}</TableHead>
+              <TableHead className="text-slate-300">{t('customContent.list.table.status')}</TableHead>
+              <TableHead className="text-slate-300">{t('customContent.list.table.chunks')}</TableHead>
+              <TableHead className="text-slate-300">{t('customContent.list.table.created')}</TableHead>
+              <TableHead className="text-right text-slate-300">{t('customContent.list.table.actions')}</TableHead>
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {data.map((item) => (
+              <TableRow key={item.id} className="border-slate-700 hover:bg-slate-700/30">
+                <TableCell>
+                  {item.contentType === "pdf_upload" ? (
+                    <File className="h-4 w-4 text-red-400" />
+                  ) : (
+                    <FileText className="h-4 w-4 text-blue-400" />
+                  )}
+                </TableCell>
+                <TableCell className="font-medium text-white">{item.title}</TableCell>
+                <TableCell className="text-sm text-slate-400">
+                  {item.contentType === "pdf_upload" ? (
+                    <div>
+                      <div>{item.originalFilename}</div>
+                      <div>
+                        {item.pageCount} {t('customContent.list.table.pages')} • {((item.fileSize || 0) / 1024).toFixed(0)} KB
+                      </div>
+                    </div>
+                  ) : (
+                    <div>{t('customContent.list.table.textEntry')}</div>
+                  )}
+                </TableCell>
+                <TableCell>
+                  <CustomContentStatusBadge
+                    contentId={item.id}
+                    status={item.syncStatus}
+                    error={item.syncError}
+                  />
+                </TableCell>
+                <TableCell className="text-slate-300">{item.chunkCount || 0}</TableCell>
+                <TableCell className="text-slate-300">
+                  {new Date(item.createdAt).toLocaleDateString()}
+                </TableCell>
+                <TableCell className="text-right">
+                  <div className="flex gap-1 justify-end">
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      onClick={() => setViewContentId(item.id)}
+                      className="text-slate-400 hover:text-cyan-400 hover:bg-slate-700"
+                      title={t('customContent.list.view')}
+                    >
+                      <Eye className="h-4 w-4" />
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="icon"
+                      onClick={() => setDeleteId(item.id)}
+                      className="text-slate-400 hover:text-red-400 hover:bg-slate-700"
+                      title={t('customContent.list.delete.title')}
+                    >
+                      <Trash2 className="h-4 w-4" />
+                    </Button>
+                  </div>
+                </TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      </div>
+
+      {/* Delete Confirmation Dialog */}
+      <AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
+        <AlertDialogContent className="bg-slate-800 border-slate-700">
+          <AlertDialogHeader>
+            <AlertDialogTitle className="text-white">{t('customContent.list.delete.title')}</AlertDialogTitle>
+            <AlertDialogDescription className="text-slate-400">
+              {t('customContent.list.delete.description')}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600">
+              {t('customContent.list.delete.cancel')}
+            </AlertDialogCancel>
+            <AlertDialogAction
+              onClick={() => deleteId && handleDelete(deleteId)}
+              className="bg-red-600 text-white hover:bg-red-700"
+            >
+              {t('customContent.list.delete.confirm')}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
+
+      {/* Content Viewer Dialog */}
+      {viewContentId && selectedShop && (
+        <CustomContentViewer
+          contentId={viewContentId}
+          storeId={selectedShop.id}
+          open={!!viewContentId}
+          onOpenChange={(open) => !open && setViewContentId(null)}
+        />
+      )}
+    </>
+  );
+}

+ 106 - 0
shopcall.ai-main/src/components/CustomContentManager.tsx

@@ -0,0 +1,106 @@
+import { useState } from "react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { FileUp, Plus } from "lucide-react";
+import { CustomContentUpload } from "./CustomContentUpload";
+import { CustomContentTextEntry } from "./CustomContentTextEntry";
+import { CustomContentList } from "./CustomContentList";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+import { useTranslation } from 'react-i18next';
+
+export function CustomContentManager() {
+  const { t } = useTranslation();
+  const [showUpload, setShowUpload] = useState(false);
+  const [showTextEntry, setShowTextEntry] = useState(false);
+  const [activeTab, setActiveTab] = useState<string>("all");
+  const [refreshKey, setRefreshKey] = useState(0);
+
+  const handleUploadSuccess = () => {
+    setShowUpload(false);
+    setRefreshKey(prev => prev + 1); // Trigger list refresh
+  };
+
+  const handleTextEntrySuccess = () => {
+    setShowTextEntry(false);
+    setRefreshKey(prev => prev + 1); // Trigger list refresh
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Action Buttons */}
+      <div className="flex gap-2">
+        <Dialog open={showUpload} onOpenChange={setShowUpload}>
+          <DialogTrigger asChild>
+            <Button className="bg-cyan-500 hover:bg-cyan-600 text-white">
+              <FileUp className="mr-2 h-4 w-4" />
+              {t('customContent.uploadPdf')}
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-2xl">
+            <CustomContentUpload onSuccess={handleUploadSuccess} />
+          </DialogContent>
+        </Dialog>
+
+        <Dialog open={showTextEntry} onOpenChange={setShowTextEntry}>
+          <DialogTrigger asChild>
+            <Button variant="outline" className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600">
+              <Plus className="mr-2 h-4 w-4" />
+              {t('customContent.addTextEntry')}
+            </Button>
+          </DialogTrigger>
+          <DialogContent className="max-w-4xl border-slate-700 bg-slate-900">
+            <CustomContentTextEntry onSuccess={handleTextEntrySuccess} />
+          </DialogContent>
+        </Dialog>
+      </div>
+
+      {/* Tabs */}
+      <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+        <TabsList className="bg-slate-800 border border-slate-700">
+          <TabsTrigger
+            value="all"
+            className="data-[state=active]:bg-slate-700 data-[state=active]:text-white text-slate-400"
+          >
+            {t('customContent.tabs.all')}
+          </TabsTrigger>
+          <TabsTrigger
+            value="pdf_upload"
+            className="data-[state=active]:bg-slate-700 data-[state=active]:text-white text-slate-400"
+          >
+            {t('customContent.tabs.pdfs')}
+          </TabsTrigger>
+          <TabsTrigger
+            value="text_entry"
+            className="data-[state=active]:bg-slate-700 data-[state=active]:text-white text-slate-400"
+          >
+            {t('customContent.tabs.textEntries')}
+          </TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="all" className="mt-4">
+          <CustomContentList
+            contentType={undefined}
+            refreshKey={refreshKey}
+            onDelete={() => setRefreshKey(prev => prev + 1)}
+          />
+        </TabsContent>
+
+        <TabsContent value="pdf_upload" className="mt-4">
+          <CustomContentList
+            contentType="pdf_upload"
+            refreshKey={refreshKey}
+            onDelete={() => setRefreshKey(prev => prev + 1)}
+          />
+        </TabsContent>
+
+        <TabsContent value="text_entry" className="mt-4">
+          <CustomContentList
+            contentType="text_entry"
+            refreshKey={refreshKey}
+            onDelete={() => setRefreshKey(prev => prev + 1)}
+          />
+        </TabsContent>
+      </Tabs>
+    </div>
+  );
+}

+ 127 - 0
shopcall.ai-main/src/components/CustomContentStatusBadge.tsx

@@ -0,0 +1,127 @@
+import { useEffect, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { supabase } from "@/lib/supabase";
+import { useTranslation } from 'react-i18next';
+
+interface CustomContentStatusBadgeProps {
+  contentId: string;
+  status: string;
+  error?: string;
+}
+
+export function CustomContentStatusBadge({
+  contentId,
+  status: initialStatus,
+  error,
+}: CustomContentStatusBadgeProps) {
+  const { t } = useTranslation();
+  const [status, setStatus] = useState(initialStatus);
+  const [polling, setPolling] = useState(
+    initialStatus === "pending" || initialStatus === "processing"
+  );
+
+  useEffect(() => {
+    if (!polling) return;
+
+    const pollStatus = async () => {
+      try {
+        const { data: { session } } = await supabase.auth.getSession();
+        if (!session) return;
+
+        const response = await fetch(
+          `${import.meta.env.VITE_API_URL}/custom-content-sync-status?content_id=${contentId}`,
+          {
+            headers: {
+              Authorization: `Bearer ${session.access_token}`,
+            },
+          }
+        );
+
+        if (response.ok) {
+          const result = await response.json();
+          setStatus(result.status);
+
+          // Stop polling if completed or failed
+          if (result.status === "completed" || result.status === "failed") {
+            setPolling(false);
+          }
+        }
+      } catch (err) {
+        console.error("Status poll error:", err);
+      }
+    };
+
+    // Poll every 3 seconds
+    const interval = setInterval(pollStatus, 3000);
+    pollStatus(); // Initial poll
+
+    return () => clearInterval(interval);
+  }, [contentId, polling]);
+
+  const getStatusDisplay = () => {
+    switch (status) {
+      case "pending":
+        return {
+          label: t('customContent.status.pending'),
+          icon: <Clock className="h-3 w-3" />,
+          variant: "secondary" as const,
+          tooltip: t('customContent.status.tooltip.pending'),
+        };
+      case "processing":
+        return {
+          label: t('customContent.status.processing'),
+          icon: <Loader2 className="h-3 w-3 animate-spin" />,
+          variant: "default" as const,
+          tooltip: t('customContent.status.tooltip.processing'),
+        };
+      case "completed":
+        return {
+          label: t('customContent.status.ready'),
+          icon: <CheckCircle2 className="h-3 w-3" />,
+          variant: "default" as const,
+          tooltip: t('customContent.status.tooltip.ready'),
+        };
+      case "failed":
+        return {
+          label: t('customContent.status.failed'),
+          icon: <AlertCircle className="h-3 w-3" />,
+          variant: "destructive" as const,
+          tooltip: error || t('customContent.status.tooltip.failed'),
+        };
+      default:
+        return {
+          label: status,
+          icon: null,
+          variant: "secondary" as const,
+          tooltip: "",
+        };
+    }
+  };
+
+  const statusDisplay = getStatusDisplay();
+
+  return (
+    <TooltipProvider>
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <span className="inline-flex">
+            <Badge variant={statusDisplay.variant} className="gap-1">
+              {statusDisplay.icon}
+              {statusDisplay.label}
+            </Badge>
+          </span>
+        </TooltipTrigger>
+        <TooltipContent>
+          <p>{statusDisplay.tooltip}</p>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  );
+}

+ 300 - 0
shopcall.ai-main/src/components/CustomContentTextEntry.tsx

@@ -0,0 +1,300 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { AlertCircle } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from "@/lib/supabase";
+import { useShop } from "@/components/context/ShopContext";
+import { useTranslation } from 'react-i18next';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useEditor, EditorContent } from '@tiptap/react';
+import StarterKit from '@tiptap/starter-kit';
+import Placeholder from '@tiptap/extension-placeholder';
+import TurndownService from 'turndown';
+
+interface CustomContentTextEntryProps {
+  onSuccess: () => void;
+}
+
+// URL validation regex (matches http://, https://, www.)
+const URL_REGEX = /https?:\/\/[^\s]+|www\.[^\s]+/gi;
+
+// Image tag regex (matches img tags, markdown images, and common image URLs)
+const IMAGE_REGEX = /<img[^>]*>|\!\[.*?\]\(.*?\)|https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|bmp|svg|webp)/gi;
+
+// Initialize Turndown service for HTML to Markdown conversion
+const turndownService = new TurndownService({
+  headingStyle: 'atx',
+  codeBlockStyle: 'fenced',
+});
+
+// Remove image tags from markdown
+turndownService.remove('img');
+
+// Clean up text: remove excessive whitespace, newlines, tabs
+const cleanupText = (text: string): string => {
+  return text
+    .replace(/\t/g, ' ')              // Replace tabs with spaces
+    .replace(/[ ]+/g, ' ')            // Replace multiple spaces with single space
+    .replace(/\n{3,}/g, '\n\n')       // Replace 3+ newlines with 2 newlines
+    .replace(/^\s+|\s+$/gm, '')       // Trim whitespace from each line
+    .trim();                           // Trim overall
+};
+
+export function CustomContentTextEntry({ onSuccess }: CustomContentTextEntryProps) {
+  const { t } = useTranslation();
+  const [title, setTitle] = useState("");
+  const [content, setContent] = useState("");
+  const [format, setFormat] = useState<'plain' | 'markdown' | 'wysiwyg'>('plain');
+  const [saving, setSaving] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [editorHasContent, setEditorHasContent] = useState(false);
+  const { toast } = useToast();
+  const { selectedShop } = useShop();
+
+  // Initialize TipTap editor for WYSIWYG mode
+  const editor = useEditor({
+    extensions: [
+      StarterKit,
+      Placeholder.configure({
+        placeholder: t('customContent.textEntry.wysiwygPlaceholder'),
+      }),
+    ],
+    content: '',
+    editorProps: {
+      attributes: {
+        class: 'prose prose-invert max-w-full min-h-[288px] p-4 focus:outline-none bg-slate-900 border border-slate-600 rounded-md text-white break-words overflow-wrap-anywhere',
+      },
+    },
+    onUpdate: ({ editor }) => {
+      // Update state when editor content changes
+      const text = editor.getText();
+      const hasText = text.trim().length > 0;
+      setEditorHasContent(hasText);
+    },
+  });
+
+  // Validate content doesn't contain URLs or images
+  const validateContent = (text: string): { isValid: boolean; error?: string } => {
+    if (URL_REGEX.test(text)) {
+      return { isValid: false, error: t('customContent.textEntry.errorContainsUrl') };
+    }
+    if (IMAGE_REGEX.test(text)) {
+      return { isValid: false, error: t('customContent.textEntry.errorContainsImage') };
+    }
+    return { isValid: true };
+  };
+
+  // Get content based on current format
+  const getCurrentContent = (): string => {
+    let rawContent = '';
+
+    if (format === 'wysiwyg' && editor) {
+      const html = editor.getHTML();
+      // Convert HTML to Markdown
+      rawContent = turndownService.turndown(html);
+    } else {
+      rawContent = content;
+    }
+
+    // Clean up the text
+    return cleanupText(rawContent);
+  };
+
+  // Check if content has any text (computed value, not a function)
+  const hasContent = format === 'wysiwyg'
+    ? editorHasContent
+    : content.trim().length > 0;
+
+  const handleSave = async () => {
+    // Get content based on format (already cleaned up)
+    const finalContent = getCurrentContent();
+
+    if (!title.trim() || !finalContent.trim()) {
+      setError(t('customContent.textEntry.errorRequired'));
+      return;
+    }
+
+    // Validate no URLs or images
+    const validation = validateContent(finalContent);
+    if (!validation.isValid) {
+      setError(validation.error || t('customContent.textEntry.errorInvalidContent'));
+      return;
+    }
+
+    if (finalContent.length > 50000) {
+      setError(t('customContent.textEntry.errorMaxLength'));
+      return;
+    }
+
+    if (!selectedShop) {
+      setError(t('customContent.textEntry.errorSelectShop'));
+      return;
+    }
+
+    setSaving(true);
+    setError(null);
+
+    try {
+      // Get auth token
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) {
+        throw new Error("Not authenticated");
+      }
+
+      // Create entry via Edge Function
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-create`,
+        {
+          method: "POST",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+            "Content-Type": "application/json",
+          },
+          body: JSON.stringify({
+            title: title.trim(),
+            content_text: finalContent.trim(),
+            store_id: selectedShop.id,
+          }),
+        }
+      );
+
+      const result = await response.json();
+
+      if (!response.ok) {
+        throw new Error(result.error || t('customContent.textEntry.errorTitle'));
+      }
+
+      toast({
+        title: t('customContent.textEntry.successTitle'),
+        description: t('customContent.textEntry.successDescription', { title }),
+      });
+
+      onSuccess();
+    } catch (err: any) {
+      console.error("Create error:", err);
+      setError(err.message || t('customContent.textEntry.errorTitle'));
+      toast({
+        title: t('customContent.textEntry.errorTitle'),
+        description: err.message || t('customContent.textEntry.errorTitle'),
+        variant: "destructive",
+      });
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  return (
+    <div className="space-y-4">
+      <div>
+        <h2 className="text-lg font-semibold text-white">{t('customContent.textEntry.title')}</h2>
+        <p className="text-sm text-slate-400">
+          {t('customContent.textEntry.description')}
+        </p>
+      </div>
+
+      {/* Title Input */}
+      <div className="space-y-2">
+        <Label htmlFor="entry-title" className="text-slate-300">{t('customContent.textEntry.titleLabel')} *</Label>
+        <Input
+          id="entry-title"
+          value={title}
+          onChange={(e) => setTitle(e.target.value)}
+          placeholder={t('customContent.textEntry.titlePlaceholder')}
+          disabled={saving}
+          className="bg-slate-900 border-slate-600 text-white placeholder:text-slate-500"
+        />
+      </div>
+
+      {/* Content Input with Format Tabs */}
+      <div className="space-y-2">
+        <Label className="text-slate-300">{t('customContent.textEntry.contentLabel')} *</Label>
+
+        <Tabs value={format} onValueChange={(value) => setFormat(value as 'plain' | 'markdown' | 'wysiwyg')} className="w-full">
+          <TabsList className="grid w-full grid-cols-3 bg-slate-700">
+            <TabsTrigger value="plain" className="data-[state=active]:bg-slate-900">
+              {t('customContent.textEntry.formatPlain')}
+            </TabsTrigger>
+            <TabsTrigger value="markdown" className="data-[state=active]:bg-slate-900">
+              {t('customContent.textEntry.formatMarkdown')}
+            </TabsTrigger>
+            <TabsTrigger value="wysiwyg" className="data-[state=active]:bg-slate-900">
+              {t('customContent.textEntry.formatWysiwyg')}
+            </TabsTrigger>
+          </TabsList>
+
+          <TabsContent value="plain" className="mt-2">
+            <Textarea
+              id="entry-content-plain"
+              value={content}
+              onChange={(e) => setContent(e.target.value)}
+              placeholder={t('customContent.textEntry.contentPlaceholder')}
+              rows={12}
+              disabled={saving}
+              className="resize-none bg-slate-900 border-slate-600 text-white placeholder:text-slate-500"
+            />
+          </TabsContent>
+
+          <TabsContent value="markdown" className="mt-2">
+            <Textarea
+              id="entry-content-markdown"
+              value={content}
+              onChange={(e) => setContent(e.target.value)}
+              placeholder={t('customContent.textEntry.markdownPlaceholder')}
+              rows={12}
+              disabled={saving}
+              className="resize-none bg-slate-900 border-slate-600 text-white placeholder:text-slate-500 font-mono"
+            />
+          </TabsContent>
+
+          <TabsContent value="wysiwyg" className="mt-2" forceMount>
+            <div className={format === 'wysiwyg' ? 'block' : 'hidden'}>
+              <div className="min-h-[288px] max-h-[400px] overflow-y-auto">
+                <EditorContent editor={editor} />
+              </div>
+            </div>
+          </TabsContent>
+        </Tabs>
+
+        <p className="text-xs text-slate-500 text-right">
+          {t('customContent.textEntry.characterCount', {
+            count: (title.length + (format === 'wysiwyg' && editor ? editor.getText().length : content.length)).toLocaleString()
+          })}
+        </p>
+        <p className="text-xs text-slate-400">
+          {t('customContent.textEntry.validationNote')}
+        </p>
+      </div>
+
+      {/* Error Alert */}
+      {error && (
+        <Alert variant="destructive" className="bg-red-900/20 border-red-800">
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription className="text-red-200">{error}</AlertDescription>
+        </Alert>
+      )}
+
+      {/* Action Buttons */}
+      <div className="flex justify-end gap-2">
+        <Button
+          variant="outline"
+          onClick={() => onSuccess()}
+          disabled={saving}
+          className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
+        >
+          {t('customContent.textEntry.cancel')}
+        </Button>
+        <Button
+          onClick={handleSave}
+          disabled={!title.trim() || !hasContent || saving}
+          className="bg-cyan-500 hover:bg-cyan-600 text-white"
+        >
+          {saving ? t('customContent.textEntry.saving') : t('customContent.textEntry.save')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 226 - 0
shopcall.ai-main/src/components/CustomContentUpload.tsx

@@ -0,0 +1,226 @@
+import { useState, useCallback } from "react";
+import { useDropzone } from "react-dropzone";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Progress } from "@/components/ui/progress";
+import { Upload, FileText, AlertCircle } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { supabase } from "@/lib/supabase";
+import { useShop } from "@/components/context/ShopContext";
+import { useTranslation } from 'react-i18next';
+
+interface CustomContentUploadProps {
+  onSuccess: () => void;
+}
+
+export function CustomContentUpload({ onSuccess }: CustomContentUploadProps) {
+  const { t } = useTranslation();
+  const [file, setFile] = useState<File | null>(null);
+  const [title, setTitle] = useState("");
+  const [uploading, setUploading] = useState(false);
+  const [progress, setProgress] = useState(0);
+  const [error, setError] = useState<string | null>(null);
+  const { toast } = useToast();
+  const { selectedShop } = useShop();
+
+  const onDrop = useCallback((acceptedFiles: File[]) => {
+    if (acceptedFiles.length > 0) {
+      const uploadedFile = acceptedFiles[0];
+      setFile(uploadedFile);
+      // Auto-fill title with filename (without extension)
+      const fileName = uploadedFile.name.replace(/\.pdf$/i, "");
+      setTitle(fileName);
+      setError(null);
+    }
+  }, []);
+
+  const { getRootProps, getInputProps, isDragActive } = useDropzone({
+    onDrop,
+    accept: {
+      'application/pdf': ['.pdf']
+    },
+    maxFiles: 1,
+    maxSize: 10 * 1024 * 1024, // 10MB
+  });
+
+  const handleUpload = async () => {
+    if (!file || !title.trim()) {
+      setError(t('customContent.upload.errorRequired'));
+      return;
+    }
+
+    if (!selectedShop) {
+      setError(t('customContent.upload.errorSelectShop'));
+      return;
+    }
+
+    setUploading(true);
+    setError(null);
+    setProgress(10);
+
+    try {
+      // Create FormData
+      const formData = new FormData();
+      formData.append("file", file);
+      formData.append("title", title.trim());
+      formData.append("store_id", selectedShop.id);
+
+      setProgress(30);
+
+      // Get auth token
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) {
+        throw new Error("Not authenticated");
+      }
+
+      setProgress(50);
+
+      // Upload to Edge Function
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-upload`,
+        {
+          method: "POST",
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+          },
+          body: formData,
+        }
+      );
+
+      setProgress(80);
+
+      const result = await response.json();
+
+      if (!response.ok) {
+        if (result.duplicate) {
+          throw new Error(
+            t('customContent.upload.errorDuplicate', {
+              title: result.existingContent.title,
+              date: new Date(result.existingContent.uploadedAt).toLocaleDateString()
+            })
+          );
+        }
+        throw new Error(result.error || t('customContent.upload.errorTitle'));
+      }
+
+      setProgress(100);
+
+      toast({
+        title: t('customContent.upload.successTitle'),
+        description: t('customContent.upload.successDescription', { fileName: result.fileName }),
+      });
+
+      onSuccess();
+    } catch (err: any) {
+      console.error("Upload error:", err);
+      setError(err.message || t('customContent.upload.errorTitle'));
+      toast({
+        title: t('customContent.upload.errorTitle'),
+        description: err.message || t('customContent.upload.errorTitle'),
+        variant: "destructive",
+      });
+    } finally {
+      setUploading(false);
+      setProgress(0);
+    }
+  };
+
+  return (
+    <div className="space-y-4 bg-slate-800 p-6 rounded-lg">
+      <div>
+        <h2 className="text-lg font-semibold text-white">{t('customContent.upload.title')}</h2>
+        <p className="text-sm text-slate-400">
+          {t('customContent.upload.description')}
+        </p>
+      </div>
+
+      {/* File Dropzone */}
+      <div
+        {...getRootProps()}
+        className={`
+          border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
+          transition-colors
+          ${isDragActive ? "border-cyan-500 bg-cyan-500/10" : "border-slate-600"}
+          ${file ? "bg-slate-700" : "bg-slate-900"}
+        `}
+      >
+        <input {...getInputProps()} />
+        {file ? (
+          <div className="flex items-center justify-center gap-2">
+            <FileText className="h-8 w-8 text-cyan-400" />
+            <div className="text-left">
+              <p className="font-medium text-white">{file.name}</p>
+              <p className="text-sm text-slate-400">
+                {(file.size / 1024 / 1024).toFixed(2)} MB
+              </p>
+            </div>
+          </div>
+        ) : (
+          <div>
+            <Upload className="h-12 w-12 mx-auto text-slate-400 mb-2" />
+            <p className="text-sm text-slate-300">
+              {isDragActive
+                ? t('customContent.upload.dragDrop')
+                : t('customContent.upload.dragDrop')}
+            </p>
+            <p className="text-xs text-slate-500 mt-1">
+              {t('customContent.upload.maxSize')}
+            </p>
+          </div>
+        )}
+      </div>
+
+      {/* Title Input */}
+      <div className="space-y-2">
+        <Label htmlFor="title" className="text-slate-300">{t('customContent.upload.titleLabel')} *</Label>
+        <Input
+          id="title"
+          value={title}
+          onChange={(e) => setTitle(e.target.value)}
+          placeholder={t('customContent.upload.titlePlaceholder')}
+          disabled={uploading}
+          className="bg-slate-900 border-slate-600 text-white placeholder:text-slate-500"
+        />
+      </div>
+
+      {/* Progress Bar */}
+      {uploading && (
+        <div className="space-y-2">
+          <Progress value={progress} className="bg-slate-700" />
+          <p className="text-sm text-center text-slate-400">
+            {t('customContent.upload.uploading')} {progress}%
+          </p>
+        </div>
+      )}
+
+      {/* Error Alert */}
+      {error && (
+        <Alert variant="destructive" className="bg-red-900/20 border-red-800">
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription className="text-red-200">{error}</AlertDescription>
+        </Alert>
+      )}
+
+      {/* Action Buttons */}
+      <div className="flex justify-end gap-2">
+        <Button
+          variant="outline"
+          onClick={() => onSuccess()}
+          disabled={uploading}
+          className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
+        >
+          {t('customContent.upload.cancel')}
+        </Button>
+        <Button
+          onClick={handleUpload}
+          disabled={!file || !title.trim() || uploading}
+          className="bg-cyan-500 hover:bg-cyan-600 text-white"
+        >
+          {uploading ? t('customContent.upload.uploading') : t('customContent.upload.upload')}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 202 - 0
shopcall.ai-main/src/components/CustomContentViewer.tsx

@@ -0,0 +1,202 @@
+import { useState, useEffect } from "react";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { FileText, File, Loader2, ExternalLink } from "lucide-react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { supabase } from "@/lib/supabase";
+import { useTranslation } from 'react-i18next';
+
+interface CustomContentViewerProps {
+  contentId: string;
+  storeId: string;
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+}
+
+interface ContentData {
+  id: string;
+  contentType: "text_entry" | "pdf_upload";
+  title: string;
+  contentText: string;
+  originalFilename?: string;
+  pageCount?: number;
+  chunkCount: number;
+  createdAt: string;
+  pdfUrl?: string;
+}
+
+export function CustomContentViewer({
+  contentId,
+  storeId,
+  open,
+  onOpenChange,
+}: CustomContentViewerProps) {
+  const { t } = useTranslation();
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [content, setContent] = useState<ContentData | null>(null);
+  const [activeTab, setActiveTab] = useState<string>("text");
+
+  useEffect(() => {
+    if (open && contentId && storeId) {
+      fetchContent();
+    }
+  }, [open, contentId, storeId]);
+
+  const fetchContent = async () => {
+    setLoading(true);
+    setError(null);
+
+    try {
+      const { data: { session } } = await supabase.auth.getSession();
+      if (!session) throw new Error("Not authenticated");
+
+      const response = await fetch(
+        `${import.meta.env.VITE_API_URL}/custom-content-view?content_id=${contentId}&store_id=${storeId}`,
+        {
+          headers: {
+            Authorization: `Bearer ${session.access_token}`,
+          },
+        }
+      );
+
+      if (!response.ok) {
+        const errorData = await response.json();
+        throw new Error(errorData.error || "Failed to fetch content");
+      }
+
+      const data = await response.json();
+      setContent(data);
+
+      // For PDFs, default to PDF view if URL is available
+      if (data.contentType === "pdf_upload" && data.pdfUrl) {
+        setActiveTab("pdf");
+      }
+    } catch (err: any) {
+      console.error("Fetch content error:", err);
+      setError(err.message || "Failed to load content");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleOpenPdfInNewTab = () => {
+    if (content?.pdfUrl) {
+      window.open(content.pdfUrl, '_blank');
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-w-4xl max-h-[90vh] bg-slate-800 border-slate-700">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2 text-white">
+            {content?.contentType === "pdf_upload" ? (
+              <File className="h-5 w-5 text-red-400" />
+            ) : (
+              <FileText className="h-5 w-5 text-blue-400" />
+            )}
+            {content?.title || t('customContent.viewer.loading')}
+          </DialogTitle>
+        </DialogHeader>
+
+        <div className="space-y-4">
+          {loading && (
+            <div className="flex items-center justify-center py-12">
+              <Loader2 className="h-8 w-8 animate-spin text-cyan-500" />
+              <span className="ml-2 text-slate-400">{t('customContent.viewer.loading')}</span>
+            </div>
+          )}
+
+          {error && (
+            <Alert variant="destructive" className="bg-red-900/20 border-red-800">
+              <AlertDescription className="text-red-200">{error}</AlertDescription>
+            </Alert>
+          )}
+
+          {content && !loading && !error && (
+            <>
+              {/* Metadata */}
+              <div className="flex items-center gap-4 text-sm text-slate-400 pb-2 border-b border-slate-700">
+                {content.contentType === "pdf_upload" && content.originalFilename && (
+                  <span>{content.originalFilename}</span>
+                )}
+                {content.pageCount && (
+                  <span>{content.pageCount} {t('customContent.list.table.pages')}</span>
+                )}
+                <span>{content.chunkCount} {t('customContent.viewer.chunks')}</span>
+                <span>{new Date(content.createdAt).toLocaleDateString()}</span>
+              </div>
+
+              {/* Content Viewer */}
+              {content.contentType === "pdf_upload" && content.pdfUrl ? (
+                <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+                  <TabsList className="bg-slate-700 border border-slate-600">
+                    <TabsTrigger
+                      value="pdf"
+                      className="data-[state=active]:bg-slate-600 data-[state=active]:text-white text-slate-300"
+                    >
+                      <File className="h-4 w-4 mr-2" />
+                      {t('customContent.viewer.pdfView')}
+                    </TabsTrigger>
+                    <TabsTrigger
+                      value="text"
+                      className="data-[state=active]:bg-slate-600 data-[state=active]:text-white text-slate-300"
+                    >
+                      <FileText className="h-4 w-4 mr-2" />
+                      {t('customContent.viewer.textView')}
+                    </TabsTrigger>
+                  </TabsList>
+
+                  <TabsContent value="pdf" className="mt-4">
+                    <div className="space-y-2">
+                      <div className="flex justify-end">
+                        <Button
+                          variant="outline"
+                          size="sm"
+                          onClick={handleOpenPdfInNewTab}
+                          className="bg-slate-700 border-slate-600 text-white hover:bg-slate-600"
+                        >
+                          <ExternalLink className="h-4 w-4 mr-2" />
+                          {t('customContent.viewer.openInNewTab')}
+                        </Button>
+                      </div>
+                      <div className="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
+                        <iframe
+                          src={content.pdfUrl}
+                          className="w-full h-[600px]"
+                          title={content.title}
+                        />
+                      </div>
+                    </div>
+                  </TabsContent>
+
+                  <TabsContent value="text" className="mt-4">
+                    <div className="bg-slate-900 rounded-lg p-4 border border-slate-700 max-h-[600px] overflow-y-auto">
+                      <pre className="text-slate-200 whitespace-pre-wrap font-mono text-sm">
+                        {content.contentText}
+                      </pre>
+                    </div>
+                  </TabsContent>
+                </Tabs>
+              ) : (
+                // Text entry - just show text
+                <div className="bg-slate-900 rounded-lg p-4 border border-slate-700 max-h-[600px] overflow-y-auto">
+                  <pre className="text-slate-200 whitespace-pre-wrap font-mono text-sm">
+                    {content.contentText}
+                  </pre>
+                </div>
+              )}
+            </>
+          )}
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 105 - 1
shopcall.ai-main/src/i18n/locales/en.json

@@ -205,7 +205,8 @@
     "aiMenu": "AI Configuration",
     "aiMenu": "AI Configuration",
     "knowledgeBase": "Knowledge Base",
     "knowledgeBase": "Knowledge Base",
     "products": "Products",
     "products": "Products",
-    "websiteContent": "Website Content"
+    "websiteContent": "Website Content",
+    "customContent": "Custom Content"
   },
   },
   "common": {
   "common": {
     "loading": "Loading...",
     "loading": "Loading...",
@@ -1561,6 +1562,109 @@
     "playStereo": "Play Stereo Recording",
     "playStereo": "Play Stereo Recording",
     "noRecording": "No recording available"
     "noRecording": "No recording available"
   },
   },
+  "customContent": {
+    "title": "Custom Content",
+    "description": "Upload PDFs and create text entries for your AI assistant's knowledge base",
+    "uploadPdf": "Upload PDF",
+    "addTextEntry": "Add Text Entry",
+    "tabs": {
+      "all": "All Content",
+      "pdfs": "PDFs",
+      "textEntries": "Text Entries"
+    },
+    "upload": {
+      "title": "Upload PDF",
+      "description": "Upload a PDF file to add to your AI assistant's knowledge base",
+      "dragDrop": "Drag and drop a PDF file here, or click to select",
+      "maxSize": "Maximum file size: 10MB",
+      "titleLabel": "Title",
+      "titlePlaceholder": "Enter a descriptive title",
+      "uploading": "Uploading...",
+      "upload": "Upload",
+      "cancel": "Cancel",
+      "successTitle": "Upload successful",
+      "successDescription": "{{fileName}} is being processed. This may take a few moments.",
+      "errorTitle": "Upload failed",
+      "errorRequired": "Please select a file and provide a title",
+      "errorSelectShop": "Please select a shop first",
+      "errorDuplicate": "This file already exists as \"{{title}}\" (uploaded on {{date}})"
+    },
+    "textEntry": {
+      "title": "Add Text Entry",
+      "description": "Create a custom text entry for your AI assistant's knowledge base",
+      "titleLabel": "Title",
+      "titlePlaceholder": "Enter a title",
+      "contentLabel": "Content",
+      "contentPlaceholder": "Enter your content here...",
+      "markdownPlaceholder": "Enter your content in Markdown format...\n\n# Heading\n## Subheading\n\n**Bold** and *italic* text",
+      "wysiwygPlaceholder": "Start typing or paste formatted text from Word...",
+      "formatPlain": "Plain Text",
+      "formatMarkdown": "Markdown",
+      "formatWysiwyg": "Rich Text",
+      "validationNote": "Note: URLs and images are not allowed in content",
+      "characterCount": "{{count}} / 50,000 characters",
+      "save": "Save",
+      "saving": "Saving...",
+      "cancel": "Cancel",
+      "successTitle": "Entry created",
+      "successDescription": "\"{{title}}\" has been added to your knowledge base",
+      "errorTitle": "Creation failed",
+      "errorRequired": "Please provide both title and content",
+      "errorMaxLength": "Content exceeds maximum length of 50,000 characters",
+      "errorSelectShop": "Please select a shop first",
+      "errorContainsUrl": "Content cannot contain URLs. Please remove all links.",
+      "errorContainsImage": "Content cannot contain images. Please remove all image references.",
+      "errorInvalidContent": "Content contains invalid elements"
+    },
+    "list": {
+      "loading": "Loading...",
+      "noContent": "No custom content yet. Upload a PDF or create a text entry to get started.",
+      "view": "View content",
+      "table": {
+        "type": "Type",
+        "title": "Title",
+        "details": "Details",
+        "status": "Status",
+        "chunks": "Chunks",
+        "created": "Created",
+        "actions": "Actions",
+        "textEntry": "Text entry",
+        "pages": "pages"
+      },
+      "delete": {
+        "title": "Delete content?",
+        "description": "This will permanently remove the content from your knowledge base and cannot be undone.",
+        "cancel": "Cancel",
+        "confirm": "Delete",
+        "successTitle": "Content deleted",
+        "successDescription": "The content has been removed from your knowledge base",
+        "errorTitle": "Deletion failed"
+      },
+      "failureWarning": {
+        "title": "Processing Failed",
+        "description": "Some content failed to process. Please delete the failed items and try uploading them again. Check the error details in the status badge for more information."
+      }
+    },
+    "viewer": {
+      "loading": "Loading content...",
+      "chunks": "chunks",
+      "pdfView": "PDF View",
+      "textView": "Text View",
+      "openInNewTab": "Open in New Tab"
+    },
+    "status": {
+      "pending": "Pending",
+      "processing": "Processing",
+      "ready": "Ready",
+      "failed": "Failed",
+      "tooltip": {
+        "pending": "Waiting to start processing",
+        "processing": "Extracting text and generating embeddings",
+        "ready": "Successfully added to knowledge base",
+        "failed": "Processing failed"
+      }
+    }
+  },
   "crawler": {
   "crawler": {
     "title": "ShopCall.ai Web Crawler",
     "title": "ShopCall.ai Web Crawler",
     "backHome": "Back to Home",
     "backHome": "Back to Home",

+ 105 - 1
shopcall.ai-main/src/i18n/locales/hu.json

@@ -195,7 +195,8 @@
     "aiMenu": "AI Konfiguráció",
     "aiMenu": "AI Konfiguráció",
     "knowledgeBase": "Tudásbázis",
     "knowledgeBase": "Tudásbázis",
     "products": "Termékek",
     "products": "Termékek",
-    "websiteContent": "Weboldal Tartalom"
+    "websiteContent": "Weboldal Tartalom",
+    "customContent": "Egyedi Tartalom"
   },
   },
   "common": {
   "common": {
     "loading": "Betöltés...",
     "loading": "Betöltés...",
@@ -1522,6 +1523,109 @@
       }
       }
     }
     }
   },
   },
+  "customContent": {
+    "title": "Egyedi Tartalom",
+    "description": "Töltsön fel PDF-eket és hozzon létre szöveges bejegyzéseket AI asszisztensének tudásbázisa számára",
+    "uploadPdf": "PDF Feltöltése",
+    "addTextEntry": "Szöveges Bejegyzés Hozzáadása",
+    "tabs": {
+      "all": "Minden Tartalom",
+      "pdfs": "PDF-ek",
+      "textEntries": "Szöveges Bejegyzések"
+    },
+    "upload": {
+      "title": "PDF Feltöltése",
+      "description": "Töltsön fel egy PDF fájlt AI asszisztensének tudásbázisához",
+      "dragDrop": "Húzza ide a PDF fájlt, vagy kattintson a kiválasztáshoz",
+      "maxSize": "Maximum fájlméret: 10MB",
+      "titleLabel": "Cím",
+      "titlePlaceholder": "Adjon meg egy leíró címet",
+      "uploading": "Feltöltés...",
+      "upload": "Feltöltés",
+      "cancel": "Mégse",
+      "successTitle": "Sikeres feltöltés",
+      "successDescription": "{{fileName}} feldolgozás alatt. Ez eltarthat néhány pillanatig.",
+      "errorTitle": "Sikertelen feltöltés",
+      "errorRequired": "Kérjük, válasszon ki egy fájlt és adjon meg címet",
+      "errorSelectShop": "Először válasszon ki egy boltot",
+      "errorDuplicate": "Ez a fájl már létezik mint \"{{title}}\" (feltöltve: {{date}})"
+    },
+    "textEntry": {
+      "title": "Szöveges Bejegyzés Hozzáadása",
+      "description": "Hozzon létre egyedi szöveges bejegyzést AI asszisztensének tudásbázisa számára",
+      "titleLabel": "Cím",
+      "titlePlaceholder": "Adjon meg egy címet",
+      "contentLabel": "Tartalom",
+      "contentPlaceholder": "Írja be a tartalmat ide...",
+      "markdownPlaceholder": "Írja be a tartalmat Markdown formátumban...\n\n# Címsor\n## Alcím\n\n**Félkövér** és *dőlt* szöveg",
+      "wysiwygPlaceholder": "Kezdjen el gépelni, vagy illesszen be formázott szöveget Wordből...",
+      "formatPlain": "Egyszerű Szöveg",
+      "formatMarkdown": "Markdown",
+      "formatWysiwyg": "Formázott Szöveg",
+      "validationNote": "Megjegyzés: URL-ek és képek nem engedélyezettek a tartalomban",
+      "characterCount": "{{count}} / 50 000 karakter",
+      "save": "Mentés",
+      "saving": "Mentés...",
+      "cancel": "Mégse",
+      "successTitle": "Bejegyzés létrehozva",
+      "successDescription": "\"{{title}}\" hozzáadva a tudásbázishoz",
+      "errorTitle": "Sikertelen létrehozás",
+      "errorRequired": "Kérjük, adja meg a címet és a tartalmat",
+      "errorMaxLength": "A tartalom meghaladja a maximális 50 000 karakteres hosszúságot",
+      "errorSelectShop": "Először válasszon ki egy boltot",
+      "errorContainsUrl": "A tartalom nem tartalmazhat URL-eket. Kérjük, távolítsa el az összes linket.",
+      "errorContainsImage": "A tartalom nem tartalmazhat képeket. Kérjük, távolítsa el az összes képhivatkozást.",
+      "errorInvalidContent": "A tartalom érvénytelen elemeket tartalmaz"
+    },
+    "list": {
+      "loading": "Betöltés...",
+      "noContent": "Még nincs egyedi tartalom. Töltsön fel egy PDF-et vagy hozzon létre egy szöveges bejegyzést a kezdéshez.",
+      "view": "Tartalom megtekintése",
+      "table": {
+        "type": "Típus",
+        "title": "Cím",
+        "details": "Részletek",
+        "status": "Állapot",
+        "chunks": "Darabok",
+        "created": "Létrehozva",
+        "actions": "Műveletek",
+        "textEntry": "Szöveges bejegyzés",
+        "pages": "oldal"
+      },
+      "delete": {
+        "title": "Tartalom törlése?",
+        "description": "Ez véglegesen eltávolítja a tartalmat a tudásbázisból, és nem lehet visszavonni.",
+        "cancel": "Mégse",
+        "confirm": "Törlés",
+        "successTitle": "Tartalom törölve",
+        "successDescription": "A tartalom eltávolítva a tudásbázisból",
+        "errorTitle": "Sikertelen törlés"
+      },
+      "failureWarning": {
+        "title": "Feldolgozás Sikertelen",
+        "description": "Néhány tartalom feldolgozása nem sikerült. Kérjük, törölje a sikertelen elemeket és próbálja meg újra feltölteni őket. További információért nézze meg a hiba részleteit az állapot jelvényen."
+      }
+    },
+    "viewer": {
+      "loading": "Tartalom betöltése...",
+      "chunks": "darab",
+      "pdfView": "PDF Nézet",
+      "textView": "Szöveges Nézet",
+      "openInNewTab": "Megnyitás Új Lapon"
+    },
+    "status": {
+      "pending": "Függőben",
+      "processing": "Feldolgozás",
+      "ready": "Kész",
+      "failed": "Sikertelen",
+      "tooltip": {
+        "pending": "Feldolgozás indításra vár",
+        "processing": "Szöveg kinyerése és beágyazások generálása",
+        "ready": "Sikeresen hozzáadva a tudásbázishoz",
+        "failed": "A feldolgozás sikertelen volt"
+      }
+    }
+  },
   "callDetails": {
   "callDetails": {
     "title": "Hívás Részletei",
     "title": "Hívás Részletei",
     "contactInformation": "Kapcsolattartási Információk",
     "contactInformation": "Kapcsolattartási Információk",

+ 125 - 0
shopcall.ai-main/src/index.css

@@ -100,4 +100,129 @@
   body {
   body {
     @apply bg-background text-foreground;
     @apply bg-background text-foreground;
   }
   }
+}
+
+/* TipTap Editor Styles */
+.ProseMirror {
+  outline: none;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  word-break: break-word;
+  max-width: 100%;
+}
+
+.ProseMirror p.is-editor-empty:first-child::before {
+  color: #64748b;
+  content: attr(data-placeholder);
+  float: left;
+  height: 0;
+  pointer-events: none;
+}
+
+.ProseMirror p {
+  margin-bottom: 0.5rem;
+}
+
+.ProseMirror h1 {
+  font-size: 1.875rem;
+  font-weight: 700;
+  margin-top: 1rem;
+  margin-bottom: 0.5rem;
+}
+
+.ProseMirror h2 {
+  font-size: 1.5rem;
+  font-weight: 600;
+  margin-top: 0.875rem;
+  margin-bottom: 0.5rem;
+}
+
+.ProseMirror h3 {
+  font-size: 1.25rem;
+  font-weight: 600;
+  margin-top: 0.75rem;
+  margin-bottom: 0.5rem;
+}
+
+.ProseMirror ul,
+.ProseMirror ol {
+  margin-left: 1.5rem;
+  margin-bottom: 0.5rem;
+}
+
+.ProseMirror ul {
+  list-style-type: disc;
+}
+
+.ProseMirror ol {
+  list-style-type: decimal;
+}
+
+.ProseMirror li {
+  margin-bottom: 0.25rem;
+}
+
+.ProseMirror strong {
+  font-weight: 700;
+}
+
+.ProseMirror em {
+  font-style: italic;
+}
+
+.ProseMirror code {
+  background-color: rgba(148, 163, 184, 0.1);
+  border-radius: 0.25rem;
+  padding: 0.125rem 0.25rem;
+  font-family: 'Courier New', monospace;
+  font-size: 0.875em;
+}
+
+.ProseMirror pre {
+  background-color: rgba(148, 163, 184, 0.1);
+  border-radius: 0.375rem;
+  padding: 0.75rem;
+  margin-bottom: 0.5rem;
+  overflow-x: auto;
+}
+
+.ProseMirror pre code {
+  background-color: transparent;
+  padding: 0;
+}
+
+.ProseMirror blockquote {
+  border-left: 3px solid #64748b;
+  padding-left: 1rem;
+  margin-left: 0;
+  margin-bottom: 0.5rem;
+  color: #94a3b8;
+}
+
+.ProseMirror table {
+  border-collapse: collapse;
+  width: 100%;
+  max-width: 100%;
+  table-layout: fixed;
+  margin-bottom: 0.5rem;
+  overflow: hidden;
+}
+
+.ProseMirror table td,
+.ProseMirror table th {
+  border: 1px solid #64748b;
+  padding: 0.375rem;
+  word-wrap: break-word;
+  overflow-wrap: break-word;
+  min-width: 50px;
+}
+
+.ProseMirror img {
+  max-width: 100%;
+  height: auto;
+  display: block;
+}
+
+.ProseMirror * {
+  max-width: 100%;
 }
 }

+ 27 - 0
shopcall.ai-main/src/pages/CustomContent.tsx

@@ -0,0 +1,27 @@
+import { SidebarProvider } from "@/components/ui/sidebar";
+import { AppSidebar } from "@/components/AppSidebar";
+import { CustomContentManager } from "@/components/CustomContentManager";
+import { useTranslation } from 'react-i18next';
+
+export default function CustomContent() {
+  const { t } = useTranslation();
+
+  return (
+    <SidebarProvider>
+      <div className="min-h-screen flex w-full bg-slate-900">
+        <AppSidebar />
+        <main className="flex-1 bg-slate-900 text-white">
+          <div className="flex flex-col gap-6 p-6">
+            <div>
+              <h1 className="text-3xl font-bold text-white">{t('customContent.title')}</h1>
+              <p className="text-slate-400 mt-2">
+                {t('customContent.description')}
+              </p>
+            </div>
+            <CustomContentManager />
+          </div>
+        </main>
+      </div>
+    </SidebarProvider>
+  );
+}

+ 351 - 0
supabase/functions/_shared/content-validator.ts

@@ -0,0 +1,351 @@
+/**
+ * Content Validation Utilities
+ *
+ * Validates that content is suitable for RAG knowledge base:
+ * - Accepts: Plain text, Markdown
+ * - Rejects: JSON, XML, JavaScript, HTML, SQL, and other code/structured formats
+ */
+
+/**
+ * Content validation result
+ */
+export interface ValidationResult {
+  isValid: boolean;
+  reason?: string;
+  detectedFormat?: string;
+}
+
+/**
+ * Detect if content contains structured data or code
+ *
+ * @param content - Text content to validate
+ * @returns Validation result with reason if invalid
+ */
+export function validateContent(content: string): ValidationResult {
+  const trimmedContent = content.trim();
+
+  if (trimmedContent.length === 0) {
+    return {
+      isValid: false,
+      reason: 'Content is empty',
+    };
+  }
+
+  // Check for JSON
+  if (looksLikeJSON(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be JSON data. Only plain text and Markdown are allowed.',
+      detectedFormat: 'JSON',
+    };
+  }
+
+  // Check for XML/HTML
+  if (looksLikeXML(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be XML/HTML. Only plain text and Markdown are allowed.',
+      detectedFormat: 'XML/HTML',
+    };
+  }
+
+  // Check for JavaScript/TypeScript code
+  if (looksLikeJavaScript(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be JavaScript/TypeScript code. Only plain text and Markdown are allowed.',
+      detectedFormat: 'JavaScript/TypeScript',
+    };
+  }
+
+  // Check for Python code
+  if (looksLikePython(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be Python code. Only plain text and Markdown are allowed.',
+      detectedFormat: 'Python',
+    };
+  }
+
+  // Check for SQL
+  if (looksLikeSQL(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be SQL code. Only plain text and Markdown are allowed.',
+      detectedFormat: 'SQL',
+    };
+  }
+
+  // Check for YAML
+  if (looksLikeYAML(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be YAML data. Only plain text and Markdown are allowed.',
+      detectedFormat: 'YAML',
+    };
+  }
+
+  // Check for CSV (with high confidence threshold)
+  if (looksLikeCSV(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content appears to be CSV data. Only plain text and Markdown are allowed.',
+      detectedFormat: 'CSV',
+    };
+  }
+
+  // Check for excessive code-like patterns
+  if (hasExcessiveCodePatterns(trimmedContent)) {
+    return {
+      isValid: false,
+      reason: 'Content contains too many code-like patterns. Only plain text and Markdown are allowed.',
+      detectedFormat: 'Code',
+    };
+  }
+
+  // Content passes all checks
+  return {
+    isValid: true,
+  };
+}
+
+/**
+ * Check if content looks like JSON
+ */
+function looksLikeJSON(content: string): boolean {
+  // Try to parse as JSON
+  if (content.startsWith('{') || content.startsWith('[')) {
+    try {
+      JSON.parse(content);
+      return true;
+    } catch {
+      // Not valid JSON, but might still look like it
+    }
+  }
+
+  // Check for JSON-like patterns (high confidence)
+  const jsonPatterns = [
+    /^\s*\{[\s\S]*\}\s*$/,  // Object literal
+    /^\s*\[[\s\S]*\]\s*$/,  // Array literal
+    /"[^"]+"\s*:\s*[{\["]/g,  // Key-value pairs
+  ];
+
+  const jsonKeywords = ['"type":', '"id":', '"data":', '"properties":'];
+
+  const hasJSONStructure = jsonPatterns.some(pattern => pattern.test(content));
+  const hasMultipleJSONKeywords = jsonKeywords.filter(keyword => content.includes(keyword)).length >= 2;
+
+  return hasJSONStructure && hasMultipleJSONKeywords;
+}
+
+/**
+ * Check if content looks like XML or HTML
+ */
+function looksLikeXML(content: string): boolean {
+  // Check for XML/HTML tags
+  const xmlPattern = /<[a-zA-Z][^>]*>[\s\S]*<\/[a-zA-Z][^>]*>/;
+  const hasXMLTags = xmlPattern.test(content);
+
+  // Count opening and closing tags
+  const openTags = (content.match(/<[a-zA-Z][^>]*>/g) || []).length;
+  const closeTags = (content.match(/<\/[a-zA-Z][^>]*>/g) || []).length;
+
+  // If more than 3 matching tags, likely XML/HTML
+  const tagCount = Math.min(openTags, closeTags);
+
+  // Check for XML declaration
+  const hasXMLDeclaration = content.includes('<?xml');
+
+  return hasXMLTags && (tagCount >= 3 || hasXMLDeclaration);
+}
+
+/**
+ * Check if content looks like JavaScript/TypeScript
+ */
+function looksLikeJavaScript(content: string): boolean {
+  const jsPatterns = [
+    /\bfunction\s+\w+\s*\(/,
+    /\bconst\s+\w+\s*=/,
+    /\blet\s+\w+\s*=/,
+    /\bvar\s+\w+\s*=/,
+    /\bclass\s+\w+/,
+    /\bimport\s+.*\bfrom\b/,
+    /\bexport\s+(default\s+)?(function|class|const)/,
+    /=>\s*\{/,  // Arrow functions
+    /\basync\s+function/,
+    /\bawait\s+\w+/,
+  ];
+
+  const matchCount = jsPatterns.filter(pattern => pattern.test(content)).length;
+
+  // If 3 or more JS patterns match, likely JavaScript
+  return matchCount >= 3;
+}
+
+/**
+ * Check if content looks like Python
+ */
+function looksLikePython(content: string): boolean {
+  const pythonPatterns = [
+    /\bdef\s+\w+\s*\(/,
+    /\bclass\s+\w+:/,
+    /\bimport\s+\w+/,
+    /\bfrom\s+\w+\s+import\b/,
+    /\bif\s+__name__\s*==\s*['"']__main__['"']/,
+    /\bself\.\w+/,
+    /:\s*$\n\s{4,}/m,  // Indentation after colon
+  ];
+
+  const matchCount = pythonPatterns.filter(pattern => pattern.test(content)).length;
+
+  return matchCount >= 3;
+}
+
+/**
+ * Check if content looks like SQL
+ */
+function looksLikeSQL(content: string): boolean {
+  const sqlKeywords = [
+    /\bSELECT\s+.*\s+FROM\b/i,
+    /\bINSERT\s+INTO\b/i,
+    /\bUPDATE\s+.*\s+SET\b/i,
+    /\bDELETE\s+FROM\b/i,
+    /\bCREATE\s+(TABLE|VIEW|INDEX)\b/i,
+    /\bALTER\s+TABLE\b/i,
+    /\bDROP\s+(TABLE|VIEW|INDEX)\b/i,
+    /\bJOIN\b.*\bON\b/i,
+  ];
+
+  const matchCount = sqlKeywords.filter(pattern => pattern.test(content)).length;
+
+  return matchCount >= 2;
+}
+
+/**
+ * Check if content looks like YAML
+ */
+function looksLikeYAML(content: string): boolean {
+  const yamlPatterns = [
+    /^[a-zA-Z_][\w-]*:\s*.+$/m,  // Key-value pairs
+    /^\s*-\s+[a-zA-Z_][\w-]*:/m,  // List items with keys
+    /^---\s*$/m,  // Document separator
+  ];
+
+  // Count lines that look like YAML
+  const lines = content.split('\n');
+  const yamlLikeLines = lines.filter(line =>
+    /^[a-zA-Z_][\w-]*:\s*.+$/.test(line.trim()) ||
+    /^\s*-\s+/.test(line)
+  ).length;
+
+  const hasYAMLSeparator = content.includes('---');
+  const yamlLineRatio = yamlLikeLines / lines.length;
+
+  // If more than 40% of lines look like YAML, it's probably YAML
+  return (yamlLineRatio > 0.4 && yamlLikeLines >= 5) || hasYAMLSeparator;
+}
+
+/**
+ * Check if content looks like CSV
+ */
+function looksLikeCSV(content: string): boolean {
+  const lines = content.trim().split('\n');
+
+  if (lines.length < 3) {
+    return false;  // Need at least a few lines to be CSV
+  }
+
+  // Count commas in each line
+  const commaCounts = lines.slice(0, 10).map(line =>
+    (line.match(/,/g) || []).length
+  );
+
+  // Check if most lines have similar comma counts
+  if (commaCounts.length === 0) return false;
+
+  const avgCommas = commaCounts.reduce((a, b) => a + b, 0) / commaCounts.length;
+  const hasConsistentCommas = commaCounts.filter(count =>
+    Math.abs(count - avgCommas) <= 1
+  ).length >= commaCounts.length * 0.8;
+
+  // Need at least 3 commas per line on average and consistency
+  return avgCommas >= 3 && hasConsistentCommas;
+}
+
+/**
+ * Check for excessive code-like patterns
+ */
+function hasExcessiveCodePatterns(content: string): boolean {
+  const codePatterns = [
+    /[{}\[\]();]/g,  // Brackets and braces
+    /[=<>!]+/g,  // Operators
+    /\b0x[0-9a-fA-F]+\b/g,  // Hex numbers
+    /\/\/.*/g,  // Single-line comments
+    /\/\*[\s\S]*?\*\//g,  // Multi-line comments
+  ];
+
+  let totalMatches = 0;
+  for (const pattern of codePatterns) {
+    const matches = content.match(pattern) || [];
+    totalMatches += matches.length;
+  }
+
+  // If there are excessive code patterns relative to content length
+  const codePatternDensity = totalMatches / content.length;
+
+  // More than 10% of characters are code-like
+  return codePatternDensity > 0.1 && totalMatches > 50;
+}
+
+/**
+ * Check if PDF text extraction contains mostly structured content
+ *
+ * This is useful for validating PDFs after extraction
+ */
+export function validatePDFText(extractedText: string): ValidationResult {
+  // PDFs might contain some special characters from extraction
+  // Be more lenient with PDF content but still reject obvious code/data
+
+  const result = validateContent(extractedText);
+
+  // If it failed basic validation, check if it's a false positive
+  if (!result.isValid) {
+    // Check if it's mostly readable text despite some code-like elements
+    const readableTextRatio = calculateReadableTextRatio(extractedText);
+
+    // If more than 70% is readable text, allow it
+    if (readableTextRatio > 0.7) {
+      return {
+        isValid: true,
+      };
+    }
+  }
+
+  return result;
+}
+
+/**
+ * Calculate ratio of readable text to total content
+ */
+function calculateReadableTextRatio(content: string): number {
+  // Remove code-like patterns
+  let cleanedContent = content
+    .replace(/[{}\[\]();]/g, '')  // Remove brackets
+    .replace(/[=<>!]+/g, '')  // Remove operators
+    .replace(/\b0x[0-9a-fA-F]+\b/g, '')  // Remove hex
+    .replace(/\/\/.*/g, '')  // Remove comments
+    .replace(/\/\*[\s\S]*?\*\//g, '');  // Remove multi-line comments
+
+  // Count words (sequences of letters)
+  const words = cleanedContent.match(/[a-zA-Z]{3,}/g) || [];
+  const wordCount = words.length;
+
+  // Count total characters
+  const totalChars = content.length;
+
+  // Readable text should have reasonable word density
+  const avgWordLength = 5;
+  const estimatedReadableChars = wordCount * avgWordLength;
+
+  return Math.min(estimatedReadableChars / totalChars, 1.0);
+}

+ 67 - 1
supabase/functions/_shared/mcp-qdrant-helpers.ts

@@ -5,7 +5,7 @@
  */
  */
 
 
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
 import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
-import { scrollPoints, searchPoints, getCollectionName, generateEmbedding } from './qdrant-client.ts';
+import { scrollPoints, searchPoints, getCollectionName, generateEmbedding, searchCustomContent } from './qdrant-client.ts';
 import { LlmOrder, LlmCustomer, LlmProduct } from './mcp-types.ts';
 import { LlmOrder, LlmCustomer, LlmProduct } from './mcp-types.ts';
 
 
 // Initialize Supabase client
 // Initialize Supabase client
@@ -320,3 +320,69 @@ function formatCustomerForLlm(customer: any): LlmCustomer {
     totalSpent: customer.total_spent ? customer.total_spent.toString() : undefined
     totalSpent: customer.total_spent ? customer.total_spent.toString() : undefined
   };
   };
 }
 }
+
+/**
+ * Custom content result for LLM
+ */
+export interface LlmCustomContent {
+  id: string;
+  title: string;
+  contentType: 'text_entry' | 'pdf_upload';
+  excerpt: string;
+  chunkIndex: number;
+  totalChunks: number;
+  relevanceScore: number;
+  originalFilename?: string;
+  pageCount?: number;
+}
+
+/**
+ * Query custom content from Qdrant
+ *
+ * @param storeId - Store UUID
+ * @param shopname - Store name for collection naming
+ * @param query - Search query text
+ * @param limit - Maximum number of results
+ * @returns Array of custom content results
+ */
+export async function queryQdrantCustomContent(
+  storeId: string,
+  shopname: string,
+  query: string,
+  limit: number = 5
+): Promise<LlmCustomContent[]> {
+  try {
+    console.log(`[MCP Qdrant] Searching custom content for: "${query}"`);
+
+    // Use semantic search from qdrant-client
+    const results = await searchCustomContent(shopname, query, limit);
+
+    console.log(`[MCP Qdrant] Found ${results.length} custom content results`);
+
+    // Format for LLM
+    return results.map((result) => ({
+      id: result.contentId,
+      title: result.title,
+      contentType: result.contentType as 'text_entry' | 'pdf_upload',
+      excerpt: truncateText(result.chunkText, 500),
+      chunkIndex: result.chunkIndex,
+      totalChunks: result.totalChunks,
+      relevanceScore: result.score,
+      originalFilename: result.originalFilename,
+      pageCount: result.pageCount,
+    }));
+  } catch (error) {
+    console.error('[MCP Qdrant] Error querying custom content:', error);
+    throw error;
+  }
+}
+
+/**
+ * Truncate text to a maximum length
+ */
+function truncateText(text: string, maxLength: number): string {
+  if (text.length <= maxLength) {
+    return text;
+  }
+  return text.substring(0, maxLength) + '...';
+}

+ 224 - 0
supabase/functions/_shared/pdf-processor.ts

@@ -0,0 +1,224 @@
+/**
+ * PDF Processing Utilities
+ *
+ * Provides functions for:
+ * - Extracting text from PDF files
+ * - Calculating SHA-256 checksums for deduplication
+ * - Estimating token counts for chunking decisions
+ */
+
+import pdf from 'npm:pdf-parse@1.1.1';
+
+/**
+ * Extract text content from a PDF buffer
+ *
+ * @param buffer - PDF file as Uint8Array or Buffer
+ * @returns Extracted text and metadata
+ */
+export async function extractTextFromPDF(
+  buffer: Uint8Array | ArrayBuffer
+): Promise<{
+  text: string;
+  pageCount: number;
+  metadata: {
+    title?: string;
+    author?: string;
+    creator?: string;
+    producer?: string;
+    creationDate?: Date;
+  };
+}> {
+  try {
+    // Convert to Uint8Array if needed (pdf-parse can handle Uint8Array in Deno)
+    const pdfBuffer = buffer instanceof Uint8Array
+      ? buffer
+      : new Uint8Array(buffer);
+
+    // Parse PDF
+    const data = await pdf(pdfBuffer);
+
+    // Clean up extracted text
+    const cleanText = data.text
+      .replace(/\r\n/g, '\n') // Normalize line endings
+      .replace(/\n{3,}/g, '\n\n') // Remove excessive newlines
+      .trim();
+
+    return {
+      text: cleanText,
+      pageCount: data.numpages,
+      metadata: {
+        title: data.info?.Title,
+        author: data.info?.Author,
+        creator: data.info?.Creator,
+        producer: data.info?.Producer,
+        creationDate: data.info?.CreationDate,
+      },
+    };
+  } catch (error) {
+    console.error('PDF extraction failed:', error);
+    throw new Error(
+      `Failed to extract text from PDF: ${error instanceof Error ? error.message : 'Unknown error'}`
+    );
+  }
+}
+
+/**
+ * Calculate SHA-256 checksum of a file buffer
+ *
+ * Used for deduplication - same file content = same checksum
+ *
+ * @param buffer - File content as Uint8Array or ArrayBuffer
+ * @returns Hex-encoded SHA-256 hash (64 characters)
+ */
+export async function calculateChecksum(
+  buffer: Uint8Array | ArrayBuffer
+): Promise<string> {
+  try {
+    // Convert to Uint8Array if needed
+    const data = buffer instanceof Uint8Array
+      ? buffer
+      : new Uint8Array(buffer);
+
+    // Calculate SHA-256 hash
+    const hashBuffer = await crypto.subtle.digest('SHA-256', data);
+
+    // Convert to hex string
+    const hashArray = Array.from(new Uint8Array(hashBuffer));
+    const hashHex = hashArray
+      .map(b => b.toString(16).padStart(2, '0'))
+      .join('');
+
+    return hashHex;
+  } catch (error) {
+    console.error('Checksum calculation failed:', error);
+    throw new Error(
+      `Failed to calculate checksum: ${error instanceof Error ? error.message : 'Unknown error'}`
+    );
+  }
+}
+
+/**
+ * Estimate token count for text
+ *
+ * Uses a rough approximation: ~4 characters per token
+ * This is conservative for most languages
+ *
+ * @param text - Text to estimate tokens for
+ * @returns Estimated token count
+ */
+export function estimateTokenCount(text: string): number {
+  // Rough approximation: 1 token ≈ 4 characters
+  // This is conservative and works well for English and most languages
+  const estimatedTokens = Math.ceil(text.length / 4);
+
+  return estimatedTokens;
+}
+
+/**
+ * Validate PDF file
+ *
+ * Checks if buffer is a valid PDF by looking for PDF magic number
+ *
+ * @param buffer - File buffer to validate
+ * @returns true if valid PDF
+ */
+export function isValidPDF(buffer: Uint8Array | ArrayBuffer): boolean {
+  try {
+    const data = buffer instanceof Uint8Array
+      ? buffer
+      : new Uint8Array(buffer);
+
+    // Check for PDF magic number: %PDF-
+    if (data.length < 5) return false;
+
+    const header = String.fromCharCode(...data.slice(0, 5));
+    return header === '%PDF-';
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * Get file size in human-readable format
+ *
+ * @param bytes - File size in bytes
+ * @returns Formatted string (e.g., "2.5 MB")
+ */
+export function formatFileSize(bytes: number): string {
+  const units = ['B', 'KB', 'MB', 'GB'];
+  let size = bytes;
+  let unitIndex = 0;
+
+  while (size >= 1024 && unitIndex < units.length - 1) {
+    size /= 1024;
+    unitIndex++;
+  }
+
+  return `${size.toFixed(2)} ${units[unitIndex]}`;
+}
+
+/**
+ * Extract metadata-only from PDF (without full text extraction)
+ *
+ * Useful for quick file validation
+ *
+ * @param buffer - PDF file buffer
+ * @returns Basic PDF metadata
+ */
+export async function extractPDFMetadata(
+  buffer: Uint8Array | ArrayBuffer
+): Promise<{
+  pageCount: number;
+  info: {
+    title?: string;
+    author?: string;
+    creator?: string;
+    producer?: string;
+  };
+}> {
+  try {
+    const pdfBuffer = buffer instanceof Uint8Array
+      ? buffer
+      : new Uint8Array(buffer);
+
+    // Parse only metadata (no text extraction)
+    const data = await pdf(pdfBuffer, {
+      max: 0, // Don't extract pages
+    });
+
+    return {
+      pageCount: data.numpages,
+      info: {
+        title: data.info?.Title,
+        author: data.info?.Author,
+        creator: data.info?.Creator,
+        producer: data.info?.Producer,
+      },
+    };
+  } catch (error) {
+    console.error('PDF metadata extraction failed:', error);
+    throw new Error(
+      `Failed to extract PDF metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
+    );
+  }
+}
+
+/**
+ * Sanitize extracted text for storage
+ *
+ * Removes control characters and excessive whitespace
+ *
+ * @param text - Raw extracted text
+ * @returns Cleaned text
+ */
+export function sanitizeExtractedText(text: string): string {
+  return text
+    // Remove control characters except newlines and tabs
+    .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
+    // Normalize whitespace
+    .replace(/[ \t]+/g, ' ')
+    // Remove excessive newlines
+    .replace(/\n{4,}/g, '\n\n\n')
+    // Trim
+    .trim();
+}

+ 182 - 0
supabase/functions/_shared/qdrant-client.ts

@@ -322,6 +322,188 @@ export async function getCollectionInfo(collectionName: string, config?: QdrantC
   return response.result;
   return response.result;
 }
 }
 
 
+/**
+ * Get standardized collection name for custom content
+ */
+export function getCustomContentCollectionName(shopname: string): string {
+  // Sanitize shopname: lowercase, replace non-alphanumeric with hyphens
+  const sanitized = shopname.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+  return `${sanitized}-custom-content`;
+}
+
+/**
+ * Initialize custom content collection for a store
+ */
+export async function initializeCustomContentCollection(
+  shopname: string,
+  config?: QdrantConfig
+): Promise<void> {
+  console.log(`[Qdrant] Initializing custom content collection for store: ${shopname}`);
+
+  const collectionName = getCustomContentCollectionName(shopname);
+
+  if (!(await collectionExists(collectionName, config))) {
+    await createCollection(collectionName, [
+      { field: 'store_id', type: 'keyword' },
+      { field: 'content_id', type: 'keyword' },
+      { field: 'content_type', type: 'keyword' }, // 'text_entry' | 'pdf_upload'
+      { field: 'title', type: 'text' },
+      { field: 'chunk_index', type: 'integer' },
+      { field: 'created_at', type: 'keyword' },
+    ], config);
+  }
+
+  console.log(`[Qdrant] Custom content collection initialized for ${shopname}`);
+}
+
+/**
+ * Sync custom content to Qdrant
+ *
+ * @param storeId - Store UUID
+ * @param contentId - Custom content UUID
+ * @param shopname - Store name for collection naming
+ * @param chunks - Array of text chunks to embed
+ * @param metadata - Additional metadata (title, type, filename, etc.)
+ * @param config - Qdrant configuration
+ * @returns Array of Qdrant point IDs for tracking
+ */
+export async function syncCustomContent(
+  storeId: string,
+  contentId: string,
+  shopname: string,
+  chunks: string[],
+  metadata: {
+    title: string;
+    contentType: 'text_entry' | 'pdf_upload';
+    originalFilename?: string;
+    pageCount?: number;
+    createdAt: string;
+  },
+  config?: QdrantConfig
+): Promise<string[]> {
+  console.log(`[Qdrant] Syncing custom content ${contentId} (${chunks.length} chunks)`);
+
+  // Ensure collection exists
+  await initializeCustomContentCollection(shopname, config);
+
+  const collectionName = getCustomContentCollectionName(shopname);
+
+  // Generate embeddings for all chunks
+  const embeddings = await generateEmbeddingBatch(chunks);
+
+  // Create points for each chunk
+  const points: QdrantPoint[] = chunks.map((chunk, index) => {
+    const pointId = generatePointId('custom-content', storeId, `${contentId}-${index}`);
+
+    return {
+      id: pointId,
+      vector: embeddings[index],
+      payload: {
+        store_id: storeId,
+        content_id: contentId,
+        content_type: metadata.contentType,
+        title: metadata.title,
+        chunk_index: index,
+        total_chunks: chunks.length,
+        chunk_text: chunk,
+        original_filename: metadata.originalFilename,
+        page_count: metadata.pageCount,
+        created_at: metadata.createdAt,
+      },
+    };
+  });
+
+  // Upsert points to Qdrant
+  await upsertPoints(collectionName, points, config);
+
+  console.log(`[Qdrant] Successfully synced ${points.length} chunks for content ${contentId}`);
+
+  return points.map(p => p.id.toString());
+}
+
+/**
+ * Delete custom content from Qdrant
+ *
+ * @param shopname - Store name for collection naming
+ * @param pointIds - Array of Qdrant point IDs to delete
+ * @param config - Qdrant configuration
+ */
+export async function deleteCustomContent(
+  shopname: string,
+  pointIds: string[],
+  config?: QdrantConfig
+): Promise<void> {
+  if (pointIds.length === 0) {
+    return;
+  }
+
+  console.log(`[Qdrant] Deleting ${pointIds.length} custom content points`);
+
+  const collectionName = getCustomContentCollectionName(shopname);
+
+  // Check if collection exists
+  if (!(await collectionExists(collectionName, config))) {
+    console.log(`[Qdrant] Collection ${collectionName} does not exist, nothing to delete`);
+    return;
+  }
+
+  await deletePoints(collectionName, pointIds, config);
+
+  console.log(`[Qdrant] Successfully deleted ${pointIds.length} custom content points`);
+}
+
+/**
+ * Search custom content using semantic similarity
+ *
+ * @param shopname - Store name for collection naming
+ * @param query - Search query text
+ * @param limit - Maximum number of results
+ * @param config - Qdrant configuration
+ * @returns Array of search results with scores
+ */
+export async function searchCustomContent(
+  shopname: string,
+  query: string,
+  limit: number = 5,
+  config?: QdrantConfig
+): Promise<Array<{
+  contentId: string;
+  title: string;
+  contentType: string;
+  chunkText: string;
+  chunkIndex: number;
+  totalChunks: number;
+  score: number;
+  originalFilename?: string;
+  pageCount?: number;
+}>> {
+  const collectionName = getCustomContentCollectionName(shopname);
+
+  // Check if collection exists
+  if (!(await collectionExists(collectionName, config))) {
+    console.log(`[Qdrant] Collection ${collectionName} does not exist`);
+    return [];
+  }
+
+  // Generate query embedding
+  const queryEmbedding = await generateEmbedding(query);
+
+  // Search for similar vectors
+  const results = await searchPoints(collectionName, queryEmbedding, limit, undefined, config);
+
+  return results.map((result: any) => ({
+    contentId: result.payload.content_id,
+    title: result.payload.title,
+    contentType: result.payload.content_type,
+    chunkText: result.payload.chunk_text,
+    chunkIndex: result.payload.chunk_index,
+    totalChunks: result.payload.total_chunks,
+    score: result.score,
+    originalFilename: result.payload.original_filename,
+    pageCount: result.payload.page_count,
+  }));
+}
+
 /**
 /**
  * Generate a deterministic UUID from a string using a simple hash-based approach
  * Generate a deterministic UUID from a string using a simple hash-based approach
  * This creates valid UUIDs that Qdrant accepts, while being reproducible
  * This creates valid UUIDs that Qdrant accepts, while being reproducible

+ 394 - 0
supabase/functions/_shared/text-chunker.ts

@@ -0,0 +1,394 @@
+/**
+ * Text Chunking Utilities
+ *
+ * Implements hybrid chunking strategy for RAG:
+ * - Small documents (≤2500 tokens): Store as single chunk
+ * - Large documents (>2500 tokens): Split into overlapping chunks
+ *
+ * Uses token-based chunking with overlap to preserve context across chunks
+ */
+
+import { estimateTokenCount } from './pdf-processor.ts';
+
+/**
+ * Configuration for chunking strategy
+ */
+export interface ChunkConfig {
+  /** Maximum tokens for single-chunk threshold */
+  maxTokensForSingleChunk: number;
+  /** Target tokens per chunk (for large documents) */
+  chunkSizeTokens: number;
+  /** Overlap between chunks (in tokens) */
+  overlapTokens: number;
+  /** Minimum chunk size (avoid tiny chunks) */
+  minChunkSizeTokens: number;
+}
+
+/**
+ * Default chunking configuration
+ * Optimized for 3072-dimension embeddings
+ */
+export const DEFAULT_CHUNK_CONFIG: ChunkConfig = {
+  maxTokensForSingleChunk: 2500, // Safe margin for 3072-dim vectors
+  chunkSizeTokens: 500, // Conservative chunk size
+  overlapTokens: 100, // 20% overlap
+  minChunkSizeTokens: 50, // Avoid very small chunks
+};
+
+/**
+ * Result of chunking operation
+ */
+export interface ChunkResult {
+  chunks: string[];
+  strategy: 'single' | 'multi';
+  totalTokens: number;
+  chunkTokens: number[];
+}
+
+/**
+ * Determine if text should be chunked
+ *
+ * @param text - Text to analyze
+ * @param config - Chunking configuration
+ * @returns true if text exceeds single-chunk threshold
+ */
+export function shouldChunk(
+  text: string,
+  config: ChunkConfig = DEFAULT_CHUNK_CONFIG
+): boolean {
+  const tokenCount = estimateTokenCount(text);
+  return tokenCount > config.maxTokensForSingleChunk;
+}
+
+/**
+ * Chunk text using hybrid strategy
+ *
+ * Strategy:
+ * - If text fits in single chunk → return as-is
+ * - If text is large → split into overlapping chunks
+ *
+ * @param text - Text to chunk
+ * @param config - Chunking configuration
+ * @returns Chunk result with strategy and token counts
+ */
+export function chunkText(
+  text: string,
+  config: ChunkConfig = DEFAULT_CHUNK_CONFIG
+): ChunkResult {
+  const totalTokens = estimateTokenCount(text);
+
+  // Strategy 1: Single chunk (small documents)
+  if (totalTokens <= config.maxTokensForSingleChunk) {
+    return {
+      chunks: [text],
+      strategy: 'single',
+      totalTokens,
+      chunkTokens: [totalTokens],
+    };
+  }
+
+  // Strategy 2: Multi-chunk with overlap (large documents)
+  const chunks = chunkTextWithOverlap(text, config);
+
+  return {
+    chunks,
+    strategy: 'multi',
+    totalTokens,
+    chunkTokens: chunks.map(estimateTokenCount),
+  };
+}
+
+/**
+ * Split text into overlapping chunks
+ *
+ * Uses character-based splitting with token estimation
+ * to approximate token-based chunking
+ *
+ * @param text - Text to split
+ * @param config - Chunking configuration
+ * @returns Array of text chunks
+ */
+function chunkTextWithOverlap(
+  text: string,
+  config: ChunkConfig
+): string[] {
+  const chunks: string[] = [];
+
+  // Estimate characters per token (average ~4 chars/token)
+  const charsPerToken = 4;
+  const chunkSizeChars = config.chunkSizeTokens * charsPerToken;
+  const overlapChars = config.overlapTokens * charsPerToken;
+
+  let startIndex = 0;
+
+  while (startIndex < text.length) {
+    // Calculate chunk end position
+    let endIndex = startIndex + chunkSizeChars;
+
+    // If this is the last chunk, take everything
+    if (endIndex >= text.length) {
+      endIndex = text.length;
+    } else {
+      // Try to break at sentence boundary near the end
+      endIndex = findSentenceBoundary(
+        text,
+        endIndex,
+        Math.min(endIndex + 200, text.length) // Look ahead max 200 chars
+      );
+    }
+
+    // Extract chunk
+    const chunk = text.substring(startIndex, endIndex).trim();
+
+    // Only add non-empty chunks that meet minimum size
+    if (chunk.length > 0 && estimateTokenCount(chunk) >= config.minChunkSizeTokens) {
+      chunks.push(chunk);
+    }
+
+    // Move to next chunk with overlap
+    // If this was the last chunk, break
+    if (endIndex >= text.length) {
+      break;
+    }
+
+    startIndex = endIndex - overlapChars;
+
+    // Ensure we're making progress (avoid infinite loop)
+    if (startIndex < chunks.length * chunkSizeChars * 0.5) {
+      startIndex = endIndex;
+    }
+  }
+
+  // If no chunks were created (shouldn't happen), return original text
+  if (chunks.length === 0) {
+    return [text];
+  }
+
+  return chunks;
+}
+
+/**
+ * Find nearest sentence boundary after target position
+ *
+ * Looks for sentence-ending punctuation (. ! ?) followed by space or newline
+ * Falls back to word boundaries to avoid splitting words
+ *
+ * @param text - Full text
+ * @param targetPos - Target position to find boundary near
+ * @param maxPos - Maximum position to search
+ * @returns Position of sentence boundary
+ */
+function findSentenceBoundary(
+  text: string,
+  targetPos: number,
+  maxPos: number
+): number {
+  // Look for sentence endings: . ! ? followed by whitespace
+  const sentenceEnders = /[.!?]+[\s\n]/g;
+
+  // Start from target position
+  const searchText = text.substring(targetPos, maxPos);
+
+  sentenceEnders.lastIndex = 0;
+  const match = sentenceEnders.exec(searchText);
+
+  if (match && match.index !== undefined) {
+    // Found a sentence boundary
+    return targetPos + match.index + match[0].length;
+  }
+
+  // No sentence boundary found, try paragraph break
+  const paragraphBreak = searchText.indexOf('\n\n');
+  if (paragraphBreak !== -1) {
+    return targetPos + paragraphBreak + 2;
+  }
+
+  // No paragraph break found, try single newline
+  const lineBreak = searchText.indexOf('\n');
+  if (lineBreak !== -1) {
+    return targetPos + lineBreak + 1;
+  }
+
+  // No natural boundary found, find nearest word boundary
+  // to avoid splitting words mid-word
+  return findWordBoundary(text, targetPos, maxPos);
+}
+
+/**
+ * Find nearest word boundary (whitespace) after target position
+ *
+ * Ensures chunks don't split in the middle of words
+ *
+ * @param text - Full text
+ * @param targetPos - Target position to find boundary near
+ * @param maxPos - Maximum position to search
+ * @returns Position of word boundary
+ */
+function findWordBoundary(
+  text: string,
+  targetPos: number,
+  maxPos: number
+): number {
+  // Look for whitespace characters
+  const searchText = text.substring(targetPos, maxPos);
+
+  // Find first whitespace after target position
+  const whitespaceMatch = searchText.match(/\s/);
+  if (whitespaceMatch && whitespaceMatch.index !== undefined) {
+    return targetPos + whitespaceMatch.index + 1;
+  }
+
+  // No whitespace found in search range
+  // Look backwards from targetPos to find last word boundary before it
+  // This ensures we don't split a word
+  let pos = targetPos;
+  while (pos > 0 && !/\s/.test(text[pos - 1])) {
+    pos--;
+  }
+
+  // If we found a word boundary before targetPos, use it
+  if (pos > 0 && pos < targetPos) {
+    return pos;
+  }
+
+  // Last resort: use target position (shouldn't happen often)
+  return targetPos;
+}
+
+/**
+ * Split text by paragraphs
+ *
+ * Alternative chunking strategy that preserves paragraph structure
+ *
+ * @param text - Text to split
+ * @param maxTokensPerChunk - Maximum tokens per chunk
+ * @returns Array of paragraph-based chunks
+ */
+export function chunkByParagraphs(
+  text: string,
+  maxTokensPerChunk: number = 500
+): string[] {
+  // Split by double newlines (paragraph breaks)
+  const paragraphs = text.split(/\n\n+/).map(p => p.trim()).filter(p => p.length > 0);
+
+  const chunks: string[] = [];
+  let currentChunk = '';
+
+  for (const paragraph of paragraphs) {
+    const paragraphTokens = estimateTokenCount(paragraph);
+
+    // If single paragraph exceeds max, it becomes its own chunk
+    if (paragraphTokens > maxTokensPerChunk) {
+      // Save current chunk if not empty
+      if (currentChunk) {
+        chunks.push(currentChunk.trim());
+        currentChunk = '';
+      }
+      // Add large paragraph as its own chunk
+      chunks.push(paragraph);
+      continue;
+    }
+
+    // Check if adding this paragraph would exceed limit
+    const combinedTokens = estimateTokenCount(currentChunk + '\n\n' + paragraph);
+
+    if (combinedTokens > maxTokensPerChunk && currentChunk.length > 0) {
+      // Save current chunk and start new one
+      chunks.push(currentChunk.trim());
+      currentChunk = paragraph;
+    } else {
+      // Add to current chunk
+      currentChunk += (currentChunk ? '\n\n' : '') + paragraph;
+    }
+  }
+
+  // Add final chunk
+  if (currentChunk) {
+    chunks.push(currentChunk.trim());
+  }
+
+  return chunks.length > 0 ? chunks : [text];
+}
+
+/**
+ * Get chunking statistics for text
+ *
+ * Useful for debugging and optimization
+ *
+ * @param text - Text to analyze
+ * @param config - Chunking configuration
+ * @returns Statistics about how text would be chunked
+ */
+export function getChunkingStats(
+  text: string,
+  config: ChunkConfig = DEFAULT_CHUNK_CONFIG
+): {
+  totalCharacters: number;
+  totalTokens: number;
+  estimatedChunks: number;
+  strategy: 'single' | 'multi';
+  averageChunkTokens: number;
+} {
+  const totalTokens = estimateTokenCount(text);
+  const totalCharacters = text.length;
+
+  if (totalTokens <= config.maxTokensForSingleChunk) {
+    return {
+      totalCharacters,
+      totalTokens,
+      estimatedChunks: 1,
+      strategy: 'single',
+      averageChunkTokens: totalTokens,
+    };
+  }
+
+  // Estimate number of chunks needed
+  const effectiveChunkSize = config.chunkSizeTokens - config.overlapTokens;
+  const estimatedChunks = Math.ceil(totalTokens / effectiveChunkSize);
+
+  return {
+    totalCharacters,
+    totalTokens,
+    estimatedChunks,
+    strategy: 'multi',
+    averageChunkTokens: Math.ceil(totalTokens / estimatedChunks),
+  };
+}
+
+/**
+ * Validate chunk configuration
+ *
+ * @param config - Configuration to validate
+ * @returns Validation result with any errors
+ */
+export function validateChunkConfig(config: ChunkConfig): {
+  valid: boolean;
+  errors: string[];
+} {
+  const errors: string[] = [];
+
+  if (config.maxTokensForSingleChunk < 100) {
+    errors.push('maxTokensForSingleChunk must be at least 100');
+  }
+
+  if (config.chunkSizeTokens < config.minChunkSizeTokens) {
+    errors.push('chunkSizeTokens must be greater than minChunkSizeTokens');
+  }
+
+  if (config.overlapTokens >= config.chunkSizeTokens) {
+    errors.push('overlapTokens must be less than chunkSizeTokens');
+  }
+
+  if (config.overlapTokens < 0) {
+    errors.push('overlapTokens must be non-negative');
+  }
+
+  if (config.minChunkSizeTokens < 10) {
+    errors.push('minChunkSizeTokens should be at least 10');
+  }
+
+  return {
+    valid: errors.length === 0,
+    errors,
+  };
+}

+ 218 - 0
supabase/functions/custom-content-create/index.ts

@@ -0,0 +1,218 @@
+/**
+ * Custom Content Create Edge Function
+ *
+ * Creates text-based custom content entries:
+ * 1. Validate input
+ * 2. Create database entry
+ * 3. Chunk text if needed
+ * 4. Generate embeddings
+ * 5. Store in Qdrant
+ * 6. Update database with results
+ *
+ * POST /custom-content-create
+ * Body: { title, content_text, store_id }
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { chunkText } from '../_shared/text-chunker.ts';
+import { syncCustomContent } from '../_shared/qdrant-client.ts';
+import { validateContent } from '../_shared/content-validator.ts';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse request body
+    const { title, content_text, store_id } = await req.json();
+
+    // Validate inputs
+    if (!title || !content_text || !store_id) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required fields: title, content_text, store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Validate content length
+    if (content_text.trim().length === 0) {
+      return new Response(
+        JSON.stringify({ error: 'Content text cannot be empty' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    if (content_text.length > 50000) {
+      return new Response(
+        JSON.stringify({ error: 'Content text exceeds maximum length of 50,000 characters' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Validate content format (reject code/structured data)
+    const validationResult = validateContent(content_text);
+    if (!validationResult.isValid) {
+      return new Response(
+        JSON.stringify({
+          error: validationResult.reason || 'Invalid content format',
+          detectedFormat: validationResult.detectedFormat,
+        }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Verify user owns the store
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('id, store_name, qdrant_url, qdrant_api_key')
+      .eq('id', store_id)
+      .eq('user_id', user.id)
+      .single();
+
+    if (storeError || !store) {
+      return new Response(
+        JSON.stringify({ error: 'Store not found or access denied' }),
+        { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    console.log(`[Create] Creating text entry for store ${store_id}`);
+
+    // Create database entry
+    const { data: newContent, error: insertError } = await supabase
+      .from('custom_content')
+      .insert({
+        store_id: store_id,
+        user_id: user.id,
+        content_type: 'text_entry',
+        title: title,
+        content_text: content_text,
+        sync_status: 'processing',
+        sync_started_at: new Date().toISOString(),
+      })
+      .select('id, created_at')
+      .single();
+
+    if (insertError || !newContent) {
+      throw insertError || new Error('Failed to create database entry');
+    }
+
+    const contentId = newContent.id;
+
+    try {
+      // Combine title and content for chunking
+      // Title is prepended to content so it's searchable in the knowledge base
+      const fullText = `${title}\n${content_text}`;
+
+      // Chunk text using hybrid strategy
+      console.log(`[Create] Chunking text...`);
+      const chunkResult = chunkText(fullText);
+      console.log(`[Create] Created ${chunkResult.chunks.length} chunks using ${chunkResult.strategy} strategy`);
+
+      // Get Qdrant config from store
+      const qdrantConfig = store.qdrant_url && store.qdrant_api_key
+        ? { url: store.qdrant_url, apiKey: store.qdrant_api_key }
+        : undefined;
+
+      // Sync to Qdrant
+      console.log(`[Create] Syncing to Qdrant...`);
+      const pointIds = await syncCustomContent(
+        store_id,
+        contentId,
+        store.store_name,
+        chunkResult.chunks,
+        {
+          title: title,
+          contentType: 'text_entry',
+          createdAt: newContent.created_at,
+        },
+        qdrantConfig
+      );
+
+      console.log(`[Create] Synced ${pointIds.length} points to Qdrant`);
+
+      // Update database with success
+      await supabase
+        .from('custom_content')
+        .update({
+          chunk_count: chunkResult.chunks.length,
+          qdrant_point_ids: pointIds,
+          sync_status: 'completed',
+          sync_completed_at: new Date().toISOString(),
+          sync_error: null,
+        })
+        .eq('id', contentId);
+
+      console.log(`[Create] Successfully created content ${contentId}`);
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          contentId: contentId,
+          title: title,
+          chunkCount: chunkResult.chunks.length,
+          strategy: chunkResult.strategy,
+          qdrantPoints: pointIds.length,
+          status: 'completed',
+        }),
+        { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+
+    } catch (processingError: any) {
+      console.error('[Create] Processing error:', processingError);
+
+      // Update database with error
+      await supabase
+        .from('custom_content')
+        .update({
+          sync_status: 'failed',
+          sync_completed_at: new Date().toISOString(),
+          sync_error: processingError.message || processingError.toString(),
+        })
+        .eq('id', contentId);
+
+      throw processingError;
+    }
+
+  } catch (error: any) {
+    console.error('[Create] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 170 - 0
supabase/functions/custom-content-delete/index.ts

@@ -0,0 +1,170 @@
+/**
+ * Custom Content Delete Edge Function
+ *
+ * Deletes custom content (hard delete):
+ * 1. Verify ownership
+ * 2. Delete from Qdrant
+ * 3. Delete files from Storage
+ * 4. Delete database record
+ *
+ * DELETE /custom-content-delete
+ * Body: { content_id, store_id }
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { deleteCustomContent } from '../_shared/qdrant-client.ts';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse request body
+    const { content_id, store_id } = await req.json();
+
+    if (!content_id || !store_id) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required fields: content_id, store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    console.log(`[Delete] Deleting content ${content_id} from store ${store_id}`);
+
+    // Get content record and verify ownership
+    const { data: content, error: contentError } = await supabase
+      .from('custom_content')
+      .select('*, stores!inner(store_name, user_id, qdrant_url, qdrant_api_key)')
+      .eq('id', content_id)
+      .eq('store_id', store_id)
+      .single();
+
+    if (contentError || !content) {
+      return new Response(
+        JSON.stringify({ error: 'Content not found' }),
+        { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Verify user owns the store
+    if (content.stores.user_id !== user.id) {
+      return new Response(
+        JSON.stringify({ error: 'Access denied' }),
+        { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Delete from Qdrant
+    if (content.qdrant_point_ids && content.qdrant_point_ids.length > 0) {
+      console.log(`[Delete] Deleting ${content.qdrant_point_ids.length} points from Qdrant`);
+
+      try {
+        const qdrantConfig = content.stores.qdrant_url && content.stores.qdrant_api_key
+          ? { url: content.stores.qdrant_url, apiKey: content.stores.qdrant_api_key }
+          : undefined;
+
+        await deleteCustomContent(
+          content.stores.store_name,
+          content.qdrant_point_ids,
+          qdrantConfig
+        );
+
+        console.log(`[Delete] Successfully deleted from Qdrant`);
+      } catch (qdrantError: any) {
+        console.error('[Delete] Qdrant deletion error:', qdrantError);
+        // Continue with deletion even if Qdrant fails
+      }
+    }
+
+    // Delete files from Storage
+    const filesToDelete: string[] = [];
+    if (content.storage_path_pdf) {
+      filesToDelete.push(content.storage_path_pdf);
+    }
+    if (content.storage_path_txt) {
+      filesToDelete.push(content.storage_path_txt);
+    }
+
+    if (filesToDelete.length > 0) {
+      console.log(`[Delete] Deleting ${filesToDelete.length} files from storage`);
+
+      try {
+        const { error: storageError } = await supabase.storage
+          .from('custom-content')
+          .remove(filesToDelete);
+
+        if (storageError) {
+          console.error('[Delete] Storage deletion error:', storageError);
+          // Continue with deletion even if storage fails
+        } else {
+          console.log(`[Delete] Successfully deleted files from storage`);
+        }
+      } catch (storageError: any) {
+        console.error('[Delete] Storage deletion error:', storageError);
+      }
+    }
+
+    // Delete database record
+    console.log(`[Delete] Deleting database record`);
+    const { error: deleteError } = await supabase
+      .from('custom_content')
+      .delete()
+      .eq('id', content_id);
+
+    if (deleteError) {
+      throw deleteError;
+    }
+
+    console.log(`[Delete] Successfully deleted content ${content_id}`);
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        contentId: content_id,
+        message: 'Content deleted successfully',
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[Delete] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 128 - 0
supabase/functions/custom-content-list/index.ts

@@ -0,0 +1,128 @@
+/**
+ * Custom Content List Edge Function
+ *
+ * Lists all custom content for a store
+ *
+ * GET /custom-content-list?store_id={uuid}&content_type={optional}
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse query parameters
+    const url = new URL(req.url);
+    const storeId = url.searchParams.get('store_id');
+    const contentType = url.searchParams.get('content_type');
+
+    if (!storeId) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required parameter: store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Verify user owns the store
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('id')
+      .eq('id', storeId)
+      .eq('user_id', user.id)
+      .single();
+
+    if (storeError || !store) {
+      return new Response(
+        JSON.stringify({ error: 'Store not found or access denied' }),
+        { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Build query
+    let query = supabase
+      .from('custom_content')
+      .select('*')
+      .eq('store_id', storeId)
+      .order('created_at', { ascending: false });
+
+    // Filter by content type if specified
+    if (contentType) {
+      query = query.eq('content_type', contentType);
+    }
+
+    const { data: contentList, error: listError } = await query;
+
+    if (listError) {
+      throw listError;
+    }
+
+    // Format response
+    const formattedContent = contentList.map((item) => ({
+      id: item.id,
+      title: item.title,
+      contentType: item.content_type,
+      originalFilename: item.original_filename,
+      fileSize: item.file_size_bytes,
+      pageCount: item.page_count,
+      chunkCount: item.chunk_count,
+      syncStatus: item.sync_status,
+      syncError: item.sync_error,
+      syncStartedAt: item.sync_started_at,
+      syncCompletedAt: item.sync_completed_at,
+      createdAt: item.created_at,
+      updatedAt: item.updated_at,
+    }));
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        content: formattedContent,
+        total: formattedContent.length,
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[List] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 244 - 0
supabase/functions/custom-content-process/index.ts

@@ -0,0 +1,244 @@
+/**
+ * Custom Content Process Edge Function
+ *
+ * Asynchronous processing of uploaded PDF files:
+ * 1. Download PDF from Storage
+ * 2. Extract text using pdf-parse
+ * 3. Save extracted text to Storage
+ * 4. Chunk text if needed (hybrid strategy)
+ * 5. Generate embeddings
+ * 6. Store in Qdrant
+ * 7. Update database with results
+ *
+ * POST /custom-content-process
+ * Body: { content_id, store_id }
+ *
+ * Note: This function is triggered internally by custom-content-upload
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { extractTextFromPDF, sanitizeExtractedText } from '../_shared/pdf-processor.ts';
+import { chunkText } from '../_shared/text-chunker.ts';
+import { syncCustomContent } from '../_shared/qdrant-client.ts';
+import { validatePDFText } from '../_shared/content-validator.ts';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  // Parse request body FIRST before any error handling
+  let content_id: string | undefined;
+  let store_id: string | undefined;
+
+  try {
+    const body = await req.json();
+    content_id = body.content_id;
+    store_id = body.store_id;
+  } catch (parseError) {
+    console.error('[Process] Failed to parse request body:', parseError);
+    return new Response(
+      JSON.stringify({ error: 'Invalid request body' }),
+      { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    if (!content_id || !store_id) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required fields: content_id, store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    console.log(`[Process] Starting processing for content ${content_id}`);
+
+    // Get content record
+    const { data: content, error: contentError } = await supabase
+      .from('custom_content')
+      .select('*, stores!inner(store_name)')
+      .eq('id', content_id)
+      .eq('store_id', store_id)
+      .single();
+
+    if (contentError || !content) {
+      throw new Error(`Content not found: ${contentError?.message}`);
+    }
+
+    // Update status to processing
+    await supabase
+      .from('custom_content')
+      .update({
+        sync_status: 'processing',
+        sync_started_at: new Date().toISOString(),
+        sync_error: null,
+      })
+      .eq('id', content_id);
+
+    // Download PDF from Storage
+    console.log(`[Process] Downloading PDF from storage: ${content.storage_path_pdf}`);
+    const { data: pdfData, error: downloadError } = await supabase.storage
+      .from('custom-content')
+      .download(content.storage_path_pdf);
+
+    if (downloadError || !pdfData) {
+      throw new Error(`Failed to download PDF: ${downloadError?.message}`);
+    }
+
+    // Convert Blob to ArrayBuffer
+    const pdfBuffer = await pdfData.arrayBuffer();
+    const pdfUint8Array = new Uint8Array(pdfBuffer);
+
+    // Extract text from PDF
+    console.log(`[Process] Extracting text from PDF...`);
+    const { text, pageCount, metadata } = await extractTextFromPDF(pdfUint8Array);
+
+    if (!text || text.trim().length === 0) {
+      throw new Error('No text could be extracted from the PDF');
+    }
+
+    console.log(`[Process] Extracted ${text.length} characters from ${pageCount} pages`);
+
+    // Sanitize extracted text
+    const cleanText = sanitizeExtractedText(text);
+
+    // Validate PDF content (reject code/structured data)
+    const validationResult = validatePDFText(cleanText);
+    if (!validationResult.isValid) {
+      throw new Error(
+        `Invalid PDF content: ${validationResult.reason}. ` +
+        `Detected format: ${validationResult.detectedFormat}. ` +
+        `Only plain text and Markdown PDFs are allowed.`
+      );
+    }
+
+    // Save extracted text to Storage
+    const txtStoragePath = `${store_id}/${content_id}.txt`;
+    console.log(`[Process] Saving extracted text to: ${txtStoragePath}`);
+
+    const txtBlob = new Blob([cleanText], { type: 'text/plain' });
+    const { error: txtUploadError } = await supabase.storage
+      .from('custom-content')
+      .upload(txtStoragePath, txtBlob, {
+        contentType: 'text/plain',
+        upsert: true,
+      });
+
+    if (txtUploadError) {
+      console.error('[Process] Failed to upload text file:', txtUploadError);
+    }
+
+    // Chunk text using hybrid strategy
+    console.log(`[Process] Chunking text...`);
+    const chunkResult = chunkText(cleanText);
+    console.log(`[Process] Created ${chunkResult.chunks.length} chunks using ${chunkResult.strategy} strategy`);
+
+    // Get Qdrant config from store
+    const { data: storeConfig } = await supabase
+      .from('stores')
+      .select('qdrant_url, qdrant_api_key, store_name')
+      .eq('id', store_id)
+      .single();
+
+    const qdrantConfig = storeConfig?.qdrant_url && storeConfig?.qdrant_api_key
+      ? { url: storeConfig.qdrant_url, apiKey: storeConfig.qdrant_api_key }
+      : undefined;
+
+    // Sync to Qdrant
+    console.log(`[Process] Syncing to Qdrant...`);
+    const pointIds = await syncCustomContent(
+      store_id,
+      content_id,
+      storeConfig?.store_name || store_id,
+      chunkResult.chunks,
+      {
+        title: content.title,
+        contentType: 'pdf_upload',
+        originalFilename: content.original_filename,
+        pageCount: pageCount,
+        createdAt: content.created_at,
+      },
+      qdrantConfig
+    );
+
+    console.log(`[Process] Synced ${pointIds.length} points to Qdrant`);
+
+    // Update database with success
+    await supabase
+      .from('custom_content')
+      .update({
+        content_text: cleanText,
+        storage_path_txt: txtStoragePath,
+        page_count: pageCount,
+        chunk_count: chunkResult.chunks.length,
+        qdrant_point_ids: pointIds,
+        sync_status: 'completed',
+        sync_completed_at: new Date().toISOString(),
+        sync_error: null,
+      })
+      .eq('id', content_id);
+
+    console.log(`[Process] Successfully processed content ${content_id}`);
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        contentId: content_id,
+        pageCount: pageCount,
+        chunkCount: chunkResult.chunks.length,
+        strategy: chunkResult.strategy,
+        qdrantPoints: pointIds.length,
+        status: 'completed',
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[Process] Error:', error);
+    console.error('[Process] Error stack:', error.stack);
+
+    // Try to update database with error (content_id is already parsed above)
+    if (content_id) {
+      try {
+        const supabase = createClient(
+          Deno.env.get('SUPABASE_URL')!,
+          Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+        );
+
+        await supabase
+          .from('custom_content')
+          .update({
+            sync_status: 'failed',
+            sync_completed_at: new Date().toISOString(),
+            sync_error: error.message || error.toString(),
+          })
+          .eq('id', content_id);
+
+        console.log('[Process] Updated database with error status for content:', content_id);
+      } catch (updateError) {
+        console.error('[Process] Failed to update error status:', updateError);
+      }
+    } else {
+      console.error('[Process] Cannot update error status - content_id is undefined');
+    }
+
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 126 - 0
supabase/functions/custom-content-retry/index.ts

@@ -0,0 +1,126 @@
+/**
+ * Custom Content Retry Edge Function
+ *
+ * Manually retry processing for stuck content
+ *
+ * POST /custom-content-retry
+ * Body: { content_id, store_id }
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse request body
+    const { content_id, store_id } = await req.json();
+
+    if (!content_id || !store_id) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required fields: content_id, store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Verify user owns this content
+    const { data: content, error: contentError } = await supabase
+      .from('custom_content')
+      .select('id, user_id, sync_status')
+      .eq('id', content_id)
+      .eq('store_id', store_id)
+      .eq('user_id', user.id)
+      .single();
+
+    if (contentError || !content) {
+      return new Response(
+        JSON.stringify({ error: 'Content not found or access denied' }),
+        { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    console.log(`[Retry] Retrying processing for content ${content_id}`);
+
+    // Reset status to pending
+    await supabase
+      .from('custom_content')
+      .update({
+        sync_status: 'pending',
+        sync_started_at: null,
+        sync_completed_at: null,
+        sync_error: null,
+      })
+      .eq('id', content_id);
+
+    // Trigger processing
+    const processUrl = `${supabaseUrl}/functions/v1/custom-content-process`;
+    const processResponse = await fetch(processUrl, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${supabaseServiceKey}`,
+      },
+      body: JSON.stringify({
+        content_id: content_id,
+        store_id: store_id,
+      }),
+    });
+
+    if (!processResponse.ok) {
+      const errorText = await processResponse.text();
+      console.error('[Retry] Processing trigger failed:', errorText);
+      throw new Error(`Failed to trigger processing: ${errorText}`);
+    }
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        message: 'Processing retry initiated',
+        contentId: content_id,
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[Retry] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 140 - 0
supabase/functions/custom-content-sync-status/index.ts

@@ -0,0 +1,140 @@
+/**
+ * Custom Content Sync Status Edge Function
+ *
+ * Returns the current sync status for a content entry
+ * Used for polling during async processing
+ *
+ * GET /custom-content-sync-status?content_id={uuid}
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse query parameter
+    const url = new URL(req.url);
+    const contentId = url.searchParams.get('content_id');
+
+    if (!contentId) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required parameter: content_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Get content record and verify ownership
+    const { data: content, error: contentError } = await supabase
+      .from('custom_content')
+      .select(`
+        id,
+        title,
+        content_type,
+        sync_status,
+        sync_started_at,
+        sync_completed_at,
+        sync_error,
+        chunk_count,
+        page_count,
+        file_size_bytes,
+        stores!inner(user_id)
+      `)
+      .eq('id', contentId)
+      .single();
+
+    if (contentError || !content) {
+      return new Response(
+        JSON.stringify({ error: 'Content not found' }),
+        { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Verify user owns the store
+    if (content.stores.user_id !== user.id) {
+      return new Response(
+        JSON.stringify({ error: 'Access denied' }),
+        { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Calculate progress percentage
+    let progressPercent = 0;
+    if (content.sync_status === 'completed') {
+      progressPercent = 100;
+    } else if (content.sync_status === 'processing') {
+      progressPercent = 50; // Rough estimate
+    } else if (content.sync_status === 'pending') {
+      progressPercent = 0;
+    }
+
+    // Calculate duration if available
+    let durationMs = null;
+    if (content.sync_started_at && content.sync_completed_at) {
+      const startTime = new Date(content.sync_started_at).getTime();
+      const endTime = new Date(content.sync_completed_at).getTime();
+      durationMs = endTime - startTime;
+    }
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        contentId: content.id,
+        title: content.title,
+        contentType: content.content_type,
+        status: content.sync_status,
+        progress: progressPercent,
+        chunkCount: content.chunk_count,
+        pageCount: content.page_count,
+        fileSize: content.file_size_bytes,
+        startedAt: content.sync_started_at,
+        completedAt: content.sync_completed_at,
+        durationMs: durationMs,
+        error: content.sync_error,
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[SyncStatus] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 259 - 0
supabase/functions/custom-content-upload/index.ts

@@ -0,0 +1,259 @@
+/**
+ * Custom Content Upload Edge Function
+ *
+ * Handles PDF file uploads with deduplication:
+ * 1. Receive PDF file + metadata
+ * 2. Calculate SHA-256 checksum
+ * 3. Check for duplicate (same store + checksum)
+ * 4. Upload to Supabase Storage (store_id/uuid.pdf)
+ * 5. Create database entry with status='pending'
+ * 6. Trigger async processing
+ *
+ * POST /custom-content-upload
+ * Content-Type: multipart/form-data
+ * Body:
+ *   - file: PDF file
+ *   - title: Content title
+ *   - store_id: Store UUID
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+import { calculateChecksum } from '../_shared/pdf-processor.ts';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+interface UploadRequest {
+  file: File;
+  title: string;
+  store_id: string;
+}
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse multipart form data
+    const formData = await req.formData();
+    const file = formData.get('file') as File | null;
+    const title = formData.get('title') as string | null;
+    const storeId = formData.get('store_id') as string | null;
+
+    // Validate inputs
+    if (!file || !title || !storeId) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required fields: file, title, store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Validate file type
+    if (file.type !== 'application/pdf') {
+      return new Response(
+        JSON.stringify({ error: 'Only PDF files are supported' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Validate file size (10MB limit)
+    const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
+    if (file.size > MAX_FILE_SIZE) {
+      return new Response(
+        JSON.stringify({ error: 'File size exceeds 10MB limit' }),
+        { status: 413, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Verify user owns the store
+    const { data: store, error: storeError } = await supabase
+      .from('stores')
+      .select('id, store_name, platform_name')
+      .eq('id', storeId)
+      .eq('user_id', user.id)
+      .single();
+
+    if (storeError || !store) {
+      return new Response(
+        JSON.stringify({ error: 'Store not found or access denied' }),
+        { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Read file buffer
+    const arrayBuffer = await file.arrayBuffer();
+    const fileBuffer = new Uint8Array(arrayBuffer);
+
+    // Calculate checksum
+    console.log('[Upload] Calculating checksum...');
+    const checksum = await calculateChecksum(fileBuffer);
+    console.log('[Upload] Checksum:', checksum);
+
+    // Check for duplicate
+    const { data: existingContent, error: duplicateError } = await supabase
+      .from('custom_content')
+      .select('id, title, created_at')
+      .eq('store_id', storeId)
+      .eq('file_checksum', checksum)
+      .single();
+
+    if (duplicateError && duplicateError.code !== 'PGRST116') { // PGRST116 = not found
+      throw duplicateError;
+    }
+
+    if (existingContent) {
+      // File already exists
+      const createdDate = new Date(existingContent.created_at).toLocaleDateString();
+      return new Response(
+        JSON.stringify({
+          error: 'This file already exists',
+          duplicate: true,
+          existingContent: {
+            id: existingContent.id,
+            title: existingContent.title,
+            uploadedAt: createdDate,
+          },
+        }),
+        { status: 409, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Generate UUID for content
+    const { data: newContent, error: insertError } = await supabase
+      .from('custom_content')
+      .insert({
+        store_id: storeId,
+        user_id: user.id,
+        content_type: 'pdf_upload',
+        title: title,
+        original_filename: file.name,
+        file_checksum: checksum,
+        file_size_bytes: file.size,
+        sync_status: 'pending',
+      })
+      .select('id')
+      .single();
+
+    if (insertError || !newContent) {
+      throw insertError || new Error('Failed to create database entry');
+    }
+
+    const contentId = newContent.id;
+
+    // Upload file to Supabase Storage
+    const storagePath = `${storeId}/${contentId}.pdf`;
+    console.log('[Upload] Uploading to storage:', storagePath);
+
+    const { error: uploadError } = await supabase.storage
+      .from('custom-content')
+      .upload(storagePath, fileBuffer, {
+        contentType: 'application/pdf',
+        upsert: false,
+      });
+
+    if (uploadError) {
+      // Rollback database entry
+      await supabase
+        .from('custom_content')
+        .delete()
+        .eq('id', contentId);
+
+      throw uploadError;
+    }
+
+    // Update database with storage path
+    const { error: updateError } = await supabase
+      .from('custom_content')
+      .update({
+        storage_path_pdf: storagePath,
+      })
+      .eq('id', contentId);
+
+    if (updateError) {
+      console.error('[Upload] Failed to update storage path:', updateError);
+    }
+
+    // Trigger async processing
+    console.log('[Upload] Triggering async processing for content:', contentId);
+
+    // Call custom-content-process function asynchronously
+    // Use Promise.resolve to ensure it fires even if we return early
+    const processUrl = `${supabaseUrl}/functions/v1/custom-content-process`;
+    Promise.resolve().then(async () => {
+      try {
+        const response = await fetch(processUrl, {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            'Authorization': `Bearer ${supabaseServiceKey}`,
+          },
+          body: JSON.stringify({
+            content_id: contentId,
+            store_id: storeId,
+          }),
+        });
+
+        if (!response.ok) {
+          const errorText = await response.text();
+          console.error('[Upload] Processing trigger failed:', response.status, errorText);
+        } else {
+          console.log('[Upload] Processing triggered successfully');
+        }
+      } catch (error) {
+        console.error('[Upload] Failed to trigger processing:', error);
+      }
+    });
+
+    return new Response(
+      JSON.stringify({
+        success: true,
+        contentId: contentId,
+        title: title,
+        fileName: file.name,
+        fileSize: file.size,
+        checksum: checksum,
+        status: 'pending',
+        message: 'File uploaded successfully. Processing started.',
+      }),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[Upload] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 130 - 0
supabase/functions/custom-content-view/index.ts

@@ -0,0 +1,130 @@
+/**
+ * Custom Content View Edge Function
+ *
+ * Retrieves content for viewing:
+ * - For text entries: Returns the content_text directly
+ * - For PDFs: Returns both content_text and a signed URL to the PDF
+ *
+ * GET /custom-content-view?content_id=xxx&store_id=xxx
+ */
+
+import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
+
+const corsHeaders = {
+  'Access-Control-Allow-Origin': '*',
+  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
+};
+
+Deno.serve(async (req) => {
+  // Handle CORS preflight
+  if (req.method === 'OPTIONS') {
+    return new Response(null, { headers: corsHeaders });
+  }
+
+  try {
+    // Get Supabase client
+    const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
+    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
+    const supabase = createClient(supabaseUrl, supabaseServiceKey);
+
+    // Verify authentication
+    const authHeader = req.headers.get('Authorization');
+    if (!authHeader) {
+      return new Response(
+        JSON.stringify({ error: 'Missing authorization header' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    const token = authHeader.replace('Bearer ', '');
+    const { data: { user }, error: authError } = await supabase.auth.getUser(token);
+
+    if (authError || !user) {
+      return new Response(
+        JSON.stringify({ error: 'Unauthorized' }),
+        { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Parse query parameters
+    const url = new URL(req.url);
+    const contentId = url.searchParams.get('content_id');
+    const storeId = url.searchParams.get('store_id');
+
+    if (!contentId || !storeId) {
+      return new Response(
+        JSON.stringify({ error: 'Missing required parameters: content_id, store_id' }),
+        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Get content record
+    const { data: content, error: contentError } = await supabase
+      .from('custom_content')
+      .select('*')
+      .eq('id', contentId)
+      .eq('store_id', storeId)
+      .eq('user_id', user.id)
+      .single();
+
+    if (contentError || !content) {
+      return new Response(
+        JSON.stringify({ error: 'Content not found or access denied' }),
+        { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+      );
+    }
+
+    // Prepare response based on content type
+    const response: any = {
+      id: content.id,
+      contentType: content.content_type,
+      title: content.title,
+      contentText: content.content_text,
+      originalFilename: content.original_filename,
+      pageCount: content.page_count,
+      chunkCount: content.chunk_count,
+      createdAt: content.created_at,
+    };
+
+    // For PDFs, generate signed URL
+    if (content.content_type === 'pdf_upload' && content.storage_path_pdf) {
+      const { data: signedUrlData, error: signedUrlError } = await supabase.storage
+        .from('custom-content')
+        .createSignedUrl(content.storage_path_pdf, 3600); // 1 hour expiry
+
+      if (!signedUrlError && signedUrlData) {
+        // Replace project subdomain with SUPABASE_URL to hide project ID
+        let pdfUrl = signedUrlData.signedUrl;
+        const supabaseUrl = Deno.env.get('SUPABASE_URL') || '';
+
+        // Extract the host from SUPABASE_URL (e.g., api.shopcall.ai)
+        if (supabaseUrl) {
+          try {
+            const customHost = new URL(supabaseUrl).host;
+            // Replace ztklqodcdjeqpsvhlpud.supabase.co with custom domain
+            pdfUrl = pdfUrl.replace(/https:\/\/[^.]+\.supabase\.co/, supabaseUrl);
+          } catch (e) {
+            console.error('[View] Failed to parse SUPABASE_URL:', e);
+          }
+        }
+
+        response.pdfUrl = pdfUrl;
+      }
+    }
+
+    return new Response(
+      JSON.stringify(response),
+      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+
+  } catch (error: any) {
+    console.error('[View] Error:', error);
+    return new Response(
+      JSON.stringify({
+        error: error.message || 'Internal server error',
+        details: error.toString(),
+      }),
+      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
+    );
+  }
+});

+ 113 - 1
supabase/functions/mcp-shopify/index.ts

@@ -39,7 +39,8 @@ import {
   getStoreQdrantConfig,
   getStoreQdrantConfig,
   queryQdrantProducts,
   queryQdrantProducts,
   queryQdrantOrders,
   queryQdrantOrders,
-  queryQdrantCustomers
+  queryQdrantCustomers,
+  queryQdrantCustomContent
 } from '../_shared/mcp-qdrant-helpers.ts';
 } from '../_shared/mcp-qdrant-helpers.ts';
 import {
 import {
   getAccessPolicyConfig,
   getAccessPolicyConfig,
@@ -202,6 +203,28 @@ const TOOLS: McpTool[] = [
       },
       },
       required: ['shop_id', 'email']
       required: ['shop_id', 'email']
     }
     }
+  },
+  {
+    name: 'shopify_search_custom_content',
+    description: 'Search custom knowledge base (uploaded PDFs and text entries) for relevant information using semantic search. Returns matching content with titles, excerpts, and relevance scores. Use this to find custom documentation, policies, FAQs, or any store-specific information that has been uploaded.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        shop_id: {
+          type: 'string',
+          description: 'The UUID of the Shopify store from the stores table'
+        },
+        query: {
+          type: 'string',
+          description: 'The search query to find relevant content'
+        },
+        limit: {
+          type: 'number',
+          description: 'Number of results to return (default: 5, max: 10)'
+        }
+      },
+      required: ['shop_id', 'query']
+    }
   }
   }
 ];
 ];
 
 
@@ -697,6 +720,80 @@ async function handleGetCustomer(args: Record<string, any>): Promise<ToolCallRes
   }
   }
 }
 }
 
 
+/**
+ * Handle custom content search
+ */
+async function handleSearchCustomContent(args: Record<string, any>): Promise<ToolCallResult> {
+  const { shop_id, query, limit = 5 } = args;
+
+  try {
+    // Get store config for Qdrant connection
+    const qdrantConfig = await getStoreQdrantConfig(shop_id);
+
+    if (!qdrantConfig) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            error: 'Store configuration not found'
+          })
+        }],
+        isError: true
+      };
+    }
+
+    // Query custom content from Qdrant
+    const results = await queryQdrantCustomContent(
+      shop_id,
+      qdrantConfig.shopname,
+      query,
+      Math.min(limit, 10) // Max 10 results
+    );
+
+    if (results.length === 0) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            message: 'No custom content found matching your query',
+            results: []
+          })
+        }],
+        isError: false
+      };
+    }
+
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({
+          results: results.map(r => ({
+            id: r.id,
+            title: r.title,
+            content_type: r.contentType,
+            excerpt: r.excerpt,
+            chunk_info: `Chunk ${r.chunkIndex + 1} of ${r.totalChunks}`,
+            relevance_score: Math.round(r.relevanceScore * 100) / 100,
+            original_filename: r.originalFilename,
+            page_count: r.pageCount
+          })),
+          total: results.length
+        })
+      }],
+      isError: false
+    };
+  } catch (error: any) {
+    console.error('[MCP Shopify] Error searching custom content:', error);
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({ error: `Failed to search custom content: ${error instanceof Error ? error.message : 'Unknown error'}` })
+      }],
+      isError: true
+    };
+  }
+}
+
 /**
 /**
  * Handle tool call
  * Handle tool call
  */
  */
@@ -743,6 +840,21 @@ async function handleToolCall(params: ToolCallParams): Promise<ToolCallResult> {
       }
       }
       return await handleGetCustomer(args);
       return await handleGetCustomer(args);
 
 
+    case 'shopify_search_custom_content':
+      const customContentValidation = validateParams(args, ['shop_id', 'query']);
+      if (!customContentValidation.valid) {
+        return {
+          content: [{
+            type: 'text',
+            text: JSON.stringify({
+              error: `Missing required parameters: ${customContentValidation.missing?.join(', ')}`
+            })
+          }],
+          isError: true
+        };
+      }
+      return await handleSearchCustomContent(args);
+
     // Legacy tool names for backward compatibility
     // Legacy tool names for backward compatibility
     case 'shopify_list_customers':
     case 'shopify_list_customers':
       return {
       return {

+ 113 - 1
supabase/functions/mcp-shoprenter/index.ts

@@ -41,7 +41,8 @@ import {
   getStoreQdrantConfig,
   getStoreQdrantConfig,
   queryQdrantProducts,
   queryQdrantProducts,
   queryQdrantOrders,
   queryQdrantOrders,
-  queryQdrantCustomers
+  queryQdrantCustomers,
+  queryQdrantCustomContent
 } from '../_shared/mcp-qdrant-helpers.ts';
 } from '../_shared/mcp-qdrant-helpers.ts';
 import {
 import {
   getAccessPolicyConfig,
   getAccessPolicyConfig,
@@ -192,6 +193,28 @@ const TOOLS: McpTool[] = [
       },
       },
       required: ['shop_id', 'email']
       required: ['shop_id', 'email']
     }
     }
+  },
+  {
+    name: 'shoprenter_search_custom_content',
+    description: 'Search custom knowledge base (uploaded PDFs and text entries) for relevant information using semantic search. Returns matching content with titles, excerpts, and relevance scores. Use this to find custom documentation, policies, FAQs, or any store-specific information that has been uploaded.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        shop_id: {
+          type: 'string',
+          description: 'The UUID of the ShopRenter store from the stores table'
+        },
+        query: {
+          type: 'string',
+          description: 'The search query to find relevant content'
+        },
+        limit: {
+          type: 'number',
+          description: 'Number of results to return (default: 5, max: 10)'
+        }
+      },
+      required: ['shop_id', 'query']
+    }
   }
   }
 ];
 ];
 
 
@@ -1057,6 +1080,80 @@ async function handleGetCustomer(args: Record<string, any>): Promise<ToolCallRes
   }
   }
 }
 }
 
 
+/**
+ * Handle custom content search
+ */
+async function handleSearchCustomContent(args: Record<string, any>): Promise<ToolCallResult> {
+  const { shop_id, query, limit = 5 } = args;
+
+  try {
+    // Get store config for Qdrant connection
+    const qdrantConfig = await getStoreQdrantConfig(shop_id);
+
+    if (!qdrantConfig) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            error: 'Store configuration not found'
+          })
+        }],
+        isError: true
+      };
+    }
+
+    // Query custom content from Qdrant
+    const results = await queryQdrantCustomContent(
+      shop_id,
+      qdrantConfig.shopname,
+      query,
+      Math.min(limit, 10) // Max 10 results
+    );
+
+    if (results.length === 0) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            message: 'No custom content found matching your query',
+            results: []
+          })
+        }],
+        isError: false
+      };
+    }
+
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({
+          results: results.map(r => ({
+            id: r.id,
+            title: r.title,
+            content_type: r.contentType,
+            excerpt: r.excerpt,
+            chunk_info: `Chunk ${r.chunkIndex + 1} of ${r.totalChunks}`,
+            relevance_score: Math.round(r.relevanceScore * 100) / 100,
+            original_filename: r.originalFilename,
+            page_count: r.pageCount
+          })),
+          total: results.length
+        })
+      }],
+      isError: false
+    };
+  } catch (error: any) {
+    console.error('[MCP ShopRenter] Error searching custom content:', error);
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({ error: `Failed to search custom content: ${error instanceof Error ? error.message : 'Unknown error'}` })
+      }],
+      isError: true
+    };
+  }
+}
+
 /**
 /**
  * Handle tool call
  * Handle tool call
  */
  */
@@ -1115,6 +1212,21 @@ async function handleToolCall(params: ToolCallParams): Promise<ToolCallResult> {
       }
       }
       return await handleGetCustomer(args);
       return await handleGetCustomer(args);
 
 
+    case 'shoprenter_search_custom_content':
+      const customContentValidation = validateParams(args, ['shop_id', 'query']);
+      if (!customContentValidation.valid) {
+        return {
+          content: [{
+            type: 'text',
+            text: JSON.stringify({
+              error: `Missing required parameters: ${customContentValidation.missing?.join(', ')}`
+            })
+          }],
+          isError: true
+        };
+      }
+      return await handleSearchCustomContent(args);
+
     // Legacy tool names for backward compatibility
     // Legacy tool names for backward compatibility
     case 'shoprenter_list_customers':
     case 'shoprenter_list_customers':
       return {
       return {

+ 113 - 1
supabase/functions/mcp-woocommerce/index.ts

@@ -41,7 +41,8 @@ import {
   getStoreQdrantConfig,
   getStoreQdrantConfig,
   queryQdrantProducts,
   queryQdrantProducts,
   queryQdrantOrders,
   queryQdrantOrders,
-  queryQdrantCustomers
+  queryQdrantCustomers,
+  queryQdrantCustomContent
 } from '../_shared/mcp-qdrant-helpers.ts';
 } from '../_shared/mcp-qdrant-helpers.ts';
 import {
 import {
   getAccessPolicyConfig,
   getAccessPolicyConfig,
@@ -196,6 +197,28 @@ const TOOLS: McpTool[] = [
       },
       },
       required: ['shop_id', 'email']
       required: ['shop_id', 'email']
     }
     }
+  },
+  {
+    name: 'woocommerce_search_custom_content',
+    description: 'Search custom knowledge base (uploaded PDFs and text entries) for relevant information using semantic search. Returns matching content with titles, excerpts, and relevance scores. Use this to find custom documentation, policies, FAQs, or any store-specific information that has been uploaded.',
+    inputSchema: {
+      type: 'object',
+      properties: {
+        shop_id: {
+          type: 'string',
+          description: 'The UUID of the WooCommerce store from the stores table'
+        },
+        query: {
+          type: 'string',
+          description: 'The search query to find relevant content'
+        },
+        limit: {
+          type: 'number',
+          description: 'Number of results to return (default: 5, max: 10)'
+        }
+      },
+      required: ['shop_id', 'query']
+    }
   }
   }
 ];
 ];
 
 
@@ -777,6 +800,80 @@ async function handleGetCustomer(args: Record<string, any>): Promise<ToolCallRes
   }
   }
 }
 }
 
 
+/**
+ * Handle custom content search
+ */
+async function handleSearchCustomContent(args: Record<string, any>): Promise<ToolCallResult> {
+  const { shop_id, query, limit = 5 } = args;
+
+  try {
+    // Get store config for Qdrant connection
+    const qdrantConfig = await getStoreQdrantConfig(shop_id);
+
+    if (!qdrantConfig) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            error: 'Store configuration not found'
+          })
+        }],
+        isError: true
+      };
+    }
+
+    // Query custom content from Qdrant
+    const results = await queryQdrantCustomContent(
+      shop_id,
+      qdrantConfig.shopname,
+      query,
+      Math.min(limit, 10) // Max 10 results
+    );
+
+    if (results.length === 0) {
+      return {
+        content: [{
+          type: 'text',
+          text: JSON.stringify({
+            message: 'No custom content found matching your query',
+            results: []
+          })
+        }],
+        isError: false
+      };
+    }
+
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({
+          results: results.map(r => ({
+            id: r.id,
+            title: r.title,
+            content_type: r.contentType,
+            excerpt: r.excerpt,
+            chunk_info: `Chunk ${r.chunkIndex + 1} of ${r.totalChunks}`,
+            relevance_score: Math.round(r.relevanceScore * 100) / 100,
+            original_filename: r.originalFilename,
+            page_count: r.pageCount
+          })),
+          total: results.length
+        })
+      }],
+      isError: false
+    };
+  } catch (error: any) {
+    console.error('[MCP WooCommerce] Error searching custom content:', error);
+    return {
+      content: [{
+        type: 'text',
+        text: JSON.stringify({ error: `Failed to search custom content: ${error instanceof Error ? error.message : 'Unknown error'}` })
+      }],
+      isError: true
+    };
+  }
+}
+
 /**
 /**
  * Handle tool call
  * Handle tool call
  */
  */
@@ -835,6 +932,21 @@ async function handleToolCall(params: ToolCallParams): Promise<ToolCallResult> {
       }
       }
       return await handleGetCustomer(args);
       return await handleGetCustomer(args);
 
 
+    case 'woocommerce_search_custom_content':
+      const customContentValidation = validateParams(args, ['shop_id', 'query']);
+      if (!customContentValidation.valid) {
+        return {
+          content: [{
+            type: 'text',
+            text: JSON.stringify({
+              error: `Missing required parameters: ${customContentValidation.missing?.join(', ')}`
+            })
+          }],
+          isError: true
+        };
+      }
+      return await handleSearchCustomContent(args);
+
     // Legacy tool names for backward compatibility
     // Legacy tool names for backward compatibility
     case 'woocommerce_list_customers':
     case 'woocommerce_list_customers':
       return {
       return {

+ 229 - 0
supabase/migrations/20251125_custom_content.sql

@@ -0,0 +1,229 @@
+-- Custom Content RAG Feature Migration
+-- Enables users to upload PDFs and create text entries for AI knowledge base
+
+-- Create custom_content table
+CREATE TABLE IF NOT EXISTS public.custom_content (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    store_id UUID NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE,
+    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+
+    -- Content metadata
+    content_type TEXT NOT NULL CHECK (content_type IN ('text_entry', 'pdf_upload')),
+    title TEXT NOT NULL,
+    original_filename TEXT, -- For PDFs, original uploaded filename
+    file_checksum TEXT, -- SHA-256 hash for deduplication
+
+    -- Content data
+    content_text TEXT, -- Extracted text (from PDF or user input)
+
+    -- Storage paths
+    storage_path_pdf TEXT, -- e.g., 'store_id/uuid.pdf'
+    storage_path_txt TEXT, -- e.g., 'store_id/uuid.txt'
+
+    -- File metadata
+    file_size_bytes BIGINT,
+    page_count INTEGER, -- For PDFs
+    chunk_count INTEGER DEFAULT 0, -- Number of chunks in Qdrant
+
+    -- Sync status tracking
+    sync_status TEXT NOT NULL DEFAULT 'pending' CHECK (sync_status IN ('pending', 'processing', 'completed', 'failed')),
+    sync_started_at TIMESTAMPTZ,
+    sync_completed_at TIMESTAMPTZ,
+    sync_error TEXT,
+
+    -- Qdrant integration
+    qdrant_point_ids TEXT[], -- Array of point IDs in Qdrant for cleanup
+
+    -- Timestamps
+    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+    -- Prevent duplicate files per store
+    CONSTRAINT unique_store_checksum UNIQUE (store_id, file_checksum)
+);
+
+-- Create indexes for performance
+CREATE INDEX idx_custom_content_store_id ON public.custom_content(store_id);
+CREATE INDEX idx_custom_content_user_id ON public.custom_content(user_id);
+CREATE INDEX idx_custom_content_content_type ON public.custom_content(content_type);
+CREATE INDEX idx_custom_content_sync_status ON public.custom_content(sync_status);
+CREATE INDEX idx_custom_content_checksum ON public.custom_content(file_checksum) WHERE file_checksum IS NOT NULL;
+CREATE INDEX idx_custom_content_created_at ON public.custom_content(created_at DESC);
+
+-- Enable Row Level Security
+ALTER TABLE public.custom_content ENABLE ROW LEVEL SECURITY;
+
+-- RLS Policies
+
+-- Users can view custom content for stores they own
+CREATE POLICY "Users can view their store's custom content"
+    ON public.custom_content
+    FOR SELECT
+    USING (
+        EXISTS (
+            SELECT 1 FROM public.stores
+            WHERE stores.id = custom_content.store_id
+            AND stores.user_id = auth.uid()
+        )
+    );
+
+-- Users can insert custom content for their stores
+CREATE POLICY "Users can create custom content for their stores"
+    ON public.custom_content
+    FOR INSERT
+    WITH CHECK (
+        EXISTS (
+            SELECT 1 FROM public.stores
+            WHERE stores.id = custom_content.store_id
+            AND stores.user_id = auth.uid()
+        )
+        AND user_id = auth.uid()
+    );
+
+-- Users can update custom content for their stores
+CREATE POLICY "Users can update their store's custom content"
+    ON public.custom_content
+    FOR UPDATE
+    USING (
+        EXISTS (
+            SELECT 1 FROM public.stores
+            WHERE stores.id = custom_content.store_id
+            AND stores.user_id = auth.uid()
+        )
+    )
+    WITH CHECK (
+        EXISTS (
+            SELECT 1 FROM public.stores
+            WHERE stores.id = custom_content.store_id
+            AND stores.user_id = auth.uid()
+        )
+    );
+
+-- Users can delete custom content from their stores
+CREATE POLICY "Users can delete their store's custom content"
+    ON public.custom_content
+    FOR DELETE
+    USING (
+        EXISTS (
+            SELECT 1 FROM public.stores
+            WHERE stores.id = custom_content.store_id
+            AND stores.user_id = auth.uid()
+        )
+    );
+
+-- Service role can do everything (for Edge Functions)
+CREATE POLICY "Service role has full access"
+    ON public.custom_content
+    FOR ALL
+    USING (auth.jwt() ->> 'role' = 'service_role')
+    WITH CHECK (auth.jwt() ->> 'role' = 'service_role');
+
+-- Create updated_at trigger
+CREATE OR REPLACE FUNCTION update_custom_content_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+    NEW.updated_at = NOW();
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER custom_content_updated_at
+    BEFORE UPDATE ON public.custom_content
+    FOR EACH ROW
+    EXECUTE FUNCTION update_custom_content_updated_at();
+
+-- Create Supabase Storage bucket for custom content
+-- Note: This is declarative - if bucket exists, it will be updated
+INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
+VALUES (
+    'custom-content',
+    'custom-content',
+    false, -- Private bucket
+    10485760, -- 10MB limit
+    ARRAY['application/pdf', 'text/plain']
+)
+ON CONFLICT (id) DO UPDATE SET
+    file_size_limit = 10485760,
+    allowed_mime_types = ARRAY['application/pdf', 'text/plain'];
+
+-- Storage bucket RLS policies
+-- Users can upload files to their store's folder
+CREATE POLICY "Users can upload to their store folder"
+    ON storage.objects
+    FOR INSERT
+    WITH CHECK (
+        bucket_id = 'custom-content'
+        AND (storage.foldername(name))[1] IN (
+            SELECT id::text FROM public.stores WHERE user_id = auth.uid()
+        )
+    );
+
+-- Users can read files from their store's folder
+CREATE POLICY "Users can read from their store folder"
+    ON storage.objects
+    FOR SELECT
+    USING (
+        bucket_id = 'custom-content'
+        AND (storage.foldername(name))[1] IN (
+            SELECT id::text FROM public.stores WHERE user_id = auth.uid()
+        )
+    );
+
+-- Users can delete files from their store's folder
+CREATE POLICY "Users can delete from their store folder"
+    ON storage.objects
+    FOR DELETE
+    USING (
+        bucket_id = 'custom-content'
+        AND (storage.foldername(name))[1] IN (
+            SELECT id::text FROM public.stores WHERE user_id = auth.uid()
+        )
+    );
+
+-- Service role has full access to storage
+CREATE POLICY "Service role has full storage access"
+    ON storage.objects
+    FOR ALL
+    USING (
+        bucket_id = 'custom-content'
+        AND auth.jwt() ->> 'role' = 'service_role'
+    )
+    WITH CHECK (
+        bucket_id = 'custom-content'
+        AND auth.jwt() ->> 'role' = 'service_role'
+    );
+
+-- Create helper function to get custom content statistics
+CREATE OR REPLACE FUNCTION get_custom_content_stats(p_store_id UUID)
+RETURNS TABLE (
+    total_entries BIGINT,
+    text_entries BIGINT,
+    pdf_uploads BIGINT,
+    total_chunks BIGINT,
+    pending_sync BIGINT,
+    failed_sync BIGINT,
+    total_storage_bytes BIGINT
+) AS $$
+BEGIN
+    RETURN QUERY
+    SELECT
+        COUNT(*)::BIGINT as total_entries,
+        COUNT(*) FILTER (WHERE content_type = 'text_entry')::BIGINT as text_entries,
+        COUNT(*) FILTER (WHERE content_type = 'pdf_upload')::BIGINT as pdf_uploads,
+        COALESCE(SUM(chunk_count), 0)::BIGINT as total_chunks,
+        COUNT(*) FILTER (WHERE sync_status IN ('pending', 'processing'))::BIGINT as pending_sync,
+        COUNT(*) FILTER (WHERE sync_status = 'failed')::BIGINT as failed_sync,
+        COALESCE(SUM(file_size_bytes), 0)::BIGINT as total_storage_bytes
+    FROM public.custom_content
+    WHERE store_id = p_store_id;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Grant execute permission to authenticated users
+GRANT EXECUTE ON FUNCTION get_custom_content_stats(UUID) TO authenticated;
+
+-- Add comment for documentation
+COMMENT ON TABLE public.custom_content IS 'Stores custom RAG content (text entries and uploaded PDFs) for AI knowledge base';
+COMMENT ON COLUMN public.custom_content.file_checksum IS 'SHA-256 hash of file content for deduplication (PDFs only)';
+COMMENT ON COLUMN public.custom_content.qdrant_point_ids IS 'Array of Qdrant point IDs for deletion when content is removed';
+COMMENT ON COLUMN public.custom_content.chunk_count IS 'Number of text chunks stored in Qdrant (1 for small docs, N for chunked docs)';

+ 161 - 0
supabase/migrations/20251125_restore_sync_config.sql

@@ -0,0 +1,161 @@
+-- Migration: Restore Scheduled Sync Configuration
+-- Date: 2025-11-25
+-- Purpose: Restore database configuration settings for pg_cron scheduled sync
+
+-- =============================================================================
+-- CRITICAL: Set these environment variables in Supabase Edge Functions:
+--   INTERNAL_SYNC_SECRET = your_secret_key_here
+-- =============================================================================
+
+-- ============================================================================
+-- Option 1: Using SET for session-level (not persistent, testing only)
+-- ============================================================================
+-- Uncomment for testing:
+-- SET app.internal_sync_secret = 'your_secret_here';
+-- SET app.supabase_url = 'https://ztklqodcdjeqpsvhlpud.supabase.co';
+
+-- ============================================================================
+-- Option 2: Using a configuration table (WORKAROUND)
+-- ============================================================================
+-- Since ALTER DATABASE requires superuser, we use a config table instead
+
+CREATE TABLE IF NOT EXISTS system_config (
+  key TEXT PRIMARY KEY,
+  value TEXT NOT NULL,
+  description TEXT,
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Enable RLS
+ALTER TABLE system_config ENABLE ROW LEVEL SECURITY;
+
+-- Only allow service role to modify
+CREATE POLICY "Service role can manage system config"
+  ON system_config
+  FOR ALL
+  TO service_role
+  USING (true)
+  WITH CHECK (true);
+
+-- Insert/update configuration values
+INSERT INTO system_config (key, value, description)
+VALUES
+  ('internal_sync_secret', 'REPLACE_WITH_YOUR_SECRET', 'Secret key for scheduled sync authentication'),
+  ('supabase_url', 'https://ztklqodcdjeqpsvhlpud.supabase.co', 'Supabase project URL')
+ON CONFLICT (key)
+DO UPDATE SET
+  value = EXCLUDED.value,
+  updated_at = NOW();
+
+-- ============================================================================
+-- Update trigger function to read from system_config table
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION trigger_shoprenter_scheduled_sync()
+RETURNS void AS $$
+DECLARE
+  response_data jsonb;
+  internal_secret TEXT;
+  supabase_url TEXT;
+BEGIN
+  -- Try to get from PostgreSQL settings first (if they exist)
+  internal_secret := current_setting('app.internal_sync_secret', true);
+  supabase_url := current_setting('app.supabase_url', true);
+
+  -- If not set, fallback to system_config table
+  IF internal_secret IS NULL OR supabase_url IS NULL THEN
+    SELECT value INTO internal_secret
+    FROM system_config
+    WHERE key = 'internal_sync_secret';
+
+    SELECT value INTO supabase_url
+    FROM system_config
+    WHERE key = 'supabase_url';
+  END IF;
+
+  IF internal_secret IS NULL OR supabase_url IS NULL THEN
+    RAISE WARNING 'Missing required settings for scheduled sync (checked both current_setting and system_config)';
+    RETURN;
+  END IF;
+
+  -- Make HTTP request to the scheduled sync Edge Function
+  SELECT INTO response_data
+    net.http_post(
+      url := supabase_url || '/functions/v1/shoprenter-scheduled-sync',
+      headers := jsonb_build_object(
+        'Content-Type', 'application/json',
+        'x-internal-secret', internal_secret
+      ),
+      body := jsonb_build_object('source', 'pg_cron')
+    );
+
+  -- Log the result
+  RAISE NOTICE 'Scheduled sync triggered: %', response_data;
+
+EXCEPTION
+  WHEN OTHERS THEN
+    RAISE WARNING 'Error triggering scheduled sync: %', SQLERRM;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- Apply same fix to WooCommerce trigger
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION trigger_woocommerce_scheduled_sync()
+RETURNS void AS $$
+DECLARE
+  response_data jsonb;
+  internal_secret TEXT;
+  supabase_url TEXT;
+BEGIN
+  -- Try to get from PostgreSQL settings first
+  internal_secret := current_setting('app.internal_sync_secret', true);
+  supabase_url := current_setting('app.supabase_url', true);
+
+  -- Fallback to system_config table
+  IF internal_secret IS NULL OR supabase_url IS NULL THEN
+    SELECT value INTO internal_secret
+    FROM system_config
+    WHERE key = 'internal_sync_secret';
+
+    SELECT value INTO supabase_url
+    FROM system_config
+    WHERE key = 'supabase_url';
+  END IF;
+
+  IF internal_secret IS NULL OR supabase_url IS NULL THEN
+    RAISE WARNING 'Missing required settings for scheduled sync';
+    RETURN;
+  END IF;
+
+  -- Make HTTP request
+  SELECT INTO response_data
+    net.http_post(
+      url := supabase_url || '/functions/v1/woocommerce-scheduled-sync',
+      headers := jsonb_build_object(
+        'Content-Type', 'application/json',
+        'x-internal-secret', internal_secret
+      ),
+      body := jsonb_build_object('source', 'pg_cron')
+    );
+
+  RAISE NOTICE 'WooCommerce scheduled sync triggered: %', response_data;
+
+EXCEPTION
+  WHEN OTHERS THEN
+    RAISE WARNING 'Error triggering WooCommerce scheduled sync: %', SQLERRM;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- Migration Complete
+-- ============================================================================
+
+DO $$
+BEGIN
+  RAISE NOTICE '✅ Sync configuration restored using system_config table';
+  RAISE NOTICE '⚠️  IMPORTANT: Update system_config.internal_sync_secret with your actual secret';
+  RAISE NOTICE '💡 Run: UPDATE system_config SET value = ''your_secret'' WHERE key = ''internal_sync_secret'';';
+END $$;