diff --git a/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py b/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py
new file mode 100644
index 000000000..7f8254270
--- /dev/null
+++ b/surfsense_backend/alembic/versions/48_add_note_to_documenttype_enum.py
@@ -0,0 +1,47 @@
+"""48_add_note_to_documenttype_enum
+
+Revision ID: 48
+Revises: 47
+Adds NOTE document type to support user-created BlockNote documents.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "48"
+down_revision: str | None = "47"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+# Define the ENUM type name and the new value
+ENUM_NAME = "documenttype"
+NEW_VALUE = "NOTE"
+
+
+def upgrade() -> None:
+ """Safely add 'NOTE' to documenttype enum if missing."""
+ op.execute(
+ f"""
+ DO $$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_type t
+ JOIN pg_enum e ON t.oid = e.enumtypid
+ WHERE t.typname = '{ENUM_NAME}' AND e.enumlabel = '{NEW_VALUE}'
+ ) THEN
+ ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}';
+ END IF;
+ END
+ $$;
+ """
+ )
+
+
+def downgrade() -> None:
+ """
+ Downgrade logic not implemented since PostgreSQL
+ does not support removing enum values.
+ """
+ pass
diff --git a/surfsense_backend/app/agents/researcher/nodes.py b/surfsense_backend/app/agents/researcher/nodes.py
index 491840589..35857c004 100644
--- a/surfsense_backend/app/agents/researcher/nodes.py
+++ b/surfsense_backend/app/agents/researcher/nodes.py
@@ -492,6 +492,7 @@ async def fetch_documents_by_ids(
"CLICKUP_CONNECTOR": "ClickUp (Selected)",
"AIRTABLE_CONNECTOR": "Airtable (Selected)",
"LUMA_CONNECTOR": "Luma Events (Selected)",
+ "NOTE": "Notes (Selected)",
}
source_object = {
@@ -1162,6 +1163,33 @@ async def fetch_relevant_documents(
}
)
+ elif connector == "NOTE":
+ (
+ source_object,
+ notes_chunks,
+ ) = await connector_service.search_notes(
+ user_query=reformulated_query,
+ search_space_id=search_space_id,
+ top_k=top_k,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ # Add to sources and raw documents
+ if source_object:
+ all_sources.append(source_object)
+ all_raw_documents.extend(notes_chunks)
+
+ # Stream found document count
+ if streaming_service and writer:
+ writer(
+ {
+ "yield_value": streaming_service.format_terminal_info_delta(
+ f"📝 Found {len(notes_chunks)} Notes related to your query"
+ )
+ }
+ )
+
except Exception as e:
logging.error("Error in search_airtable: %s", traceback.format_exc())
error_message = f"Error searching connector {connector}: {e!s}"
diff --git a/surfsense_backend/app/agents/researcher/utils.py b/surfsense_backend/app/agents/researcher/utils.py
index 3666d8b8a..ea3c99547 100644
--- a/surfsense_backend/app/agents/researcher/utils.py
+++ b/surfsense_backend/app/agents/researcher/utils.py
@@ -34,6 +34,7 @@ def get_connector_emoji(connector_name: str) -> str:
"LUMA_CONNECTOR": "✨",
"ELASTICSEARCH_CONNECTOR": "⚡",
"WEBCRAWLER_CONNECTOR": "🌐",
+ "NOTE": "📝",
}
return connector_emojis.get(connector_name, "🔎")
@@ -59,6 +60,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
"LUMA_CONNECTOR": "Luma",
"ELASTICSEARCH_CONNECTOR": "Elasticsearch",
"WEBCRAWLER_CONNECTOR": "Web Pages",
+ "NOTE": "Notes",
}
return connector_friendly_names.get(connector_name, connector_name)
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index cf267bd3d..c338240b3 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -51,6 +51,7 @@ class DocumentType(str, Enum):
LUMA_CONNECTOR = "LUMA_CONNECTOR"
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR"
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
+ NOTE = "NOTE"
class SearchSourceConnectorType(str, Enum):
diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py
index 4b829fe84..c9d70588d 100644
--- a/surfsense_backend/app/routes/__init__.py
+++ b/surfsense_backend/app/routes/__init__.py
@@ -15,6 +15,7 @@ from .google_gmail_add_connector_route import (
from .llm_config_routes import router as llm_config_router
from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_router
+from .notes_routes import router as notes_router
from .podcasts_routes import router as podcasts_router
from .rbac_routes import router as rbac_router
from .search_source_connectors_routes import router as search_source_connectors_router
@@ -26,6 +27,7 @@ router.include_router(search_spaces_router)
router.include_router(rbac_router) # RBAC routes for roles, members, invites
router.include_router(editor_router)
router.include_router(documents_router)
+router.include_router(notes_router)
router.include_router(podcasts_router)
router.include_router(chats_router)
router.include_router(search_source_connectors_router)
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index a959db5e6..d06217fc4 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -266,12 +266,27 @@ async def read_documents(
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
+ content_hash=doc.content_hash,
+ unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
+ updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
)
)
- return PaginatedResponse(items=api_documents, total=total)
+ # Calculate pagination info
+ actual_page = (
+ page if page is not None else (offset // page_size if page_size > 0 else 0)
+ )
+ has_more = (offset + len(api_documents)) < total if page_size > 0 else False
+
+ return PaginatedResponse(
+ items=api_documents,
+ total=total,
+ page=actual_page,
+ page_size=page_size,
+ has_more=has_more,
+ )
except HTTPException:
raise
except Exception as e:
@@ -385,12 +400,27 @@ async def search_documents(
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
+ content_hash=doc.content_hash,
+ unique_identifier_hash=doc.unique_identifier_hash,
created_at=doc.created_at,
+ updated_at=doc.updated_at,
search_space_id=doc.search_space_id,
)
)
- return PaginatedResponse(items=api_documents, total=total)
+ # Calculate pagination info
+ actual_page = (
+ page if page is not None else (offset // page_size if page_size > 0 else 0)
+ )
+ has_more = (offset + len(api_documents)) < total if page_size > 0 else False
+
+ return PaginatedResponse(
+ items=api_documents,
+ total=total,
+ page=actual_page,
+ page_size=page_size,
+ has_more=has_more,
+ )
except HTTPException:
raise
except Exception as e:
@@ -510,7 +540,10 @@ async def get_document_by_chunk_id(
document_type=document.document_type,
document_metadata=document.document_metadata,
content=document.content,
+ content_hash=document.content_hash,
+ unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
+ updated_at=document.updated_at,
search_space_id=document.search_space_id,
chunks=sorted_chunks,
)
@@ -559,7 +592,10 @@ async def read_document(
document_type=document.document_type,
document_metadata=document.document_metadata,
content=document.content,
+ content_hash=document.content_hash,
+ unique_identifier_hash=document.unique_identifier_hash,
created_at=document.created_at,
+ updated_at=document.updated_at,
search_space_id=document.search_space_id,
)
except HTTPException:
@@ -614,7 +650,10 @@ async def update_document(
document_type=db_document.document_type,
document_metadata=db_document.document_metadata,
content=db_document.content,
+ content_hash=db_document.content_hash,
+ unique_identifier_hash=db_document.unique_identifier_hash,
created_at=db_document.created_at,
+ updated_at=db_document.updated_at,
search_space_id=db_document.search_space_id,
)
except HTTPException:
diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py
index 7b7a15c13..a0e7b59c1 100644
--- a/surfsense_backend/app/routes/editor_routes.py
+++ b/surfsense_backend/app/routes/editor_routes.py
@@ -10,7 +10,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.db import Document, Permission, User, get_async_session
+from app.db import Document, DocumentType, Permission, User, get_async_session
from app.users import current_active_user
from app.utils.rbac import check_permission
@@ -59,13 +59,38 @@ async def get_editor_content(
return {
"document_id": document.id,
"title": document.title,
+ "document_type": document.document_type.value,
"blocknote_document": document.blocknote_document,
"updated_at": document.updated_at.isoformat()
if document.updated_at
else None,
}
- # Lazy migration: Try to generate blocknote_document from chunks
+ # For NOTE type documents, return empty BlockNote structure if no content exists
+ if document.document_type == DocumentType.NOTE:
+ # Return empty BlockNote structure
+ empty_blocknote = [
+ {
+ "type": "paragraph",
+ "content": [],
+ "children": [],
+ }
+ ]
+ # Save empty structure if not already saved
+ if not document.blocknote_document:
+ document.blocknote_document = empty_blocknote
+ await session.commit()
+ return {
+ "document_id": document.id,
+ "title": document.title,
+ "document_type": document.document_type.value,
+ "blocknote_document": empty_blocknote,
+ "updated_at": document.updated_at.isoformat()
+ if document.updated_at
+ else None,
+ }
+
+ # Lazy migration: Try to generate blocknote_document from chunks (for other document types)
from app.utils.blocknote_converter import convert_markdown_to_blocknote
chunks = sorted(document.chunks, key=lambda c: c.id)
@@ -102,6 +127,7 @@ async def get_editor_content(
return {
"document_id": document.id,
"title": document.title,
+ "document_type": document.document_type.value,
"blocknote_document": blocknote_json,
"updated_at": document.updated_at.isoformat() if document.updated_at else None,
}
@@ -147,6 +173,43 @@ async def save_document(
if not blocknote_document:
raise HTTPException(status_code=400, detail="blocknote_document is required")
+ # Add type validation
+ if not isinstance(blocknote_document, list):
+ raise HTTPException(status_code=400, detail="blocknote_document must be a list")
+
+ # For NOTE type documents, extract title from first block (heading)
+ if (
+ document.document_type == DocumentType.NOTE
+ and blocknote_document
+ and len(blocknote_document) > 0
+ ):
+ first_block = blocknote_document[0]
+ if (
+ first_block
+ and first_block.get("content")
+ and isinstance(first_block["content"], list)
+ ):
+ # Extract text from first block content
+ # Match the frontend extractTitleFromBlockNote logic exactly
+ title_parts = []
+ for item in first_block["content"]:
+ if isinstance(item, str):
+ title_parts.append(item)
+ elif (
+ isinstance(item, dict)
+ and "text" in item
+ and isinstance(item["text"], str)
+ ):
+ # BlockNote structure: {"type": "text", "text": "...", "styles": {}}
+ title_parts.append(item["text"])
+
+ new_title = "".join(title_parts).strip()
+ if new_title:
+ document.title = new_title
+ else:
+ # Only set to "Untitled" if content exists but is empty
+ document.title = "Untitled"
+
# Save BlockNote document
document.blocknote_document = blocknote_document
document.updated_at = datetime.now(UTC)
diff --git a/surfsense_backend/app/routes/notes_routes.py b/surfsense_backend/app/routes/notes_routes.py
new file mode 100644
index 000000000..5bb0a88a9
--- /dev/null
+++ b/surfsense_backend/app/routes/notes_routes.py
@@ -0,0 +1,235 @@
+"""
+Notes routes for creating and managing BlockNote documents.
+"""
+
+from datetime import UTC, datetime
+from typing import Any
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.db import Document, DocumentType, Permission, User, get_async_session
+from app.schemas import DocumentRead, PaginatedResponse
+from app.users import current_active_user
+from app.utils.rbac import check_permission
+
+router = APIRouter()
+
+
+class CreateNoteRequest(BaseModel):
+ title: str
+ blocknote_document: list[dict[str, Any]] | None = None
+
+
+@router.post("/search-spaces/{search_space_id}/notes", response_model=DocumentRead)
+async def create_note(
+ search_space_id: int,
+ request: CreateNoteRequest,
+ session: AsyncSession = Depends(get_async_session),
+ user: User = Depends(current_active_user),
+):
+ """
+ Create a new note (BlockNote document).
+
+ Requires DOCUMENTS_CREATE permission.
+ """
+ # Check RBAC permission
+ await check_permission(
+ session,
+ user,
+ search_space_id,
+ Permission.DOCUMENTS_CREATE.value,
+ "You don't have permission to create notes in this search space",
+ )
+
+ if not request.title or not request.title.strip():
+ raise HTTPException(status_code=400, detail="Title is required")
+
+ # Default empty BlockNote structure if not provided
+ blocknote_document = request.blocknote_document
+ if blocknote_document is None:
+ blocknote_document = [
+ {
+ "type": "paragraph",
+ "content": [],
+ "children": [],
+ }
+ ]
+
+ # Generate content hash (use title for now, will be updated on save)
+ import hashlib
+
+ content_hash = hashlib.sha256(request.title.encode()).hexdigest()
+
+ # Create document with NOTE type
+
+ document = Document(
+ search_space_id=search_space_id,
+ title=request.title.strip(),
+ document_type=DocumentType.NOTE,
+ content="", # Empty initially, will be populated on first save/reindex
+ content_hash=content_hash,
+ blocknote_document=blocknote_document,
+ content_needs_reindexing=False, # Will be set to True on first save
+ document_metadata={"NOTE": True},
+ embedding=None, # Will be generated on first reindex
+ updated_at=datetime.now(UTC),
+ )
+
+ session.add(document)
+ await session.commit()
+ await session.refresh(document)
+
+ return DocumentRead(
+ id=document.id,
+ title=document.title,
+ document_type=document.document_type,
+ content=document.content,
+ content_hash=document.content_hash,
+ unique_identifier_hash=document.unique_identifier_hash,
+ document_metadata=document.document_metadata,
+ search_space_id=document.search_space_id,
+ created_at=document.created_at,
+ updated_at=document.updated_at,
+ )
+
+
+@router.get(
+ "/search-spaces/{search_space_id}/notes",
+ response_model=PaginatedResponse[DocumentRead],
+)
+async def list_notes(
+ search_space_id: int,
+ skip: int | None = None,
+ page: int | None = None,
+ page_size: int = 50,
+ session: AsyncSession = Depends(get_async_session),
+ user: User = Depends(current_active_user),
+):
+ """
+ List all notes in a search space.
+
+ Requires DOCUMENTS_READ permission.
+ """
+ # Check RBAC permission
+ await check_permission(
+ session,
+ user,
+ search_space_id,
+ Permission.DOCUMENTS_READ.value,
+ "You don't have permission to read notes in this search space",
+ )
+
+ from sqlalchemy import func
+
+ # Build query
+ query = select(Document).where(
+ Document.search_space_id == search_space_id,
+ Document.document_type == DocumentType.NOTE,
+ )
+
+ # Get total count
+ count_query = select(func.count()).select_from(
+ select(Document)
+ .where(
+ Document.search_space_id == search_space_id,
+ Document.document_type == DocumentType.NOTE,
+ )
+ .subquery()
+ )
+ total_result = await session.execute(count_query)
+ total = total_result.scalar() or 0
+
+ # Apply pagination
+ if skip is not None:
+ query = query.offset(skip)
+ elif page is not None:
+ query = query.offset(page * page_size)
+ else:
+ query = query.offset(0)
+
+ if page_size > 0:
+ query = query.limit(page_size)
+
+ # Order by updated_at descending (most recent first)
+ query = query.order_by(Document.updated_at.desc())
+
+ # Execute query
+ result = await session.execute(query)
+ documents = result.scalars().all()
+
+ # Convert to response models
+ items = [
+ DocumentRead(
+ id=doc.id,
+ title=doc.title,
+ document_type=doc.document_type,
+ content=doc.content,
+ content_hash=doc.content_hash,
+ unique_identifier_hash=doc.unique_identifier_hash,
+ document_metadata=doc.document_metadata,
+ search_space_id=doc.search_space_id,
+ created_at=doc.created_at,
+ updated_at=doc.updated_at,
+ )
+ for doc in documents
+ ]
+
+ # Calculate pagination info
+ actual_skip = (
+ skip if skip is not None else (page * page_size if page is not None else 0)
+ )
+ has_more = (actual_skip + len(items)) < total if page_size > 0 else False
+
+ return PaginatedResponse(
+ items=items,
+ total=total,
+ page=page
+ if page is not None
+ else (actual_skip // page_size if page_size > 0 else 0),
+ page_size=page_size,
+ has_more=has_more,
+ )
+
+
+@router.delete("/search-spaces/{search_space_id}/notes/{note_id}")
+async def delete_note(
+ search_space_id: int,
+ note_id: int,
+ session: AsyncSession = Depends(get_async_session),
+ user: User = Depends(current_active_user),
+):
+ """
+ Delete a note.
+
+ Requires DOCUMENTS_DELETE permission.
+ """
+ # Check RBAC permission
+ await check_permission(
+ session,
+ user,
+ search_space_id,
+ Permission.DOCUMENTS_DELETE.value,
+ "You don't have permission to delete notes in this search space",
+ )
+
+ # Get document
+ result = await session.execute(
+ select(Document).where(
+ Document.id == note_id,
+ Document.search_space_id == search_space_id,
+ Document.document_type == DocumentType.NOTE,
+ )
+ )
+ document = result.scalars().first()
+
+ if not document:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Delete document (chunks will be cascade deleted)
+ await session.delete(document)
+ await session.commit()
+
+ return {"message": "Note deleted successfully", "note_id": note_id}
diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py
index fc83d24be..e1e8b9248 100644
--- a/surfsense_backend/app/schemas/documents.py
+++ b/surfsense_backend/app/schemas/documents.py
@@ -46,7 +46,10 @@ class DocumentRead(BaseModel):
document_type: DocumentType
document_metadata: dict
content: str # Changed to string to match frontend
+ content_hash: str
+ unique_identifier_hash: str | None
created_at: datetime
+ updated_at: datetime | None
search_space_id: int
model_config = ConfigDict(from_attributes=True)
@@ -61,3 +64,6 @@ class DocumentWithChunksRead(DocumentRead):
class PaginatedResponse[T](BaseModel):
items: list[T]
total: int
+ page: int
+ page_size: int
+ has_more: bool
diff --git a/surfsense_backend/app/services/connector_service.py b/surfsense_backend/app/services/connector_service.py
index cac1b7f47..61bc08b40 100644
--- a/surfsense_backend/app/services/connector_service.py
+++ b/surfsense_backend/app/services/connector_service.py
@@ -2360,6 +2360,75 @@ class ConnectorService:
return result_object, elasticsearch_docs
+ async def search_notes(
+ self,
+ user_query: str,
+ search_space_id: int,
+ top_k: int = 20,
+ start_date: datetime | None = None,
+ end_date: datetime | None = None,
+ ) -> tuple:
+ """
+ Search for Notes and return both the source information and langchain documents.
+
+ Uses combined chunk-level and document-level hybrid search with RRF fusion.
+
+ Args:
+ user_query: The user's query
+ search_space_id: The search space ID to search in
+ top_k: Maximum number of results to return
+ start_date: Optional start date for filtering documents by updated_at
+ end_date: Optional end date for filtering documents by updated_at
+
+ Returns:
+ tuple: (sources_info, langchain_documents)
+ """
+ notes_docs = await self._combined_rrf_search(
+ query_text=user_query,
+ search_space_id=search_space_id,
+ document_type="NOTE",
+ top_k=top_k,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ # Early return if no results
+ if not notes_docs:
+ return {
+ "id": 51,
+ "name": "Notes",
+ "type": "NOTE",
+ "sources": [],
+ }, []
+
+ def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
+ return doc_info.get("title", "Untitled Note")
+
+ def _url_fn(_doc_info: dict[str, Any], _metadata: dict[str, Any]) -> str:
+ return "" # Notes don't have URLs
+
+ def _description_fn(
+ chunk: dict[str, Any], _doc_info: dict[str, Any], _metadata: dict[str, Any]
+ ) -> str:
+ return self._chunk_preview(chunk.get("content", ""), limit=200)
+
+ sources_list = self._build_chunk_sources_from_documents(
+ notes_docs,
+ title_fn=_title_fn,
+ url_fn=_url_fn,
+ description_fn=_description_fn,
+ )
+
+ # Create result object
+ result_object = {
+ "id": 51,
+ "name": "Notes",
+ "type": "NOTE",
+ "sources": sources_list,
+ }
+
+ return result_object, notes_docs
+
async def search_bookstack(
self,
user_query: str,
diff --git a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
index 8ab5309f2..b9d4c3b95 100644
--- a/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
+++ b/surfsense_backend/app/tasks/celery_tasks/document_reindex_tasks.py
@@ -3,6 +3,7 @@
import logging
from sqlalchemy import delete, select
+from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import selectinload
from sqlalchemy.pool import NullPool
@@ -11,6 +12,7 @@ from app.celery_app import celery_app
from app.config import config
from app.db import Document
from app.services.llm_service import get_user_long_context_llm
+from app.services.task_logging_service import TaskLoggingService
from app.utils.blocknote_converter import convert_blocknote_to_markdown
from app.utils.document_converters import (
create_document_chunks,
@@ -53,21 +55,42 @@ def reindex_document_task(self, document_id: int, user_id: str):
async def _reindex_document(document_id: int, user_id: str):
"""Async function to reindex a document."""
async with get_celery_session_maker()() as session:
+ # First, get the document to get search_space_id for logging
+ result = await session.execute(
+ select(Document)
+ .options(selectinload(Document.chunks))
+ .where(Document.id == document_id)
+ )
+ document = result.scalars().first()
+
+ if not document:
+ logger.error(f"Document {document_id} not found")
+ return
+
+ # Initialize task logger
+ task_logger = TaskLoggingService(session, document.search_space_id)
+
+ # Log task start
+ log_entry = await task_logger.log_task_start(
+ task_name="document_reindex",
+ source="editor",
+ message=f"Starting reindex for document: {document.title}",
+ metadata={
+ "document_id": document_id,
+ "document_type": document.document_type.value,
+ "title": document.title,
+ "user_id": user_id,
+ },
+ )
+
try:
- # Get document
- result = await session.execute(
- select(Document)
- .options(selectinload(Document.chunks)) # Eagerly load chunks
- .where(Document.id == document_id)
- )
- document = result.scalars().first()
-
- if not document:
- logger.error(f"Document {document_id} not found")
- return
-
if not document.blocknote_document:
- logger.warning(f"Document {document_id} has no BlockNote content")
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Document {document_id} has no BlockNote content to reindex",
+ "No BlockNote content",
+ {"error_type": "NoBlockNoteContent"},
+ )
return
logger.info(f"Reindexing document {document_id} ({document.title})")
@@ -78,7 +101,12 @@ async def _reindex_document(document_id: int, user_id: str):
)
if not markdown_content:
- logger.error(f"Failed to convert document {document_id} to markdown")
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Failed to convert document {document_id} to markdown",
+ "Markdown conversion failed",
+ {"error_type": "ConversionError"},
+ )
return
# 2. Delete old chunks explicitly
@@ -118,9 +146,39 @@ async def _reindex_document(document_id: int, user_id: str):
await session.commit()
+ # Log success
+ await task_logger.log_task_success(
+ log_entry,
+ f"Successfully reindexed document: {document.title}",
+ {
+ "chunks_created": len(new_chunks),
+ "document_id": document_id,
+ },
+ )
+
logger.info(f"Successfully reindexed document {document_id}")
+ except SQLAlchemyError as db_error:
+ await session.rollback()
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Database error during reindex for document {document_id}",
+ str(db_error),
+ {"error_type": "SQLAlchemyError"},
+ )
+ logger.error(
+ f"Database error reindexing document {document_id}: {db_error}",
+ exc_info=True,
+ )
+ raise
+
except Exception as e:
await session.rollback()
+ await task_logger.log_task_failure(
+ log_entry,
+ f"Failed to reindex document: {document.title}",
+ str(e),
+ {"error_type": type(e).__name__},
+ )
logger.error(f"Error reindexing document {document_id}: {e}", exc_info=True)
raise
diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
index 028c0efdc..82197921a 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx
@@ -14,7 +14,6 @@ import { ChatPanelContainer } from "@/components/chat/ChatPanel/ChatPanelContain
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
-import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
@@ -224,7 +223,6 @@ export function DashboardClientLayout({
-
{/* Only show artifacts toggle on researcher page */}
{isResearcherPage && (
-
+
{
+ if (typeof item === "string") return item;
+ if (item?.text) return item.text;
+ return "";
+ })
+ .join("")
+ .trim();
+ return textContent || "Untitled";
+ }
+
+ return "Untitled";
+}
+
export default function EditorPage() {
const params = useParams();
const router = useRouter();
+ const queryClient = useQueryClient();
const documentId = params.documentId as string;
+ const searchSpaceId = Number(params.search_space_id);
+ const isNewNote = documentId === "new";
const [document, setDocument] = useState(null);
const [loading, setLoading] = useState(true);
@@ -29,10 +74,26 @@ export default function EditorPage() {
const [editorContent, setEditorContent] = useState(null);
const [error, setError] = useState(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
// Fetch document content - DIRECT CALL TO FASTAPI
+ // Skip fetching if this is a new note
useEffect(() => {
async function fetchDocument() {
+ // For new notes, initialize with empty state
+ if (isNewNote) {
+ setDocument({
+ document_id: 0,
+ title: "Untitled",
+ document_type: "NOTE",
+ blocknote_document: null,
+ updated_at: null,
+ });
+ setEditorContent(null);
+ setLoading(false);
+ return;
+ }
+
const token = getBearerToken();
if (!token) {
console.error("No auth token found");
@@ -51,16 +112,17 @@ export default function EditorPage() {
const errorData = await response
.json()
.catch(() => ({ detail: "Failed to fetch document" }));
- throw new Error(errorData.detail || "Failed to fetch document");
+ const errorMessage = errorData.detail || "Failed to fetch document";
+ throw new Error(errorMessage);
}
const data = await response.json();
// Check if blocknote_document exists
if (!data.blocknote_document) {
- setError(
- "This document does not have BlockNote content. Please re-upload the document to enable editing."
- );
+ const errorMsg =
+ "This document does not have BlockNote content. Please re-upload the document to enable editing.";
+ setError(errorMsg);
setLoading(false);
return;
}
@@ -70,9 +132,9 @@ export default function EditorPage() {
setError(null);
} catch (error) {
console.error("Error fetching document:", error);
- setError(
- error instanceof Error ? error.message : "Failed to fetch document. Please try again."
- );
+ const errorMessage =
+ error instanceof Error ? error.message : "Failed to fetch document. Please try again.";
+ setError(errorMessage);
} finally {
setLoading(false);
}
@@ -81,7 +143,7 @@ export default function EditorPage() {
if (documentId) {
fetchDocument();
}
- }, [documentId, params.search_space_id]);
+ }, [documentId, params.search_space_id, isNewNote]);
// Track changes to mark as unsaved
useEffect(() => {
@@ -90,9 +152,21 @@ export default function EditorPage() {
}
}, [editorContent, document]);
+ // Check if this is a NOTE type document
+ const isNote = isNewNote || document?.document_type === "NOTE";
+
+ // Extract title dynamically from editor content for notes, otherwise use document title
+ const displayTitle = useMemo(() => {
+ if (isNote && editorContent) {
+ return extractTitleFromBlockNote(editorContent);
+ }
+ return document?.title || "Untitled";
+ }, [isNote, editorContent, document?.title]);
+
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
// Save and exit - DIRECT CALL TO FASTAPI
+ // For new notes, create the note first, then save
const handleSave = async () => {
const token = getBearerToken();
if (!token) {
@@ -101,57 +175,121 @@ export default function EditorPage() {
return;
}
- if (!editorContent) {
- toast.error("No content to save");
- return;
- }
-
setSaving(true);
+ setError(null);
+
try {
- // Save blocknote_document and trigger reindexing in background
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ blocknote_document: editorContent }),
+ // If this is a new note, create it first
+ if (isNewNote) {
+ const title = extractTitleFromBlockNote(editorContent);
+
+ // Create the note first
+ const note = await notesApiService.createNote({
+ search_space_id: searchSpaceId,
+ title: title,
+ blocknote_document: editorContent || undefined,
+ });
+
+ // If there's content, save it properly and trigger reindexing
+ if (editorContent) {
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${note.id}/save`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ blocknote_document: editorContent }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response
+ .json()
+ .catch(() => ({ detail: "Failed to save document" }));
+ throw new Error(errorData.detail || "Failed to save document");
+ }
}
- );
- if (!response.ok) {
- const errorData = await response
- .json()
- .catch(() => ({ detail: "Failed to save document" }));
- throw new Error(errorData.detail || "Failed to save document");
+ setHasUnsavedChanges(false);
+ toast.success("Note created successfully! Reindexing in background...");
+
+ // Invalidate notes query to refresh the sidebar
+ queryClient.invalidateQueries({
+ queryKey: ["notes", String(searchSpaceId)],
+ });
+
+ // Update URL to reflect the new document ID without navigation
+ window.history.replaceState({}, "", `/dashboard/${searchSpaceId}/editor/${note.id}`);
+ // Update document state to reflect the new ID
+ setDocument({
+ document_id: note.id,
+ title: title,
+ document_type: "NOTE",
+ blocknote_document: editorContent,
+ updated_at: new Date().toISOString(),
+ });
+ } else {
+ // Existing document - save normally
+ if (!editorContent) {
+ toast.error("No content to save");
+ setSaving(false);
+ return;
+ }
+
+ // Save blocknote_document and trigger reindexing in background
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ blocknote_document: editorContent }),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response
+ .json()
+ .catch(() => ({ detail: "Failed to save document" }));
+ throw new Error(errorData.detail || "Failed to save document");
+ }
+
+ setHasUnsavedChanges(false);
+ toast.success("Document saved! Reindexing in background...");
+
+ // Invalidate notes query when updating notes to refresh the sidebar
+ if (isNote) {
+ queryClient.invalidateQueries({
+ queryKey: ["notes", String(searchSpaceId)],
+ });
+ }
}
-
- setHasUnsavedChanges(false);
- toast.success("Document saved! Reindexing in background...");
-
- // Small delay before redirect to show success message
- setTimeout(() => {
- router.push(`/dashboard/${params.search_space_id}/documents`);
- }, 500);
} catch (error) {
console.error("Error saving document:", error);
- toast.error(
- error instanceof Error ? error.message : "Failed to save document. Please try again."
- );
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : isNewNote
+ ? "Failed to create note. Please try again."
+ : "Failed to save document. Please try again.";
+ setError(errorMessage);
+ toast.error(errorMessage);
} finally {
setSaving(false);
}
};
- const handleCancel = () => {
+ const handleBack = () => {
if (hasUnsavedChanges) {
- if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
- router.back();
- }
+ setShowUnsavedDialog(true);
} else {
- router.back();
+ router.push(`/dashboard/${searchSpaceId}/researcher`);
}
};
+ const handleConfirmLeave = () => {
+ setShowUnsavedDialog(false);
+ router.push(`/dashboard/${searchSpaceId}/researcher`);
+ };
+
if (loading) {
return (
@@ -182,9 +320,13 @@ export default function EditorPage() {
{error}
-
@@ -193,7 +335,7 @@ export default function EditorPage() {
);
}
- if (!document) {
+ if (!document && !isNewNote) {
return (
@@ -217,26 +359,26 @@ export default function EditorPage() {
-
{document.title}
+
{displayTitle}
{hasUnsavedChanges &&
Unsaved changes
}
-
-
- Cancel
+
+
+ Back
{saving ? (
<>
- Saving...
+ {isNewNote ? "Creating..." : "Saving..."}
>
) : (
<>
- Save & Exit
+ Save
>
)}
@@ -244,13 +386,45 @@ export default function EditorPage() {
{/* Editor Container */}
-
+
+ {error && (
+
+
+
+ )}
-
+
+
+ {/* Unsaved Changes Dialog */}
+
+
+
+ Unsaved Changes
+
+ You have unsaved changes. Are you sure you want to leave?
+
+
+
+ Cancel
+ OK
+
+
+
);
}
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
index dd3f25218..5f9c4dbad 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import {
type ColumnDef,
type ColumnFiltersState,
@@ -11,6 +12,7 @@ import {
type SortingState,
useReactTable,
} from "@tanstack/react-table";
+import { useAtomValue } from "jotai";
import {
ArrowLeft,
Calendar,
@@ -44,6 +46,12 @@ import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
+import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms";
+import {
+ createRoleMutationAtom,
+ deleteRoleMutationAtom,
+ updateRoleMutationAtom,
+} from "@/atoms/roles/roles-mutation.atoms";
import {
AlertDialog,
AlertDialogAction,
@@ -99,24 +107,28 @@ import {
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
+import type {
+ CreateRoleRequest,
+ DeleteRoleRequest,
+ Role,
+ UpdateRoleRequest,
+} from "@/contracts/types/roles.types";
import {
type Invite,
type InviteCreate,
type Member,
- type Role,
- type RoleCreate,
useInvites,
useMembers,
- usePermissions,
- useRoles,
useUserAccess,
} from "@/hooks/use-rbac";
+import { rolesApiService } from "@/lib/apis/roles-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
// Animation variants
const fadeInUp = {
hidden: { opacity: 0, y: 20 },
- visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" } },
+ visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const } },
};
const staggerContainer = {
@@ -132,7 +144,7 @@ const cardVariants = {
visible: {
opacity: 1,
scale: 1,
- transition: { type: "spring", stiffness: 300, damping: 30 },
+ transition: { type: "spring" as const, stiffness: 300, damping: 30 },
},
};
@@ -150,14 +162,55 @@ export default function TeamManagementPage() {
updateMemberRole,
removeMember,
} = useMembers(searchSpaceId);
+
+ const { mutateAsync: createRole } = useAtomValue(createRoleMutationAtom);
+ const { mutateAsync: updateRole } = useAtomValue(updateRoleMutationAtom);
+ const { mutateAsync: deleteRole } = useAtomValue(deleteRoleMutationAtom);
+
+ const handleUpdateRole = useCallback(
+ async (roleId: number, data: { permissions?: string[] }): Promise
=> {
+ const request: UpdateRoleRequest = {
+ search_space_id: searchSpaceId,
+ role_id: roleId,
+ data: data,
+ };
+ return await updateRole(request);
+ },
+ [updateRole, searchSpaceId]
+ );
+
+ const handleDeleteRole = useCallback(
+ async (roleId: number): Promise => {
+ const request: DeleteRoleRequest = {
+ search_space_id: searchSpaceId,
+ role_id: roleId,
+ };
+ await deleteRole(request);
+ return true;
+ },
+ [deleteRole, searchSpaceId]
+ );
+
+ const handleCreateRole = useCallback(
+ async (roleData: CreateRoleRequest["data"]): Promise => {
+ const request: CreateRoleRequest = {
+ search_space_id: searchSpaceId,
+ data: roleData,
+ };
+ return await createRole(request);
+ },
+ [createRole, searchSpaceId]
+ );
+
const {
- roles,
- loading: rolesLoading,
- fetchRoles,
- createRole,
- updateRole,
- deleteRole,
- } = useRoles(searchSpaceId);
+ data: roles = [],
+ isLoading: rolesLoading,
+ refetch: fetchRoles,
+ } = useQuery({
+ queryKey: cacheKeys.roles.all(searchSpaceId.toString()),
+ queryFn: () => rolesApiService.getRoles({ search_space_id: searchSpaceId }),
+ enabled: !!searchSpaceId,
+ });
const {
invites,
loading: invitesLoading,
@@ -165,7 +218,19 @@ export default function TeamManagementPage() {
createInvite,
revokeInvite,
} = useInvites(searchSpaceId);
- const { groupedPermissions, loading: permissionsLoading } = usePermissions();
+
+ const { data: permissionsData, isLoading: permissionsLoading } = useAtomValue(permissionsAtom);
+ const permissions = permissionsData?.permissions || [];
+ const groupedPermissions = useMemo(() => {
+ const groups: Record = {};
+ for (const perm of permissions) {
+ if (!groups[perm.category]) {
+ groups[perm.category] = [];
+ }
+ groups[perm.category].push(perm);
+ }
+ return groups;
+ }, [permissions]);
const canManageMembers = hasPermission("members:view");
const canManageRoles = hasPermission("roles:read");
@@ -329,7 +394,7 @@ export default function TeamManagementPage() {
{activeTab === "roles" && hasPermission("roles:create") && (
)}
@@ -351,8 +416,8 @@ export default function TeamManagementPage() {
roles={roles}
groupedPermissions={groupedPermissions}
loading={rolesLoading}
- onUpdateRole={updateRole}
- onDeleteRole={deleteRole}
+ onUpdateRole={handleUpdateRole}
+ onDeleteRole={handleDeleteRole}
canUpdate={hasPermission("roles:update")}
canDelete={hasPermission("roles:delete")}
/>
@@ -663,7 +728,12 @@ function RolesTab({
{canUpdate && (
-
+ {
+ // TODO: Implement edit role dialog/modal
+ console.log("Edit role not yet implemented", role);
+ }}
+ >
Edit Role
@@ -882,7 +952,7 @@ function InvitesTab({
size="sm"
className="gap-2"
onClick={() => copyInviteLink(invite)}
- disabled={isInactive}
+ disabled={Boolean(isInactive)}
>
{copiedId === invite.id ? (
<>
@@ -1158,7 +1228,7 @@ function CreateRoleDialog({
onCreateRole,
}: {
groupedPermissions: Record;
- onCreateRole: (data: RoleCreate) => Promise;
+ onCreateRole: (data: CreateRoleRequest["data"]) => Promise;
}) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
@@ -1177,7 +1247,7 @@ function CreateRoleDialog({
try {
await onCreateRole({
name: name.trim(),
- description: description.trim() || undefined,
+ description: description.trim() || null,
permissions: selectedPermissions,
is_default: isDefault,
});
diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx
index b1525a9db..dbf5b7155 100644
--- a/surfsense_web/app/dashboard/page.tsx
+++ b/surfsense_web/app/dashboard/page.tsx
@@ -8,6 +8,9 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
+import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
+import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { UserDropdown } from "@/components/UserDropdown";
@@ -35,9 +38,6 @@ import {
} from "@/components/ui/card";
import { Spotlight } from "@/components/ui/spotlight";
import { Tilt } from "@/components/ui/tilt";
-import { useUser } from "@/hooks";
-import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
-import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
/**
@@ -156,11 +156,15 @@ const DashboardPage = () => {
},
};
- const { data: searchSpaces = [], isLoading: loading, error, refetch: refreshSearchSpaces } = useAtomValue(searchSpacesAtom);
+ const {
+ data: searchSpaces = [],
+ isLoading: loading,
+ error,
+ refetch: refreshSearchSpaces,
+ } = useAtomValue(searchSpacesAtom);
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
- // Fetch user details
- const { user, loading: isLoadingUser, error: userError } = useUser();
+ const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
// Create user object for UserDropdown
const customUser = {
@@ -172,7 +176,7 @@ const DashboardPage = () => {
};
if (loading) return ;
- if (error) return ;
+ if (error) return ;
const handleDeleteSearchSpace = async (id: number) => {
await deleteSearchSpace({ id });
diff --git a/surfsense_web/app/dashboard/searchspaces/page.tsx b/surfsense_web/app/dashboard/searchspaces/page.tsx
index 76e17c0ce..ae3956a54 100644
--- a/surfsense_web/app/dashboard/searchspaces/page.tsx
+++ b/surfsense_web/app/dashboard/searchspaces/page.tsx
@@ -3,8 +3,8 @@
import { useAtomValue } from "jotai";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
-import { SearchSpaceForm } from "@/components/search-space-form";
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
+import { SearchSpaceForm } from "@/components/search-space-form";
export default function SearchSpacesPage() {
const router = useRouter();
diff --git a/surfsense_web/atoms/permissions/permissions-query.atoms.ts b/surfsense_web/atoms/permissions/permissions-query.atoms.ts
new file mode 100644
index 000000000..335ddd77d
--- /dev/null
+++ b/surfsense_web/atoms/permissions/permissions-query.atoms.ts
@@ -0,0 +1,13 @@
+import { atomWithQuery } from "jotai-tanstack-query";
+import { permissionsApiService } from "@/lib/apis/permissions-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+export const permissionsAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.permissions.all(),
+ staleTime: 10 * 60 * 1000, // 10 minutes
+ queryFn: async () => {
+ return permissionsApiService.getPermissions();
+ },
+ };
+});
diff --git a/surfsense_web/atoms/roles/roles-mutation.atoms.ts b/surfsense_web/atoms/roles/roles-mutation.atoms.ts
new file mode 100644
index 000000000..ddbc68ca2
--- /dev/null
+++ b/surfsense_web/atoms/roles/roles-mutation.atoms.ts
@@ -0,0 +1,70 @@
+import { atomWithMutation } from "jotai-tanstack-query";
+import { toast } from "sonner";
+import type {
+ CreateRoleRequest,
+ CreateRoleResponse,
+ DeleteRoleRequest,
+ DeleteRoleResponse,
+ UpdateRoleRequest,
+ UpdateRoleResponse,
+} from "@/contracts/types/roles.types";
+import { rolesApiService } from "@/lib/apis/roles-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+import { queryClient } from "@/lib/query-client/client";
+
+export const createRoleMutationAtom = atomWithMutation(() => {
+ return {
+ mutationFn: async (request: CreateRoleRequest) => {
+ return rolesApiService.createRole(request);
+ },
+ onSuccess: (_: CreateRoleResponse, request: CreateRoleRequest) => {
+ toast.success("Role created successfully");
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.roles.all(request.search_space_id.toString()),
+ });
+ },
+ onError: () => {
+ toast.error("Failed to create role");
+ },
+ };
+});
+
+export const updateRoleMutationAtom = atomWithMutation(() => {
+ return {
+ mutationFn: async (request: UpdateRoleRequest) => {
+ return rolesApiService.updateRole(request);
+ },
+ onSuccess: (_: UpdateRoleResponse, request: UpdateRoleRequest) => {
+ toast.success("Role updated successfully");
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.roles.all(request.search_space_id.toString()),
+ });
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.roles.byId(
+ request.search_space_id.toString(),
+ request.role_id.toString()
+ ),
+ });
+ },
+ onError: () => {
+ toast.error("Failed to update role");
+ },
+ };
+});
+
+export const deleteRoleMutationAtom = atomWithMutation(() => {
+ return {
+ mutationFn: async (request: DeleteRoleRequest) => {
+ return rolesApiService.deleteRole(request);
+ },
+ onSuccess: (_: DeleteRoleResponse, request: DeleteRoleRequest) => {
+ toast.success("Role deleted successfully");
+ queryClient.invalidateQueries({
+ queryKey: cacheKeys.roles.all(request.search_space_id.toString()),
+ });
+ },
+ onError: () => {
+ toast.error("Failed to delete role");
+ },
+ };
+});
diff --git a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts
index ea1415869..62f23507b 100644
--- a/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts
+++ b/surfsense_web/atoms/search-spaces/search-space-mutation.atoms.ts
@@ -2,13 +2,13 @@ import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateSearchSpaceRequest,
- UpdateSearchSpaceRequest,
DeleteSearchSpaceRequest,
+ UpdateSearchSpaceRequest,
} from "@/contracts/types/search-space.types";
-import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
+import { activeSearchSpaceIdAtom } from "./search-space-query.atoms";
export const createSearchSpaceMutationAtom = atomWithMutation(() => {
return {
diff --git a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts
index 1f03e25a2..4aa024e93 100644
--- a/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts
+++ b/surfsense_web/atoms/search-spaces/search-space-query.atoms.ts
@@ -1,5 +1,5 @@
-import { atomWithQuery } from "jotai-tanstack-query";
import { atom } from "jotai";
+import { atomWithQuery } from "jotai-tanstack-query";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
diff --git a/surfsense_web/atoms/user/user-query.atoms.ts b/surfsense_web/atoms/user/user-query.atoms.ts
new file mode 100644
index 000000000..ea3e7ec49
--- /dev/null
+++ b/surfsense_web/atoms/user/user-query.atoms.ts
@@ -0,0 +1,13 @@
+import { atomWithQuery } from "jotai-tanstack-query";
+import { userApiService } from "@/lib/apis/user-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+export const currentUserAtom = atomWithQuery(() => {
+ return {
+ queryKey: cacheKeys.user.current(),
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ queryFn: async () => {
+ return userApiService.getMe();
+ },
+ };
+});
diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx
index 8064a0dc4..dc033bc5a 100644
--- a/surfsense_web/components/BlockNoteEditor.tsx
+++ b/surfsense_web/components/BlockNoteEditor.tsx
@@ -10,31 +10,123 @@ import { useCreateBlockNote } from "@blocknote/react";
interface BlockNoteEditorProps {
initialContent?: any;
onChange?: (content: any) => void;
+ useTitleBlock?: boolean; // Whether to use first block as title (Notion-style)
}
-export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) {
+// Helper to ensure first block is a heading for title
+function ensureTitleBlock(content: any[] | undefined): any[] {
+ if (!content || content.length === 0) {
+ // Return empty heading block for new notes
+ return [
+ {
+ type: "heading",
+ props: { level: 1 },
+ content: [],
+ children: [],
+ },
+ ];
+ }
+
+ // If first block is not a heading, convert it to one
+ const firstBlock = content[0];
+ if (firstBlock?.type !== "heading") {
+ // Extract text from first block
+ let titleText = "";
+ if (firstBlock?.content && Array.isArray(firstBlock.content)) {
+ titleText = firstBlock.content
+ .map((item: any) => {
+ if (typeof item === "string") return item;
+ if (item?.text) return item.text;
+ return "";
+ })
+ .join("")
+ .trim();
+ }
+
+ // Create heading block with extracted text
+ const titleBlock = {
+ type: "heading",
+ props: { level: 1 },
+ content: titleText
+ ? [
+ {
+ type: "text",
+ text: titleText,
+ styles: {},
+ },
+ ]
+ : [],
+ children: [],
+ };
+
+ // Replace first block with heading, keep rest
+ return [titleBlock, ...content.slice(1)];
+ }
+
+ return content;
+}
+
+export default function BlockNoteEditor({
+ initialContent,
+ onChange,
+ useTitleBlock = false,
+}: BlockNoteEditorProps) {
const { resolvedTheme } = useTheme();
// Track the initial content to prevent re-initialization
const initialContentRef = useRef(null);
const isInitializedRef = useRef(false);
+ // Prepare initial content - ensure first block is a heading if useTitleBlock is true
+ const preparedInitialContent = useMemo(() => {
+ if (initialContentRef.current !== null) {
+ return undefined; // Already initialized
+ }
+ if (initialContent === undefined) {
+ // New note - create empty heading block
+ return useTitleBlock
+ ? [
+ {
+ type: "heading",
+ props: { level: 1 },
+ content: [],
+ children: [],
+ },
+ ]
+ : undefined;
+ }
+ // Existing note - ensure first block is heading
+ return useTitleBlock ? ensureTitleBlock(initialContent) : initialContent;
+ }, [initialContent, useTitleBlock]);
+
// Creates a new editor instance - only use initialContent on first render
const editor = useCreateBlockNote({
- initialContent: initialContentRef.current === null ? initialContent || undefined : undefined,
+ initialContent: initialContentRef.current === null ? preparedInitialContent : undefined,
});
// Store initial content on first render only
useEffect(() => {
- if (initialContent && initialContentRef.current === null) {
- initialContentRef.current = initialContent;
+ if (preparedInitialContent !== undefined && initialContentRef.current === null) {
+ initialContentRef.current = preparedInitialContent;
+ isInitializedRef.current = true;
+ } else if (preparedInitialContent === undefined && initialContentRef.current === null) {
+ // Mark as initialized even when initialContent is undefined (for new notes)
isInitializedRef.current = true;
}
- }, [initialContent]);
+ }, [preparedInitialContent]);
// Call onChange when document changes (but don't update from props)
useEffect(() => {
- if (!onChange || !editor || !isInitializedRef.current) return;
+ if (!onChange || !editor) return;
+
+ // For new notes (no initialContent), we need to wait for editor to be ready
+ // Use a small delay to ensure editor is fully initialized
+ if (!isInitializedRef.current) {
+ const timer = setTimeout(() => {
+ isInitializedRef.current = true;
+ }, 100);
+ return () => clearTimeout(timer);
+ }
const handleChange = () => {
onChange(editor.document);
@@ -43,6 +135,12 @@ export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteE
// Subscribe to document changes
const unsubscribe = editor.onChange(handleChange);
+ // Also call onChange once with current document to capture initial state
+ // This ensures we capture content even if user doesn't make changes
+ if (editor.document) {
+ onChange(editor.document);
+ }
+
return () => {
unsubscribe();
};
diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx
index 6335f9f1a..abf77da5e 100644
--- a/surfsense_web/components/dashboard-breadcrumb.tsx
+++ b/surfsense_web/components/dashboard-breadcrumb.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -13,10 +14,9 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
-import { useQuery } from "@tanstack/react-query";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
interface BreadcrumbItemInterface {
label: string;
@@ -44,6 +44,13 @@ export function DashboardBreadcrumb() {
useEffect(() => {
if (segments[2] === "editor" && segments[3] && searchSpaceId) {
const documentId = segments[3];
+
+ // Skip fetch for "new" notes
+ if (documentId === "new") {
+ setDocumentTitle(null);
+ return;
+ }
+
const token = getBearerToken();
if (token) {
@@ -110,7 +117,14 @@ export function DashboardBreadcrumb() {
// Handle editor sub-sections (document ID)
if (section === "editor") {
- const documentLabel = documentTitle || subSection;
+ // Handle special cases for editor
+ let documentLabel: string;
+ if (subSection === "new") {
+ documentLabel = "New Note";
+ } else {
+ documentLabel = documentTitle || subSection;
+ }
+
breadcrumbs.push({
label: t("documents"),
href: `/dashboard/${segments[1]}/documents`,
diff --git a/surfsense_web/components/onboard/setup-prompt-step.tsx b/surfsense_web/components/onboard/setup-prompt-step.tsx
index 5e3683031..b53e49700 100644
--- a/surfsense_web/components/onboard/setup-prompt-step.tsx
+++ b/surfsense_web/components/onboard/setup-prompt-step.tsx
@@ -1,8 +1,10 @@
"use client";
+import { useAtomValue } from "jotai";
import { ChevronDown, ChevronUp, ExternalLink, Info, Sparkles, User } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -12,9 +14,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
-import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { authenticatedFetch } from "@/lib/auth-utils";
-import { useAtomValue } from "jotai";
interface SetupPromptStepProps {
searchSpaceId: number;
diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx
index dae842305..82456919c 100644
--- a/surfsense_web/components/settings/prompt-config-manager.tsx
+++ b/surfsense_web/components/settings/prompt-config-manager.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
+import { useAtomValue } from "jotai";
import {
ChevronDown,
ChevronUp,
@@ -12,6 +14,7 @@ import {
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -23,19 +26,20 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
-import { useQuery } from "@tanstack/react-query";
-import { communityPromptsAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
-import { cacheKeys } from "@/lib/query-client/cache-keys";
-import { useAtomValue } from "jotai";
import { authenticatedFetch } from "@/lib/auth-utils";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
interface PromptConfigManagerProps {
searchSpaceId: number;
}
export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) {
- const { data: searchSpace, isLoading: loading, refetch: fetchSearchSpace } = useQuery({
+ const {
+ data: searchSpace,
+ isLoading: loading,
+ refetch: fetchSearchSpace,
+ } = useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
enabled: !!searchSpaceId,
diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
index fce8697f7..ca05b0e3f 100644
--- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx
+++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx
@@ -1,12 +1,15 @@
"use client";
+import { useQuery } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { Trash2 } from "lucide-react";
+import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@@ -17,8 +20,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
-import { useUser } from "@/hooks";
-import { useQuery } from "@tanstack/react-query";
+import { notesApiService } from "@/lib/apis/notes-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@@ -48,6 +50,7 @@ export function AppSidebarProvider({
}: AppSidebarProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
+ const router = useRouter();
const setChatsQueryParams = useSetAtom(globalChatsQueryParamsAtom);
const { data: chats, error: chatError, isLoading: isLoadingChats } = useAtomValue(chatsAtom);
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
@@ -68,7 +71,23 @@ export function AppSidebarProvider({
enabled: !!searchSpaceId,
});
- const { user } = useUser();
+ const { data: user } = useAtomValue(currentUserAtom);
+
+ // Fetch notes
+ const {
+ data: notesData,
+ error: notesError,
+ isLoading: isLoadingNotes,
+ refetch: refetchNotes,
+ } = useQuery({
+ queryKey: ["notes", searchSpaceId],
+ queryFn: () =>
+ notesApiService.getNotes({
+ search_space_id: Number(searchSpaceId),
+ page_size: 5, // Get 5 notes (changed from 10)
+ }),
+ enabled: !!searchSpaceId,
+ });
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@@ -162,6 +181,53 @@ export function AppSidebarProvider({
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
+ // Transform notes to the format expected by NavNotes
+ const recentNotes = useMemo(() => {
+ if (!notesData?.items) return [];
+
+ // Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null
+ const sortedNotes = [...notesData.items].sort((a, b) => {
+ const dateA = a.updated_at
+ ? new Date(a.updated_at).getTime()
+ : new Date(a.created_at).getTime();
+ const dateB = b.updated_at
+ ? new Date(b.updated_at).getTime()
+ : new Date(b.created_at).getTime();
+ return dateB - dateA; // Descending order (most recent first)
+ });
+
+ // Limit to 5 notes
+ return sortedNotes.slice(0, 5).map((note) => ({
+ name: note.title,
+ url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
+ icon: "FileText",
+ id: note.id,
+ search_space_id: note.search_space_id,
+ actions: [
+ {
+ name: "Delete",
+ icon: "Trash2",
+ onClick: async () => {
+ try {
+ await notesApiService.deleteNote({
+ search_space_id: note.search_space_id,
+ note_id: note.id,
+ });
+ refetchNotes();
+ } catch (error) {
+ console.error("Error deleting note:", error);
+ }
+ },
+ },
+ ],
+ }));
+ }, [notesData, refetchNotes]);
+
+ // Handle add note
+ const handleAddNote = useCallback(() => {
+ router.push(`/dashboard/${searchSpaceId}/editor/new`);
+ }, [router, searchSpaceId]);
+
// Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary];
@@ -204,6 +270,7 @@ export function AppSidebarProvider({
navSecondary={navSecondary}
navMain={navMain}
RecentChats={[]}
+ RecentNotes={[]}
pageUsage={pageUsage}
/>
);
@@ -216,6 +283,8 @@ export function AppSidebarProvider({
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={displayChats}
+ RecentNotes={recentNotes}
+ onAddNote={handleAddNote}
pageUsage={pageUsage}
/>
diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
new file mode 100644
index 000000000..11c4f80ec
--- /dev/null
+++ b/surfsense_web/components/sidebar/all-notes-sidebar.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useCallback, useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet";
+import { useDebouncedValue } from "@/hooks/use-debounced-value";
+import { documentsApiService } from "@/lib/apis/documents-api.service";
+import { notesApiService } from "@/lib/apis/notes-api.service";
+import { cn } from "@/lib/utils";
+
+interface AllNotesSidebarProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ searchSpaceId: string;
+ onAddNote?: () => void;
+}
+
+export function AllNotesSidebar({
+ open,
+ onOpenChange,
+ searchSpaceId,
+ onAddNote,
+}: AllNotesSidebarProps) {
+ const t = useTranslations("sidebar");
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const [deletingNoteId, setDeletingNoteId] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
+
+ // Fetch all notes (when no search query)
+ const {
+ data: notesData,
+ error: notesError,
+ isLoading: isLoadingNotes,
+ } = useQuery({
+ queryKey: ["all-notes", searchSpaceId],
+ queryFn: () =>
+ notesApiService.getNotes({
+ search_space_id: Number(searchSpaceId),
+ page_size: 1000,
+ }),
+ enabled: !!searchSpaceId && open && !debouncedSearchQuery,
+ });
+
+ // Search notes (when there's a search query)
+ const {
+ data: searchData,
+ error: searchError,
+ isLoading: isSearching,
+ } = useQuery({
+ queryKey: ["search-notes", searchSpaceId, debouncedSearchQuery],
+ queryFn: () =>
+ documentsApiService.searchDocuments({
+ queryParams: {
+ search_space_id: Number(searchSpaceId),
+ document_types: ["NOTE"],
+ title: debouncedSearchQuery,
+ page_size: 100,
+ },
+ }),
+ enabled: !!searchSpaceId && open && !!debouncedSearchQuery,
+ });
+
+ // Handle note navigation
+ const handleNoteClick = useCallback(
+ (noteId: number, noteSearchSpaceId: number) => {
+ router.push(`/dashboard/${noteSearchSpaceId}/editor/${noteId}`);
+ onOpenChange(false);
+ },
+ [router, onOpenChange]
+ );
+
+ // Handle note deletion
+ const handleDeleteNote = useCallback(
+ async (noteId: number, noteSearchSpaceId: number) => {
+ setDeletingNoteId(noteId);
+ try {
+ await notesApiService.deleteNote({
+ search_space_id: noteSearchSpaceId,
+ note_id: noteId,
+ });
+ // Invalidate queries to refresh the list
+ queryClient.invalidateQueries({ queryKey: ["all-notes", searchSpaceId] });
+ queryClient.invalidateQueries({ queryKey: ["notes", searchSpaceId] });
+ queryClient.invalidateQueries({ queryKey: ["search-notes", searchSpaceId] });
+ } catch (error) {
+ console.error("Error deleting note:", error);
+ } finally {
+ setDeletingNoteId(null);
+ }
+ },
+ [queryClient, searchSpaceId]
+ );
+
+ // Clear search
+ const handleClearSearch = useCallback(() => {
+ setSearchQuery("");
+ }, []);
+
+ // Determine which data to show
+ const isSearchMode = !!debouncedSearchQuery;
+ const isLoading = isSearchMode ? isSearching : isLoadingNotes;
+ const error = isSearchMode ? searchError : notesError;
+
+ // Transform notes data - handle both regular notes and search results
+ const notes = useMemo(() => {
+ if (isSearchMode && searchData?.items) {
+ return searchData.items.map((doc) => ({
+ id: doc.id,
+ title: doc.title,
+ search_space_id: doc.search_space_id,
+ }));
+ }
+ return notesData?.items ?? [];
+ }, [isSearchMode, searchData, notesData]);
+
+ return (
+
+
+
+ {t("all_notes") || "All Notes"}
+
+ {t("all_notes_description") || "Browse and manage all your notes"}
+
+
+ {/* Search Input */}
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
+ />
+ {searchQuery && (
+
+
+ Clear search
+
+ )}
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ {t("error_loading_notes") || "Error loading notes"}
+
+ ) : notes.length > 0 ? (
+
+ {notes.map((note) => {
+ const isDeleting = deletingNoteId === note.id;
+
+ return (
+
+ {/* Main clickable area for navigation */}
+ handleNoteClick(note.id, note.search_space_id)}
+ disabled={isDeleting}
+ className="flex items-center gap-2 flex-1 min-w-0 text-left"
+ >
+
+ {note.title}
+
+
+ {/* Actions dropdown - separate from main click area */}
+
+
+
+ {isDeleting ? (
+
+ ) : (
+
+ )}
+ More options
+
+
+
+ handleDeleteNote(note.id, note.search_space_id)}
+ className="text-destructive focus:text-destructive"
+ >
+
+ Delete
+
+
+
+
+ );
+ })}
+
+ ) : isSearchMode ? (
+
+
+
+ {t("no_results_found") || "No notes found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+
+
+ {t("no_notes") || "No notes yet"}
+
+ {onAddNote && (
+
{
+ onAddNote();
+ onOpenChange(false);
+ }}
+ >
+
+ {t("create_new_note") || "Create a note"}
+
+ )}
+
+ )}
+
+
+
+ {/* Footer with Add Note button */}
+ {onAddNote && notes.length > 0 && (
+
+
{
+ onAddNote();
+ onOpenChange(false);
+ }}
+ className="w-full"
+ size="sm"
+ >
+
+ {t("create_new_note") || "Create a new note"}
+
+
+ )}
+
+
+ );
+}
diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx
index cd42e6fe8..48e7c35b8 100644
--- a/surfsense_web/components/sidebar/app-sidebar.tsx
+++ b/surfsense_web/components/sidebar/app-sidebar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useAtomValue } from "jotai";
import {
AlertCircle,
BookOpen,
@@ -24,11 +25,10 @@ import {
UserPlus,
Users,
} from "lucide-react";
-import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, useEffect, useMemo, useState } from "react";
-
+import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import {
DropdownMenu,
DropdownMenuContent,
@@ -38,7 +38,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import { useUser } from "@/hooks/use-user";
/**
* Generates a consistent color based on a string (email)
@@ -115,6 +114,7 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) {
}
import { NavMain } from "@/components/sidebar/nav-main";
+import { NavNotes } from "@/components/sidebar/nav-notes";
import { NavProjects } from "@/components/sidebar/nav-projects";
import { NavSecondary } from "@/components/sidebar/nav-secondary";
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
@@ -138,13 +138,13 @@ export const iconMap: Record = {
MessageCircleMore,
Settings2,
SquareLibrary,
+ FileText,
SquareTerminal,
AlertCircle,
Info,
ExternalLink,
Trash2,
Podcast,
- FileText,
Users,
};
@@ -209,6 +209,20 @@ const defaultData = {
id: 1003,
},
],
+ RecentNotes: [
+ {
+ name: "Meeting Notes",
+ url: "#",
+ icon: "FileText",
+ id: 2001,
+ },
+ {
+ name: "Project Ideas",
+ url: "#",
+ icon: "FileText",
+ id: 2002,
+ },
+ ],
};
interface AppSidebarProps extends React.ComponentProps {
@@ -240,6 +254,18 @@ interface AppSidebarProps extends React.ComponentProps {
onClick: () => void;
}[];
}[];
+ RecentNotes?: {
+ name: string;
+ url: string;
+ icon: string;
+ id?: number;
+ search_space_id?: number;
+ actions?: {
+ name: string;
+ icon: string;
+ onClick: () => void;
+ }[];
+ }[];
user?: {
name: string;
email: string;
@@ -249,6 +275,7 @@ interface AppSidebarProps extends React.ComponentProps {
pagesUsed: number;
pagesLimit: number;
};
+ onAddNote?: () => void;
}
// Memoized AppSidebar component for better performance
@@ -257,12 +284,14 @@ export const AppSidebar = memo(function AppSidebar({
navMain = defaultData.navMain,
navSecondary = defaultData.navSecondary,
RecentChats = defaultData.RecentChats,
+ RecentNotes = defaultData.RecentNotes,
pageUsage,
+ onAddNote,
...props
}: AppSidebarProps) {
const router = useRouter();
const { theme, setTheme } = useTheme();
- const { user, loading: isLoadingUser } = useUser();
+ const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
@@ -295,6 +324,16 @@ export const AppSidebar = memo(function AppSidebar({
);
}, [RecentChats]);
+ // Process RecentNotes to resolve icon names to components
+ const processedRecentNotes = useMemo(() => {
+ return (
+ RecentNotes?.map((item) => ({
+ ...item,
+ icon: iconMap[item.icon] || FileText,
+ })) || []
+ );
+ }, [RecentNotes]);
+
// Get user display name from email
const userDisplayName = user?.email ? user.email.split("@")[0] : "User";
const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown");
@@ -412,6 +451,14 @@ export const AppSidebar = memo(function AppSidebar({
)}
+
+
+
+
{pageUsage && (
diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx
new file mode 100644
index 000000000..b14ecea77
--- /dev/null
+++ b/surfsense_web/components/sidebar/nav-notes.tsx
@@ -0,0 +1,256 @@
+"use client";
+
+import {
+ ChevronRight,
+ FileText,
+ FolderOpen,
+ Loader2,
+ type LucideIcon,
+ MoreHorizontal,
+ Plus,
+ Trash2,
+} from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useCallback, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from "@/components/ui/sidebar";
+import { cn } from "@/lib/utils";
+import { AllNotesSidebar } from "./all-notes-sidebar";
+
+interface NoteAction {
+ name: string;
+ icon: string;
+ onClick: () => void;
+}
+
+interface NoteItem {
+ name: string;
+ url: string;
+ icon: LucideIcon;
+ id?: number;
+ search_space_id?: number;
+ actions?: NoteAction[];
+}
+
+interface NavNotesProps {
+ notes: NoteItem[];
+ onAddNote?: () => void;
+ defaultOpen?: boolean;
+ searchSpaceId?: string;
+}
+
+// Map of icon names to their components
+const actionIconMap: Record = {
+ FileText,
+ Trash2,
+ MoreHorizontal,
+};
+
+export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
+ const t = useTranslations("sidebar");
+ const router = useRouter();
+ const [isDeleting, setIsDeleting] = useState(null);
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+ const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
+
+ // Handle note deletion with loading state
+ const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
+ setIsDeleting(noteId);
+ try {
+ await deleteAction();
+ } finally {
+ setIsDeleting(null);
+ }
+ }, []);
+
+ // Handle note navigation
+ const handleNoteClick = useCallback(
+ (url: string) => {
+ router.push(url);
+ },
+ [router]
+ );
+
+ return (
+
+
+
+
+
+
+ {t("notes") || "Notes"}
+
+
+
+ {/* Action buttons - always visible on hover */}
+
+ {searchSpaceId && notes.length > 0 && (
+
{
+ e.stopPropagation();
+ setIsAllNotesSidebarOpen(true);
+ }}
+ aria-label="View all notes"
+ >
+
+
+ )}
+ {onAddNote && (
+
{
+ e.stopPropagation();
+ onAddNote();
+ }}
+ aria-label="Add note"
+ >
+
+
+ )}
+
+
+
+
+
+
+ {notes.length > 0 ? (
+ notes.map((note) => {
+ const isDeletingNote = isDeleting === note.id;
+
+ return (
+
+ {/* Main navigation button */}
+ handleNoteClick(note.url)}
+ disabled={isDeletingNote}
+ className={cn(
+ "pr-8", // Make room for the action button
+ isDeletingNote && "opacity-50"
+ )}
+ >
+
+ {note.name}
+
+
+ {/* Actions dropdown - positioned absolutely */}
+ {note.actions && note.actions.length > 0 && (
+
+
+
+
+ {isDeletingNote ? (
+
+ ) : (
+
+ )}
+ More options
+
+
+
+ {note.actions.map((action, actionIndex) => {
+ const ActionIcon = actionIconMap[action.icon] || FileText;
+ const isDeleteAction = action.name.toLowerCase().includes("delete");
+
+ return (
+ {
+ if (isDeleteAction) {
+ handleDeleteNote(note.id || 0, action.onClick);
+ } else {
+ action.onClick();
+ }
+ }}
+ disabled={isDeletingNote}
+ className={
+ isDeleteAction
+ ? "text-destructive focus:text-destructive"
+ : ""
+ }
+ >
+
+
+ {isDeletingNote && isDeleteAction
+ ? "Deleting..."
+ : action.name}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+ })
+ ) : (
+
+ {onAddNote ? (
+
+
+ {t("create_new_note") || "Create a new note"}
+
+ ) : (
+
+
+ {t("no_notes") || "No notes yet"}
+
+ )}
+
+ )}
+
+
+
+
+
+ {/* All Notes Sheet */}
+ {searchSpaceId && (
+
+ )}
+
+ );
+}
diff --git a/surfsense_web/components/sidebar/nav-projects.tsx b/surfsense_web/components/sidebar/nav-projects.tsx
index b3e1435d1..3862ce75d 100644
--- a/surfsense_web/components/sidebar/nav-projects.tsx
+++ b/surfsense_web/components/sidebar/nav-projects.tsx
@@ -148,19 +148,6 @@ export function NavProjects({ chats }: { chats: ChatItem[] }) {
return (
{t("recent_chats")}
-
- {/* Search Input */}
- {showSearch && (
-
- setSearchQuery(e.target.value)}
- className="h-8"
- />
-
- )}
-
{/* Chat Items */}
{filteredChats.length > 0 ? (
diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx
index 583ace11e..a12d26197 100644
--- a/surfsense_web/contracts/enums/connectorIcons.tsx
+++ b/surfsense_web/contracts/enums/connectorIcons.tsx
@@ -17,7 +17,17 @@ import {
IconTicket,
IconWorldWww,
} from "@tabler/icons-react";
-import { File, Globe, Link, Microscope, Search, Sparkles, Telescope, Webhook } from "lucide-react";
+import {
+ File,
+ FileText,
+ Globe,
+ Link,
+ Microscope,
+ Search,
+ Sparkles,
+ Telescope,
+ Webhook,
+} from "lucide-react";
import { EnumConnectorName } from "./connector";
export const getConnectorIcon = (connectorType: EnumConnectorName | string, className?: string) => {
@@ -71,6 +81,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
return ;
case "FILE":
return ;
+ case "NOTE":
+ return ;
case "EXTENSION":
return ;
case "DEEP":
diff --git a/surfsense_web/contracts/types/document.types.ts b/surfsense_web/contracts/types/document.types.ts
index abffc68a5..3ce5388dd 100644
--- a/surfsense_web/contracts/types/document.types.ts
+++ b/surfsense_web/contracts/types/document.types.ts
@@ -19,6 +19,7 @@ export const documentTypeEnum = z.enum([
"LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR",
"LINEAR_CONNECTOR",
+ "NOTE",
]);
export const document = z.object({
@@ -27,7 +28,10 @@ export const document = z.object({
document_type: documentTypeEnum,
document_metadata: z.record(z.string(), z.any()),
content: z.string(),
+ content_hash: z.string(),
+ unique_identifier_hash: z.string().nullable(),
created_at: z.string(),
+ updated_at: z.string().nullable(),
search_space_id: z.number(),
});
@@ -68,6 +72,9 @@ export const getDocumentsRequest = z.object({
export const getDocumentsResponse = z.object({
items: z.array(document),
total: z.number(),
+ page: z.number(),
+ page_size: z.number(),
+ has_more: z.boolean(),
});
/**
@@ -118,6 +125,9 @@ export const searchDocumentsRequest = z.object({
export const searchDocumentsResponse = z.object({
items: z.array(document),
total: z.number(),
+ page: z.number(),
+ page_size: z.number(),
+ has_more: z.boolean(),
});
/**
diff --git a/surfsense_web/contracts/types/invites.types.ts b/surfsense_web/contracts/types/invites.types.ts
new file mode 100644
index 000000000..2a9460e53
--- /dev/null
+++ b/surfsense_web/contracts/types/invites.types.ts
@@ -0,0 +1,111 @@
+import { z } from "zod";
+import { role } from "./roles.types";
+
+export const invite = z.object({
+ id: z.number(),
+ name: z.string().max(100).nullable().optional(),
+ invite_code: z.string(),
+ search_space_id: z.number(),
+ created_by_id: z.string().nullable(),
+ role_id: z.number().nullable(),
+ expires_at: z.string().nullable(),
+ max_uses: z.number().nullable(),
+ uses_count: z.number(),
+ is_active: z.boolean(),
+ created_at: z.string(),
+ role: role.nullable().optional(),
+});
+
+/**
+ * Create invite
+ */
+export const createInviteRequest = z.object({
+ search_space_id: z.number(),
+ data: z.object({
+ name: z.string().max(100).optional(),
+ role_id: z.number().nullable().optional(),
+ expires_at: z.string().nullable().optional(),
+ max_uses: z.number().nullable().optional(),
+ }),
+});
+
+export const createInviteResponse = invite;
+
+/**
+ * Get invites
+ */
+export const getInvitesRequest = z.object({
+ search_space_id: z.number(),
+});
+
+export const getInvitesResponse = z.array(invite);
+
+/**
+ * Update invite
+ */
+export const updateInviteRequest = z.object({
+ search_space_id: z.number(),
+ invite_id: z.number(),
+ data: z.object({
+ name: z.string().max(100).optional(),
+ role_id: z.number().nullable().optional(),
+ expires_at: z.string().nullable().optional(),
+ max_uses: z.number().nullable().optional(),
+ is_active: z.boolean().optional(),
+ }),
+});
+
+export const updateInviteResponse = invite;
+
+/**
+ * Delete invite
+ */
+export const deleteInviteRequest = z.object({
+ search_space_id: z.number(),
+ invite_id: z.number(),
+});
+
+export const deleteInviteResponse = z.object({
+ message: z.string(),
+});
+
+/**
+ * Get invite info by code
+ */
+export const getInviteInfoRequest = z.object({
+ invite_code: z.string(),
+});
+
+export const getInviteInfoResponse = z.object({
+ invite_code: z.string(),
+ search_space_name: z.string(),
+ role_name: z.string().nullable(),
+ expires_at: z.string().nullable(),
+ is_valid: z.boolean(),
+});
+
+/**
+ * Accept invite
+ */
+export const acceptInviteRequest = z.object({
+ invite_code: z.string(),
+});
+
+export const acceptInviteResponse = z.object({
+ message: z.string(),
+ search_space_id: z.number(),
+});
+
+export type Invite = z.infer;
+export type CreateInviteRequest = z.infer;
+export type CreateInviteResponse = z.infer;
+export type GetInvitesRequest = z.infer;
+export type GetInvitesResponse = z.infer;
+export type UpdateInviteRequest = z.infer;
+export type UpdateInviteResponse = z.infer;
+export type DeleteInviteRequest = z.infer;
+export type DeleteInviteResponse = z.infer;
+export type GetInviteInfoRequest = z.infer;
+export type GetInviteInfoResponse = z.infer;
+export type AcceptInviteRequest = z.infer;
+export type AcceptInviteResponse = z.infer;
diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts
new file mode 100644
index 000000000..a6d6333ac
--- /dev/null
+++ b/surfsense_web/contracts/types/members.types.ts
@@ -0,0 +1,87 @@
+import { z } from "zod";
+import { role } from "./roles.types";
+
+export const membership = z.object({
+ id: z.number(),
+ user_id: z.string(),
+ search_space_id: z.number(),
+ role_id: z.number().nullable(),
+ is_owner: z.boolean(),
+ joined_at: z.string(),
+ created_at: z.string(),
+ role: role.nullable().optional(),
+ user_email: z.string().nullable().optional(),
+ user_is_active: z.boolean().nullable().optional(),
+});
+
+/**
+ * Get members
+ */
+export const getMembersRequest = z.object({
+ search_space_id: z.number(),
+});
+
+export const getMembersResponse = z.array(membership);
+
+/**
+ * Update membership
+ */
+export const updateMembershipRequest = z.object({
+ search_space_id: z.number(),
+ membership_id: z.number(),
+ data: z.object({
+ role_id: z.number(),
+ }),
+});
+
+export const updateMembershipResponse = membership;
+
+/**
+ * Delete membership
+ */
+export const deleteMembershipRequest = z.object({
+ search_space_id: z.number(),
+ membership_id: z.number(),
+});
+
+export const deleteMembershipResponse = z.object({
+ message: z.string(),
+});
+
+/**
+ * Leave search space
+ */
+export const leaveSearchSpaceRequest = z.object({
+ search_space_id: z.number(),
+});
+
+export const leaveSearchSpaceResponse = z.object({
+ message: z.string(),
+});
+
+/**
+ * Get my access
+ */
+export const getMyAccessRequest = z.object({
+ search_space_id: z.number(),
+});
+
+export const getMyAccessResponse = z.object({
+ user_id: z.string(),
+ search_space_id: z.number(),
+ is_owner: z.boolean(),
+ permissions: z.array(z.string()),
+ role_name: z.string().nullable(),
+});
+
+export type Membership = z.infer;
+export type GetMembersRequest = z.infer;
+export type GetMembersResponse = z.infer;
+export type UpdateMembershipRequest = z.infer;
+export type UpdateMembershipResponse = z.infer;
+export type DeleteMembershipRequest = z.infer;
+export type DeleteMembershipResponse = z.infer;
+export type LeaveSearchSpaceRequest = z.infer;
+export type LeaveSearchSpaceResponse = z.infer;
+export type GetMyAccessRequest = z.infer;
+export type GetMyAccessResponse = z.infer;
diff --git a/surfsense_web/contracts/types/permissions.types.ts b/surfsense_web/contracts/types/permissions.types.ts
new file mode 100644
index 000000000..3f75192a3
--- /dev/null
+++ b/surfsense_web/contracts/types/permissions.types.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+
+export const permissionInfo = z.object({
+ value: z.string(),
+ name: z.string(),
+ category: z.string(),
+});
+
+/**
+ * Get permissions
+ */
+export const getPermissionsResponse = z.object({
+ permissions: z.array(permissionInfo),
+});
+
+export type PermissionInfo = z.infer;
+export type GetPermissionsResponse = z.infer;
diff --git a/surfsense_web/contracts/types/roles.types.ts b/surfsense_web/contracts/types/roles.types.ts
new file mode 100644
index 000000000..9008a859a
--- /dev/null
+++ b/surfsense_web/contracts/types/roles.types.ts
@@ -0,0 +1,88 @@
+import { z } from "zod";
+
+export const role = z.object({
+ id: z.number(),
+ name: z.string().min(1).max(100),
+ description: z.string().max(500).nullable(),
+ permissions: z.array(z.string()),
+ is_default: z.boolean(),
+ is_system_role: z.boolean(),
+ search_space_id: z.number(),
+ created_at: z.string(),
+});
+
+/**
+ * Create role
+ */
+export const createRoleRequest = z.object({
+ search_space_id: z.number(),
+ data: role.pick({
+ name: true,
+ description: true,
+ permissions: true,
+ is_default: true,
+ }),
+});
+
+export const createRoleResponse = role;
+
+/**
+ * Get roles
+ */
+export const getRolesRequest = z.object({
+ search_space_id: z.number(),
+});
+
+export const getRolesResponse = z.array(role);
+
+/**
+ * Get role by ID
+ */
+export const getRoleByIdRequest = z.object({
+ search_space_id: z.number(),
+ role_id: z.number(),
+});
+
+export const getRoleByIdResponse = role;
+
+/**
+ * Update role
+ */
+export const updateRoleRequest = z.object({
+ search_space_id: z.number(),
+ role_id: z.number(),
+ data: role
+ .pick({
+ name: true,
+ description: true,
+ permissions: true,
+ is_default: true,
+ })
+ .partial(),
+});
+
+export const updateRoleResponse = role;
+
+/**
+ * Delete role
+ */
+export const deleteRoleRequest = z.object({
+ search_space_id: z.number(),
+ role_id: z.number(),
+});
+
+export const deleteRoleResponse = z.object({
+ message: z.string(),
+});
+
+export type Role = z.infer;
+export type CreateRoleRequest = z.infer;
+export type CreateRoleResponse = z.infer;
+export type GetRolesRequest = z.infer;
+export type GetRolesResponse = z.infer;
+export type GetRoleByIdRequest = z.infer;
+export type GetRoleByIdResponse = z.infer;
+export type UpdateRoleRequest = z.infer;
+export type UpdateRoleResponse = z.infer;
+export type DeleteRoleRequest = z.infer;
+export type DeleteRoleResponse = z.infer;
diff --git a/surfsense_web/contracts/types/search-space.types.ts b/surfsense_web/contracts/types/search-space.types.ts
index c0096b41c..b591fafbb 100644
--- a/surfsense_web/contracts/types/search-space.types.ts
+++ b/surfsense_web/contracts/types/search-space.types.ts
@@ -2,26 +2,26 @@ import { z } from "zod";
import { paginationQueryParams } from ".";
export const searchSpace = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string().nullable(),
- created_at: z.string(),
- user_id: z.string(),
- citations_enabled: z.boolean(),
- qna_custom_instructions: z.string().nullable(),
- member_count: z.number(),
- is_owner: z.boolean(),
+ id: z.number(),
+ name: z.string(),
+ description: z.string().nullable(),
+ created_at: z.string(),
+ user_id: z.string(),
+ citations_enabled: z.boolean(),
+ qna_custom_instructions: z.string().nullable(),
+ member_count: z.number(),
+ is_owner: z.boolean(),
});
/**
* Get search spaces
*/
export const getSearchSpacesRequest = z.object({
- queryParams: paginationQueryParams
- .extend({
- owned_only: z.boolean().optional(),
- })
- .nullish(),
+ queryParams: paginationQueryParams
+ .extend({
+ owned_only: z.boolean().optional(),
+ })
+ .nullish(),
});
export const getSearchSpacesResponse = z.array(searchSpace);
@@ -29,12 +29,10 @@ export const getSearchSpacesResponse = z.array(searchSpace);
/**
* Create search space
*/
-export const createSearchSpaceRequest = searchSpace
- .pick({ name: true, description: true })
- .extend({
- citations_enabled: z.boolean().default(true).optional(),
- qna_custom_instructions: z.string().nullable().optional(),
- });
+export const createSearchSpaceRequest = searchSpace.pick({ name: true, description: true }).extend({
+ citations_enabled: z.boolean().default(true).optional(),
+ qna_custom_instructions: z.string().nullable().optional(),
+});
export const createSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
@@ -42,13 +40,13 @@ export const createSearchSpaceResponse = searchSpace.omit({ member_count: true,
* Get community prompts
*/
export const getCommunityPromptsResponse = z.array(
- z.object({
- key: z.string(),
- value: z.string(),
- author: z.string(),
- link: z.string(),
- category: z.string(),
- })
+ z.object({
+ key: z.string(),
+ value: z.string(),
+ author: z.string(),
+ link: z.string(),
+ category: z.string(),
+ })
);
/**
@@ -62,10 +60,10 @@ export const getSearchSpaceResponse = searchSpace.omit({ member_count: true, is_
* Update search space
*/
export const updateSearchSpaceRequest = z.object({
- id: z.number(),
- data: searchSpace
- .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
- .partial(),
+ id: z.number(),
+ data: searchSpace
+ .pick({ name: true, description: true, citations_enabled: true, qna_custom_instructions: true })
+ .partial(),
});
export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true, is_owner: true });
@@ -76,7 +74,7 @@ export const updateSearchSpaceResponse = searchSpace.omit({ member_count: true,
export const deleteSearchSpaceRequest = searchSpace.pick({ id: true });
export const deleteSearchSpaceResponse = z.object({
- message: z.literal("Search space deleted successfully"),
+ message: z.literal("Search space deleted successfully"),
});
// Inferred types
diff --git a/surfsense_web/contracts/types/user.types.ts b/surfsense_web/contracts/types/user.types.ts
new file mode 100644
index 000000000..f5df17694
--- /dev/null
+++ b/surfsense_web/contracts/types/user.types.ts
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+export const user = z.object({
+ id: z.string().uuid(),
+ email: z.string().email(),
+ is_active: z.boolean(),
+ is_superuser: z.boolean(),
+ is_verified: z.boolean(),
+ pages_limit: z.number(),
+ pages_used: z.number(),
+});
+
+/**
+ * Get current user
+ */
+export const getMeResponse = user;
+
+export type User = z.infer;
+export type GetMeResponse = z.infer;
diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts
index f7ef22534..db454c161 100644
--- a/surfsense_web/hooks/index.ts
+++ b/surfsense_web/hooks/index.ts
@@ -1,4 +1,4 @@
+export * from "./use-debounced-value";
export * from "./use-logs";
export * from "./use-rbac";
export * from "./use-search-source-connectors";
-export * from "./use-user";
diff --git a/surfsense_web/hooks/use-debounced-value.ts b/surfsense_web/hooks/use-debounced-value.ts
new file mode 100644
index 000000000..6b4ba2adb
--- /dev/null
+++ b/surfsense_web/hooks/use-debounced-value.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Hook that returns a debounced value that only updates after the specified delay
+ * @param value - The value to debounce
+ * @param delay - The delay in milliseconds (default: 300ms)
+ * @returns The debounced value
+ */
+export function useDebouncedValue(value: T, delay: number = 300): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/surfsense_web/hooks/use-logs.ts b/surfsense_web/hooks/use-logs.ts
index 6ce025e89..cfd161de0 100644
--- a/surfsense_web/hooks/use-logs.ts
+++ b/surfsense_web/hooks/use-logs.ts
@@ -141,32 +141,43 @@ export function useLogs(searchSpaceId?: number, filters: LogFilters = {}) {
);
// Function to create a new log
- const createLog = useCallback(async (logData: Omit) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
- {
- headers: { "Content-Type": "application/json" },
- method: "POST",
- body: JSON.stringify(logData),
+ // Use silent: true to suppress toast notifications (for internal/background operations)
+ const createLog = useCallback(
+ async (logData: Omit, options?: { silent?: boolean }) => {
+ const { silent = false } = options || {};
+ try {
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/logs`,
+ {
+ headers: { "Content-Type": "application/json" },
+ method: "POST",
+ body: JSON.stringify(logData),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.detail || "Failed to create log");
}
- );
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to create log");
+ const newLog = await response.json();
+ setLogs((prevLogs) => [newLog, ...prevLogs]);
+ // Only show toast if not silent
+ if (!silent) {
+ toast.success("Log created successfully");
+ }
+ return newLog;
+ } catch (err: any) {
+ // Only show error toast if not silent
+ if (!silent) {
+ toast.error(err.message || "Failed to create log");
+ }
+ console.error("Error creating log:", err);
+ throw err;
}
-
- const newLog = await response.json();
- setLogs((prevLogs) => [newLog, ...prevLogs]);
- toast.success("Log created successfully");
- return newLog;
- } catch (err: any) {
- toast.error(err.message || "Failed to create log");
- console.error("Error creating log:", err);
- throw err;
- }
- }, []);
+ },
+ []
+ );
// Function to update a log
const updateLog = useCallback(
diff --git a/surfsense_web/hooks/use-rbac.ts b/surfsense_web/hooks/use-rbac.ts
index ee3450746..fa619407a 100644
--- a/surfsense_web/hooks/use-rbac.ts
+++ b/surfsense_web/hooks/use-rbac.ts
@@ -218,137 +218,6 @@ export function useMembers(searchSpaceId: number) {
// ============ Roles Hook ============
-export function useRoles(searchSpaceId: number) {
- const [roles, setRoles] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const fetchRoles = useCallback(async () => {
- if (!searchSpaceId) return;
-
- try {
- setLoading(true);
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to fetch roles");
- }
-
- const data = await response.json();
- setRoles(data);
- setError(null);
- return data;
- } catch (err: any) {
- setError(err.message || "Failed to fetch roles");
- console.error("Error fetching roles:", err);
- } finally {
- setLoading(false);
- }
- }, [searchSpaceId]);
-
- useEffect(() => {
- fetchRoles();
- }, [fetchRoles]);
-
- const createRole = useCallback(
- async (roleData: RoleCreate) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`,
- {
- headers: { "Content-Type": "application/json" },
- method: "POST",
- body: JSON.stringify(roleData),
- }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to create role");
- }
-
- const newRole = await response.json();
- setRoles((prev) => [...prev, newRole]);
- toast.success("Role created successfully");
- return newRole;
- } catch (err: any) {
- toast.error(err.message || "Failed to create role");
- throw err;
- }
- },
- [searchSpaceId]
- );
-
- const updateRole = useCallback(
- async (roleId: number, roleData: RoleUpdate) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`,
- {
- headers: { "Content-Type": "application/json" },
- method: "PUT",
- body: JSON.stringify(roleData),
- }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to update role");
- }
-
- const updatedRole = await response.json();
- setRoles((prev) => prev.map((r) => (r.id === roleId ? updatedRole : r)));
- toast.success("Role updated successfully");
- return updatedRole;
- } catch (err: any) {
- toast.error(err.message || "Failed to update role");
- throw err;
- }
- },
- [searchSpaceId]
- );
-
- const deleteRole = useCallback(
- async (roleId: number) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`,
- { method: "DELETE" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to delete role");
- }
-
- setRoles((prev) => prev.filter((r) => r.id !== roleId));
- toast.success("Role deleted successfully");
- return true;
- } catch (err: any) {
- toast.error(err.message || "Failed to delete role");
- return false;
- }
- },
- [searchSpaceId]
- );
-
- return {
- roles,
- loading,
- error,
- fetchRoles,
- createRole,
- updateRole,
- deleteRole,
- };
-}
-
-// ============ Invites Hook ============
-
export function useInvites(searchSpaceId: number) {
const [invites, setInvites] = useState([]);
const [loading, setLoading] = useState(true);
@@ -480,63 +349,6 @@ export function useInvites(searchSpaceId: number) {
// ============ Permissions Hook ============
-export function usePermissions() {
- const [permissions, setPermissions] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const fetchPermissions = useCallback(async () => {
- try {
- setLoading(true);
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/permissions`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}));
- throw new Error(errorData.detail || "Failed to fetch permissions");
- }
-
- const data = await response.json();
- setPermissions(data.permissions);
- setError(null);
- return data.permissions;
- } catch (err: any) {
- setError(err.message || "Failed to fetch permissions");
- console.error("Error fetching permissions:", err);
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- fetchPermissions();
- }, [fetchPermissions]);
-
- // Group permissions by category
- const groupedPermissions = useMemo(() => {
- const groups: Record = {};
- for (const perm of permissions) {
- if (!groups[perm.category]) {
- groups[perm.category] = [];
- }
- groups[perm.category].push(perm);
- }
- return groups;
- }, [permissions]);
-
- return {
- permissions,
- groupedPermissions,
- loading,
- error,
- fetchPermissions,
- };
-}
-
-// ============ User Access Hook ============
-
export function useUserAccess(searchSpaceId: number) {
const [access, setAccess] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/surfsense_web/hooks/use-user.ts b/surfsense_web/hooks/use-user.ts
deleted file mode 100644
index e81ac350b..000000000
--- a/surfsense_web/hooks/use-user.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
-import { authenticatedFetch } from "@/lib/auth-utils";
-
-interface User {
- id: string;
- email: string;
- is_active: boolean;
- is_superuser: boolean;
- is_verified: boolean;
- pages_limit: number;
- pages_used: number;
-}
-
-export function useUser() {
- const [user, setUser] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const fetchUser = async () => {
- try {
- // Only run on client-side
- if (typeof window === "undefined") return;
-
- setLoading(true);
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/users/me`,
- { method: "GET" }
- );
-
- if (!response.ok) {
- throw new Error(`Failed to fetch user: ${response.status}`);
- }
-
- const data = await response.json();
- setUser(data);
- setError(null);
- } catch (err: any) {
- setError(err.message || "Failed to fetch user");
- console.error("Error fetching user:", err);
- } finally {
- setLoading(false);
- }
- };
-
- fetchUser();
- }, []);
-
- return { user, loading, error };
-}
diff --git a/surfsense_web/lib/apis/notes-api.service.ts b/surfsense_web/lib/apis/notes-api.service.ts
new file mode 100644
index 000000000..5e8ab8a96
--- /dev/null
+++ b/surfsense_web/lib/apis/notes-api.service.ts
@@ -0,0 +1,147 @@
+import { z } from "zod";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+// Request/Response schemas
+const createNoteRequest = z.object({
+ search_space_id: z.number(),
+ title: z.string().min(1),
+ blocknote_document: z.array(z.any()).optional(),
+});
+
+const createNoteResponse = z.object({
+ id: z.number(),
+ title: z.string(),
+ document_type: z.string(),
+ content: z.string(),
+ content_hash: z.string(),
+ unique_identifier_hash: z.string().nullable(),
+ document_metadata: z.record(z.any()).nullable(),
+ search_space_id: z.number(),
+ created_at: z.string(),
+ updated_at: z.string().nullable(),
+});
+
+const getNotesRequest = z.object({
+ search_space_id: z.number(),
+ skip: z.number().optional(),
+ page: z.number().optional(),
+ page_size: z.number().optional(),
+});
+
+const noteItem = z.object({
+ id: z.number(),
+ title: z.string(),
+ document_type: z.string(),
+ content: z.string(),
+ content_hash: z.string(),
+ unique_identifier_hash: z.string().nullable(),
+ document_metadata: z.record(z.any()).nullable(),
+ search_space_id: z.number(),
+ created_at: z.string(),
+ updated_at: z.string().nullable(),
+});
+
+const getNotesResponse = z.object({
+ items: z.array(noteItem),
+ total: z.number(),
+ page: z.number(),
+ page_size: z.number(),
+ has_more: z.boolean(),
+});
+
+const deleteNoteRequest = z.object({
+ search_space_id: z.number(),
+ note_id: z.number(),
+});
+
+const deleteNoteResponse = z.object({
+ message: z.string(),
+ note_id: z.number(),
+});
+
+// Type exports
+export type CreateNoteRequest = z.infer;
+export type CreateNoteResponse = z.infer;
+export type GetNotesRequest = z.infer;
+export type GetNotesResponse = z.infer;
+export type NoteItem = z.infer;
+export type DeleteNoteRequest = z.infer;
+export type DeleteNoteResponse = z.infer;
+
+class NotesApiService {
+ /**
+ * Create a new note
+ */
+ createNote = async (request: CreateNoteRequest) => {
+ const parsedRequest = createNoteRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { search_space_id, title, blocknote_document } = parsedRequest.data;
+
+ // Send both title and blocknote_document in request body
+ const body = {
+ title,
+ ...(blocknote_document && { blocknote_document }),
+ };
+
+ return baseApiService.post(
+ `/api/v1/search-spaces/${search_space_id}/notes`,
+ createNoteResponse,
+ { body }
+ );
+ };
+
+ /**
+ * Get list of notes
+ */
+ getNotes = async (request: GetNotesRequest) => {
+ const parsedRequest = getNotesRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { search_space_id, skip, page, page_size } = parsedRequest.data;
+
+ // Build query params
+ const params = new URLSearchParams();
+ if (skip !== undefined) params.append("skip", String(skip));
+ if (page !== undefined) params.append("page", String(page));
+ if (page_size !== undefined) params.append("page_size", String(page_size));
+
+ return baseApiService.get(
+ `/api/v1/search-spaces/${search_space_id}/notes?${params.toString()}`,
+ getNotesResponse
+ );
+ };
+
+ /**
+ * Delete a note
+ */
+ deleteNote = async (request: DeleteNoteRequest) => {
+ const parsedRequest = deleteNoteRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { search_space_id, note_id } = parsedRequest.data;
+
+ return baseApiService.delete(
+ `/api/v1/search-spaces/${search_space_id}/notes/${note_id}`,
+ deleteNoteResponse
+ );
+ };
+}
+
+export const notesApiService = new NotesApiService();
diff --git a/surfsense_web/lib/apis/permissions-api.service.ts b/surfsense_web/lib/apis/permissions-api.service.ts
new file mode 100644
index 000000000..d161879b9
--- /dev/null
+++ b/surfsense_web/lib/apis/permissions-api.service.ts
@@ -0,0 +1,10 @@
+import { getPermissionsResponse } from "@/contracts/types/permissions.types";
+import { baseApiService } from "./base-api.service";
+
+class PermissionsApiService {
+ getPermissions = async () => {
+ return baseApiService.get(`/api/v1/permissions`, getPermissionsResponse);
+ };
+}
+
+export const permissionsApiService = new PermissionsApiService();
diff --git a/surfsense_web/lib/apis/roles-api.service.ts b/surfsense_web/lib/apis/roles-api.service.ts
new file mode 100644
index 000000000..1a574ee4b
--- /dev/null
+++ b/surfsense_web/lib/apis/roles-api.service.ts
@@ -0,0 +1,109 @@
+import {
+ type CreateRoleRequest,
+ createRoleRequest,
+ createRoleResponse,
+ type DeleteRoleRequest,
+ deleteRoleRequest,
+ deleteRoleResponse,
+ type GetRoleByIdRequest,
+ type GetRolesRequest,
+ getRoleByIdRequest,
+ getRoleByIdResponse,
+ getRolesRequest,
+ getRolesResponse,
+ type UpdateRoleRequest,
+ updateRoleRequest,
+ updateRoleResponse,
+} from "@/contracts/types/roles.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class RolesApiService {
+ createRole = async (request: CreateRoleRequest) => {
+ const parsedRequest = createRoleRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.post(
+ `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`,
+ createRoleResponse,
+ {
+ body: parsedRequest.data.data,
+ }
+ );
+ };
+
+ getRoles = async (request: GetRolesRequest) => {
+ const parsedRequest = getRolesRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.get(
+ `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles`,
+ getRolesResponse
+ );
+ };
+
+ getRoleById = async (request: GetRoleByIdRequest) => {
+ const parsedRequest = getRoleByIdRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.get(
+ `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
+ getRoleByIdResponse
+ );
+ };
+
+ updateRole = async (request: UpdateRoleRequest) => {
+ const parsedRequest = updateRoleRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.put(
+ `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
+ updateRoleResponse,
+ {
+ body: parsedRequest.data.data,
+ }
+ );
+ };
+
+ deleteRole = async (request: DeleteRoleRequest) => {
+ const parsedRequest = deleteRoleRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+
+ const errorMessage = parsedRequest.error.errors.map((err) => err.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.delete(
+ `/api/v1/searchspaces/${parsedRequest.data.search_space_id}/roles/${parsedRequest.data.role_id}`,
+ deleteRoleResponse
+ );
+ };
+}
+
+export const rolesApiService = new RolesApiService();
diff --git a/surfsense_web/lib/apis/search-spaces-api.service.ts b/surfsense_web/lib/apis/search-spaces-api.service.ts
index 73b57ee3c..23433faee 100644
--- a/surfsense_web/lib/apis/search-spaces-api.service.ts
+++ b/surfsense_web/lib/apis/search-spaces-api.service.ts
@@ -1,18 +1,18 @@
import {
type CreateSearchSpaceRequest,
- type DeleteSearchSpaceRequest,
- type GetSearchSpaceRequest,
- type GetSearchSpacesRequest,
- type UpdateSearchSpaceRequest,
createSearchSpaceRequest,
createSearchSpaceResponse,
+ type DeleteSearchSpaceRequest,
deleteSearchSpaceRequest,
deleteSearchSpaceResponse,
+ type GetSearchSpaceRequest,
+ type GetSearchSpacesRequest,
getCommunityPromptsResponse,
getSearchSpaceRequest,
getSearchSpaceResponse,
getSearchSpacesRequest,
getSearchSpacesResponse,
+ type UpdateSearchSpaceRequest,
updateSearchSpaceRequest,
updateSearchSpaceResponse,
} from "@/contracts/types/search-space.types";
@@ -71,7 +71,10 @@ class SearchSpacesApiService {
* Get community-curated prompts for search space system instructions
*/
getCommunityPrompts = async () => {
- return baseApiService.get(`/api/v1/searchspaces/prompts/community`, getCommunityPromptsResponse);
+ return baseApiService.get(
+ `/api/v1/searchspaces/prompts/community`,
+ getCommunityPromptsResponse
+ );
};
/**
diff --git a/surfsense_web/lib/apis/user-api.service.ts b/surfsense_web/lib/apis/user-api.service.ts
new file mode 100644
index 000000000..ea46ac116
--- /dev/null
+++ b/surfsense_web/lib/apis/user-api.service.ts
@@ -0,0 +1,13 @@
+import { getMeResponse } from "@/contracts/types/user.types";
+import { baseApiService } from "./base-api.service";
+
+class UserApiService {
+ /**
+ * Get current authenticated user
+ */
+ getMe = async () => {
+ return baseApiService.get(`/users/me`, getMeResponse);
+ };
+}
+
+export const userApiService = new UserApiService();
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 797c40b65..db7af6636 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -2,6 +2,7 @@ import type { GetChatsRequest } from "@/contracts/types/chat.types";
import type { GetDocumentsRequest } from "@/contracts/types/document.types";
import type { GetLLMConfigsRequest } from "@/contracts/types/llm-config.types";
import type { GetPodcastsRequest } from "@/contracts/types/podcast.types";
+import type { GetRolesRequest } from "@/contracts/types/roles.types";
import type { GetSearchSpacesRequest } from "@/contracts/types/search-space.types";
export const cacheKeys = {
@@ -40,5 +41,15 @@ export const cacheKeys = {
["search-spaces", ...(queries ? Object.values(queries) : [])] as const,
detail: (searchSpaceId: string) => ["search-spaces", searchSpaceId] as const,
communityPrompts: ["search-spaces", "community-prompts"] as const,
- }
+ },
+ user: {
+ current: () => ["user", "me"] as const,
+ },
+ roles: {
+ all: (searchSpaceId: string) => ["roles", searchSpaceId] as const,
+ byId: (searchSpaceId: string, roleId: string) => ["roles", searchSpaceId, roleId] as const,
+ },
+ permissions: {
+ all: () => ["permissions"] as const,
+ },
};
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index 140b8363d..e2cf89b5f 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -642,7 +642,17 @@
"no_chats_found": "No chats found",
"no_recent_chats": "No recent chats",
"view_all_chats": "View All Chats",
- "search_space": "Search Space"
+ "search_space": "Search Space",
+ "notes": "Notes",
+ "all_notes": "All Notes",
+ "all_notes_description": "Browse and manage all your notes",
+ "search_notes": "Search notes...",
+ "no_results_found": "No notes found",
+ "try_different_search": "Try a different search term",
+ "no_notes": "No notes yet",
+ "create_new_note": "Create a new note",
+ "error_loading_notes": "Error loading notes",
+ "loading": "Loading..."
},
"errors": {
"something_went_wrong": "Something went wrong",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 5fe2239cf..38546bb87 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -642,7 +642,17 @@
"no_chats_found": "未找到对话",
"no_recent_chats": "暂无最近对话",
"view_all_chats": "查看所有对话",
- "search_space": "搜索空间"
+ "search_space": "搜索空间",
+ "notes": "笔记",
+ "all_notes": "所有笔记",
+ "all_notes_description": "浏览和管理您的所有笔记",
+ "search_notes": "搜索笔记...",
+ "no_results_found": "未找到笔记",
+ "try_different_search": "尝试其他搜索词",
+ "no_notes": "暂无笔记",
+ "create_new_note": "创建新笔记",
+ "error_loading_notes": "加载笔记时出错",
+ "loading": "加载中..."
},
"errors": {
"something_went_wrong": "出错了",