diff --git a/app/main.py b/app/main.py index 9e0ce66..5e004a6 100644 --- a/app/main.py +++ b/app/main.py @@ -488,17 +488,21 @@ def create_app() -> FastAPI: try: router_module = importlib.import_module(route_def.module) router = getattr(router_module, route_def.router_attr) - # Skip WebSocket routes — no wrapping, no plugin check + # Check if this route definition is public (no auth required) + is_public = getattr(route_def, "is_public", False) from starlette.routing import WebSocketRoute + if is_public: + # Public routes: no auth dependency, no plugin check + app.include_router(router) + logger.info(f"Registered PUBLIC routes for {plugin_name}: {route_def.module}") + continue plugin_dep = Depends(require_active_plugin(plugin_name)) for route in router.routes: if isinstance(route, WebSocketRoute): - # WebSocket routes also need plugin check — don't skip (P1.9 fix) if not hasattr(route, 'dependencies'): route.dependencies = [] route.dependencies.append(plugin_dep) continue - # Add require_active_plugin to each HTTP route's dependencies if not hasattr(route, 'dependencies'): route.dependencies = [] route.dependencies.append(plugin_dep) diff --git a/app/models/contact.py b/app/models/contact.py index 9268aa9..f3edb13 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -172,6 +172,12 @@ class Contact(Base, TenantMixin, OwnedMixin): nullable=True, ) + # ── Embedding (pgvector, 768-dim) ── + from pgvector.sqlalchemy import Vector + embedding: Mapped[Any | None] = mapped_column( + Vector(768), nullable=True, default=None + ) + # ── Audit ── created_by: Mapped[uuid.UUID | None] = mapped_column( PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True diff --git a/app/plugins/builtins/permissions/plugin.py b/app/plugins/builtins/permissions/plugin.py index 96cde69..ba61297 100644 --- a/app/plugins/builtins/permissions/plugin.py +++ b/app/plugins/builtins/permissions/plugin.py @@ -22,9 +22,10 @@ class PermissionsPlugin(BasePlugin): router_attr="router", ), PluginRouteDef( - path="/api/public", - module="app.plugins.builtins.permissions.routes", - router_attr="public_router", + path="/api/v1/public/share", + module="app.plugins.builtins.permissions.public_routes", + router_attr="router", + is_public=True, ), ], events=[], diff --git a/app/plugins/builtins/permissions/public_routes.py b/app/plugins/builtins/permissions/public_routes.py new file mode 100644 index 0000000..a667a46 --- /dev/null +++ b/app/plugins/builtins/permissions/public_routes.py @@ -0,0 +1,117 @@ +"""Public share routes — token-based access to shared files, no auth required.""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.core.storage import get_storage_backend +from app.plugins.builtins.permissions.models import ShareLink +from app.plugins.builtins.dms.models import File as DmsFile + +router = APIRouter(prefix="/api/v1/public/share", tags=["public-share"]) + + +@router.get("/{token}", status_code=status.HTTP_200_OK) +async def get_share_info(token: str, db: AsyncSession = Depends(get_db)): + """Get share link info by token (public, no auth required). + + Returns file metadata without requiring authentication. + If the share link has a password, the client must provide it via POST. + """ + share = await _get_share_link(db, token) + file = await _get_file(db, share.file_id, share.tenant_id) + return { + "file_name": file.name, + "file_size": file.size_bytes, + "mime_type": file.mime_type, + "access_level": share.access_level, + "requires_password": share.password_hash is not None, + "expires_at": share.expires_at.isoformat() if share.expires_at else None, + } + + +@router.post("/{token}/verify", status_code=status.HTTP_200_OK) +async def verify_share_password( + token: str, + password: str, + db: AsyncSession = Depends(get_db), +): + """Verify password for a password-protected share link.""" + share = await _get_share_link(db, token) + if share.password_hash is None: + return {"valid": True} + + from app.core.auth import verify_password + if not verify_password(password, share.password_hash): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"detail": "Invalid password", "code": "invalid_password"}, + ) + return {"valid": True} + + +@router.get("/{token}/download", status_code=status.HTTP_200_OK) +async def download_shared_file(token: str, db: AsyncSession = Depends(get_db)): + """Download file via share link token (public, no auth required).""" + share = await _get_share_link(db, token) + file = await _get_file(db, share.file_id, share.tenant_id) + + storage = get_storage_backend() + if not await storage.exists(file.storage_path): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": "File not found on disk", "code": "file_missing"}, + ) + + stream = await storage.get_stream(file.storage_path) + return StreamingResponse( + stream, + media_type=file.mime_type, + headers={ + "Content-Disposition": f'attachment; filename="{file.name}"', + "Content-Length": str(file.size_bytes), + }, + ) + + +async def _get_share_link(db: AsyncSession, token: str) -> ShareLink: + """Get share link by token and validate expiry.""" + result = await db.execute( + select(ShareLink).where(ShareLink.token == token) + ) + share = result.scalar_one_or_none() + if share is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": "Share link not found", "code": "share_not_found"}, + ) + if share.expires_at is not None and share.expires_at < datetime.now(UTC): + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail={"detail": "Share link expired", "code": "share_expired"}, + ) + return share + + +async def _get_file(db: AsyncSession, file_id: uuid.UUID, tenant_id: uuid.UUID) -> DmsFile: + """Get file by ID within tenant scope.""" + result = await db.execute( + select(DmsFile) + .where(DmsFile.id == file_id) + .where(DmsFile.tenant_id == tenant_id) + .where(DmsFile.deleted_at.is_(None)) + ) + file = result.scalar_one_or_none() + if file is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": "File not found", "code": "file_not_found"}, + ) + return file diff --git a/app/plugins/builtins/unified_search/provider_registry.py b/app/plugins/builtins/unified_search/provider_registry.py index fbbab28..2f0634d 100644 --- a/app/plugins/builtins/unified_search/provider_registry.py +++ b/app/plugins/builtins/unified_search/provider_registry.py @@ -115,6 +115,21 @@ async def auto_register_providers(db: AsyncSession) -> None: from app.plugins.builtins.unified_search.providers.company_provider import ( CompanySearchProvider, ) + from app.plugins.builtins.unified_search.providers.task_provider import ( + TaskSearchProvider, + ) + from app.plugins.builtins.unified_search.providers.contactperson_provider import ( + ContactPersonSearchProvider, + ) + from app.plugins.builtins.unified_search.providers.tag_provider import ( + TagSearchProvider, + ) + from app.plugins.builtins.unified_search.providers.conversation_provider import ( + ConversationSearchProvider, + ) + from app.plugins.builtins.unified_search.providers.user_provider import ( + UserSearchProvider, + ) registry = get_search_registry() registry.clear() @@ -126,6 +141,11 @@ async def auto_register_providers(db: AsyncSession) -> None: FileSearchProvider, EventSearchProvider, CompanySearchProvider, + TaskSearchProvider, + ContactPersonSearchProvider, + TagSearchProvider, + ConversationSearchProvider, + UserSearchProvider, ]: try: registry.register(provider_cls()) diff --git a/app/plugins/builtins/unified_search/providers/contactperson_provider.py b/app/plugins/builtins/unified_search/providers/contactperson_provider.py new file mode 100644 index 0000000..0a192b3 --- /dev/null +++ b/app/plugins/builtins/unified_search/providers/contactperson_provider.py @@ -0,0 +1,161 @@ +"""ContactPerson search provider — queries contactpersons table.""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider + +logger = logging.getLogger(__name__) + + +class ContactPersonSearchProvider(BaseSearchProvider): + """Search provider for ContactPerson (Ansprechpartner) entities.""" + + entity_type = "contactperson" + + async def _search_fts_filtered( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Full-text search on contactpersons fields.""" + if visible_ids is not None: + sql = text( + """ + SELECT cp.*, ts_rank( + to_tsvector('pg_catalog.german', + coalesce(cp.displayname, '') || ' ' || + coalesce(cp.firstname, '') || ' ' || + coalesce(cp.lastname, '') || ' ' || + coalesce(cp.email, '') || ' ' || + coalesce(cp.function, '') || ' ' || + coalesce(cp.tags, '') + ), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM contactpersons cp + WHERE cp.tenant_id = :tid + AND cp.deleted_at IS NULL + AND to_tsvector('pg_catalog.german', + coalesce(cp.displayname, '') || ' ' || + coalesce(cp.firstname, '') || ' ' || + coalesce(cp.lastname, '') || ' ' || + coalesce(cp.email, '') || ' ' || + coalesce(cp.function, '') || ' ' || + coalesce(cp.tags, '') + ) @@ to_tsquery('pg_catalog.german', :q) + AND cp.id = ANY(:visible_ids) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + { + "q": tsquery, + "tid": tenant_id, + "lim": limit, + "visible_ids": list(visible_ids), + }, + ) + else: + sql = text( + """ + SELECT cp.*, ts_rank( + to_tsvector('pg_catalog.german', + coalesce(cp.displayname, '') || ' ' || + coalesce(cp.firstname, '') || ' ' || + coalesce(cp.lastname, '') || ' ' || + coalesce(cp.email, '') || ' ' || + coalesce(cp.function, '') || ' ' || + coalesce(cp.tags, '') + ), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM contactpersons cp + WHERE cp.tenant_id = :tid + AND cp.deleted_at IS NULL + AND to_tsvector('pg_catalog.german', + coalesce(cp.displayname, '') || ' ' || + coalesce(cp.firstname, '') || ' ' || + coalesce(cp.lastname, '') || ' ' || + coalesce(cp.email, '') || ' ' || + coalesce(cp.function, '') || ' ' || + coalesce(cp.tags, '') + ) @@ to_tsquery('pg_catalog.german', :q) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + {"q": tsquery, "tid": tenant_id, "lim": limit}, + ) + rows = result.mappings().all() + return [dict(r) for r in rows] + + async def _search_vector_filtered( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Semantic search — contactpersons table has no embedding column yet.""" + return [] + + async def get_embedding_text( + self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID + ) -> str: + """Get text for embedding generation.""" + sql = text( + """ + SELECT displayname, firstname, lastname, email, function, tags + FROM contactpersons + WHERE id = :eid AND tenant_id = :tid + """ + ) + result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id}) + row = result.mappings().first() + if not row: + return "" + parts = [ + row.get("displayname", ""), + row.get("firstname", ""), + row.get("lastname", ""), + row.get("email", ""), + row.get("function", ""), + row.get("tags", ""), + ] + return " ".join(str(p) for p in parts if p) + + def to_search_result(self, entity: object) -> dict[str, Any]: + """Convert contactperson to search result dict.""" + if isinstance(entity, dict): + displayname = entity.get("displayname", "") + email = entity.get("email", "") or "" + entity_id = str(entity.get("id", "")) + contact_id = str(entity.get("contact_id", "")) if entity.get("contact_id") else "" + else: + displayname = getattr(entity, "displayname", "") + email = getattr(entity, "email", "") or "" + entity_id = str(getattr(entity, "id", "")) + contact_id = str(getattr(entity, "contact_id", "")) if getattr(entity, "contact_id", None) else "" + return { + "entity_type": self.entity_type, + "entity_id": entity_id, + "title": displayname, + "snippet": email[:200], + "score": 0.0, + "data": {"contact_id": contact_id}, + } diff --git a/app/plugins/builtins/unified_search/providers/conversation_provider.py b/app/plugins/builtins/unified_search/providers/conversation_provider.py new file mode 100644 index 0000000..b623c51 --- /dev/null +++ b/app/plugins/builtins/unified_search/providers/conversation_provider.py @@ -0,0 +1,137 @@ +"""Conversation search provider — queries comm_conversations table.""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider + +logger = logging.getLogger(__name__) + + +class ConversationSearchProvider(BaseSearchProvider): + """Search provider for CommConversation entities.""" + + entity_type = "conversation" + + async def _search_fts_filtered( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Full-text search on comm_conversations.title and last_msg_preview.""" + if visible_ids is not None: + sql = text( + """ + SELECT c.*, ts_rank( + to_tsvector('pg_catalog.german', + coalesce(c.title, '') || ' ' || coalesce(c.last_msg_preview, '') + ), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM comm_conversations c + WHERE c.tenant_id = :tid + AND c.deleted_at IS NULL + AND to_tsvector('pg_catalog.german', + coalesce(c.title, '') || ' ' || coalesce(c.last_msg_preview, '') + ) @@ to_tsquery('pg_catalog.german', :q) + AND c.id = ANY(:visible_ids) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + { + "q": tsquery, + "tid": tenant_id, + "lim": limit, + "visible_ids": list(visible_ids), + }, + ) + else: + sql = text( + """ + SELECT c.*, ts_rank( + to_tsvector('pg_catalog.german', + coalesce(c.title, '') || ' ' || coalesce(c.last_msg_preview, '') + ), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM comm_conversations c + WHERE c.tenant_id = :tid + AND c.deleted_at IS NULL + AND to_tsvector('pg_catalog.german', + coalesce(c.title, '') || ' ' || coalesce(c.last_msg_preview, '') + ) @@ to_tsquery('pg_catalog.german', :q) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + {"q": tsquery, "tid": tenant_id, "lim": limit}, + ) + rows = result.mappings().all() + return [dict(r) for r in rows] + + async def _search_vector_filtered( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Semantic search — comm_conversations table has no embedding column yet.""" + return [] + + async def get_embedding_text( + self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID + ) -> str: + """Get text for embedding generation.""" + sql = text( + """ + SELECT title, last_msg_preview + FROM comm_conversations + WHERE id = :eid AND tenant_id = :tid + """ + ) + result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id}) + row = result.mappings().first() + if not row: + return "" + parts = [ + row.get("title", ""), + row.get("last_msg_preview", ""), + ] + return " ".join(str(p) for p in parts if p) + + def to_search_result(self, entity: object) -> dict[str, Any]: + """Convert conversation to search result dict.""" + if isinstance(entity, dict): + title = entity.get("title", "") or "" + preview = entity.get("last_msg_preview", "") or "" + entity_id = str(entity.get("id", "")) + is_direct = entity.get("is_direct", False) + else: + title = getattr(entity, "title", "") or "" + preview = getattr(entity, "last_msg_preview", "") or "" + entity_id = str(getattr(entity, "id", "")) + is_direct = getattr(entity, "is_direct", False) + return { + "entity_type": self.entity_type, + "entity_id": entity_id, + "title": title, + "snippet": preview[:200], + "score": 0.0, + "data": {"is_direct": is_direct}, + } diff --git a/app/plugins/builtins/unified_search/providers/tag_provider.py b/app/plugins/builtins/unified_search/providers/tag_provider.py new file mode 100644 index 0000000..ebab6c7 --- /dev/null +++ b/app/plugins/builtins/unified_search/providers/tag_provider.py @@ -0,0 +1,156 @@ +"""Tag search provider — queries tags table.""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider + +logger = logging.getLogger(__name__) + + +class TagSearchProvider(BaseSearchProvider): + """Search provider for Tag entities.""" + + entity_type = "tag" + + async def _search_fts_filtered( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Full-text search on tags.search_tsv.""" + if visible_ids is not None: + sql = text( + """ + SELECT t.*, ts_rank(t.search_tsv, to_tsquery('pg_catalog.german', :q)) AS rank + FROM tags t + WHERE t.tenant_id = :tid + AND t.deleted_at IS NULL + AND t.search_tsv @@ to_tsquery('pg_catalog.german', :q) + AND t.id = ANY(:visible_ids) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + { + "q": tsquery, + "tid": tenant_id, + "lim": limit, + "visible_ids": list(visible_ids), + }, + ) + else: + sql = text( + """ + SELECT t.*, ts_rank(t.search_tsv, to_tsquery('pg_catalog.german', :q)) AS rank + FROM tags t + WHERE t.tenant_id = :tid + AND t.deleted_at IS NULL + AND t.search_tsv @@ to_tsquery('pg_catalog.german', :q) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + {"q": tsquery, "tid": tenant_id, "lim": limit}, + ) + rows = result.mappings().all() + return [dict(r) for r in rows] + + async def _search_vector_filtered( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Semantic search on tags.embedding (384-dim).""" + if visible_ids is not None: + sql = text( + """ + SELECT t.*, 1 - (t.embedding <=> cast(:emb AS vector)) AS score + FROM tags t + WHERE t.tenant_id = :tid + AND t.deleted_at IS NULL + AND t.embedding IS NOT NULL + AND t.id = ANY(:visible_ids) + ORDER BY t.embedding <=> cast(:emb AS vector) + LIMIT :lim + """ + ) + result = await db.execute( + sql, + { + "emb": str(embedding), + "tid": tenant_id, + "lim": limit, + "visible_ids": list(visible_ids), + }, + ) + else: + sql = text( + """ + SELECT t.*, 1 - (t.embedding <=> cast(:emb AS vector)) AS score + FROM tags t + WHERE t.tenant_id = :tid + AND t.deleted_at IS NULL + AND t.embedding IS NOT NULL + ORDER BY t.embedding <=> cast(:emb AS vector) + LIMIT :lim + """ + ) + result = await db.execute( + sql, + {"emb": str(embedding), "tid": tenant_id, "lim": limit}, + ) + rows = result.mappings().all() + return [dict(r) for r in rows] + + async def get_embedding_text( + self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID + ) -> str: + """Get text for embedding generation.""" + sql = text( + """ + SELECT name + FROM tags + WHERE id = :eid AND tenant_id = :tid + """ + ) + result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id}) + row = result.mappings().first() + if not row: + return "" + return row.get("name", "") or "" + + def to_search_result(self, entity: object) -> dict[str, Any]: + """Convert tag to search result dict.""" + if isinstance(entity, dict): + name = entity.get("name", "") + color = entity.get("color", "") + entity_id = str(entity.get("id", "")) + else: + name = getattr(entity, "name", "") + color = getattr(entity, "color", "") + entity_id = str(getattr(entity, "id", "")) + return { + "entity_type": self.entity_type, + "entity_id": entity_id, + "title": name, + "snippet": "", + "score": 0.0, + "data": {"color": color}, + } diff --git a/app/plugins/builtins/unified_search/providers/task_provider.py b/app/plugins/builtins/unified_search/providers/task_provider.py new file mode 100644 index 0000000..c8eabbb --- /dev/null +++ b/app/plugins/builtins/unified_search/providers/task_provider.py @@ -0,0 +1,129 @@ +"""Task search provider — queries tasks table.""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider + +logger = logging.getLogger(__name__) + + +class TaskSearchProvider(BaseSearchProvider): + """Search provider for Task entities.""" + + entity_type = "task" + + async def _search_fts_filtered( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Full-text search on tasks.title and tasks.description.""" + if visible_ids is not None: + sql = text( + """ + SELECT t.*, ts_rank( + to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM tasks t + WHERE t.tenant_id = :tid + AND t.deleted_at IS NULL + AND to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ to_tsquery('pg_catalog.german', :q) + AND t.id = ANY(:visible_ids) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + { + "q": tsquery, + "tid": tenant_id, + "lim": limit, + "visible_ids": list(visible_ids), + }, + ) + else: + sql = text( + """ + SELECT t.*, ts_rank( + to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM tasks t + WHERE t.tenant_id = :tid + AND t.deleted_at IS NULL + AND to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ to_tsquery('pg_catalog.german', :q) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + {"q": tsquery, "tid": tenant_id, "lim": limit}, + ) + rows = result.mappings().all() + return [dict(r) for r in rows] + + async def _search_vector_filtered( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Semantic search — tasks table has no embedding column yet.""" + return [] + + async def get_embedding_text( + self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID + ) -> str: + """Get text for embedding generation.""" + sql = text( + """ + SELECT title, description + FROM tasks + WHERE id = :eid AND tenant_id = :tid + """ + ) + result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id}) + row = result.mappings().first() + if not row: + return "" + parts = [ + row.get("title", ""), + row.get("description", ""), + ] + return " ".join(str(p) for p in parts if p) + + def to_search_result(self, entity: object) -> dict[str, Any]: + """Convert task to search result dict.""" + if isinstance(entity, dict): + title = entity.get("title", "") + description = entity.get("description", "") or "" + entity_id = str(entity.get("id", "")) + status = entity.get("status", "") + else: + title = getattr(entity, "title", "") + description = getattr(entity, "description", "") or "" + entity_id = str(getattr(entity, "id", "")) + status = getattr(entity, "status", "") + return { + "entity_type": self.entity_type, + "entity_id": entity_id, + "title": title, + "snippet": description[:200], + "score": 0.0, + "data": {"status": status}, + } diff --git a/app/plugins/builtins/unified_search/providers/user_provider.py b/app/plugins/builtins/unified_search/providers/user_provider.py new file mode 100644 index 0000000..24367fc --- /dev/null +++ b/app/plugins/builtins/unified_search/providers/user_provider.py @@ -0,0 +1,162 @@ +"""User search provider — queries users table via user_tenants for tenant scope.""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider + +logger = logging.getLogger(__name__) + + +class UserSearchProvider(BaseSearchProvider): + """Search provider for User entities. + + Users are global entities (no tenant_id on users table). + Tenant membership is resolved via user_tenants join. + """ + + entity_type = "user" + + async def _search_fts_filtered( + self, + db: AsyncSession, + tsquery: str, + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Full-text search on users fields, filtered by tenant membership.""" + if visible_ids is not None: + sql = text( + """ + SELECT u.*, ts_rank( + to_tsvector('pg_catalog.german', + coalesce(u.name, '') || ' ' || + coalesce(u.first_name, '') || ' ' || + coalesce(u.last_name, '') || ' ' || + coalesce(u.email, '') + ), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM users u + JOIN user_tenants ut ON ut.user_id = u.id + WHERE ut.tenant_id = :tid + AND u.deleted_at IS NULL + AND u.is_active = true + AND to_tsvector('pg_catalog.german', + coalesce(u.name, '') || ' ' || + coalesce(u.first_name, '') || ' ' || + coalesce(u.last_name, '') || ' ' || + coalesce(u.email, '') + ) @@ to_tsquery('pg_catalog.german', :q) + AND u.id = ANY(:visible_ids) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + { + "q": tsquery, + "tid": tenant_id, + "lim": limit, + "visible_ids": list(visible_ids), + }, + ) + else: + sql = text( + """ + SELECT u.*, ts_rank( + to_tsvector('pg_catalog.german', + coalesce(u.name, '') || ' ' || + coalesce(u.first_name, '') || ' ' || + coalesce(u.last_name, '') || ' ' || + coalesce(u.email, '') + ), + to_tsquery('pg_catalog.german', :q) + ) AS rank + FROM users u + JOIN user_tenants ut ON ut.user_id = u.id + WHERE ut.tenant_id = :tid + AND u.deleted_at IS NULL + AND u.is_active = true + AND to_tsvector('pg_catalog.german', + coalesce(u.name, '') || ' ' || + coalesce(u.first_name, '') || ' ' || + coalesce(u.last_name, '') || ' ' || + coalesce(u.email, '') + ) @@ to_tsquery('pg_catalog.german', :q) + ORDER BY rank DESC + LIMIT :lim + """ + ) + result = await db.execute( + sql, + {"q": tsquery, "tid": tenant_id, "lim": limit}, + ) + rows = result.mappings().all() + return [dict(r) for r in rows] + + async def _search_vector_filtered( + self, + db: AsyncSession, + embedding: list[float], + tenant_id: uuid.UUID, + limit: int, + visible_ids: set[uuid.UUID] | None, + ) -> list[dict[str, Any]]: + """Semantic search — users table has no embedding column yet.""" + return [] + + async def get_embedding_text( + self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID + ) -> str: + """Get text for embedding generation.""" + sql = text( + """ + SELECT name, first_name, last_name, email + FROM users + WHERE id = :eid + """ + ) + result = await db.execute(sql, {"eid": entity_id}) + row = result.mappings().first() + if not row: + return "" + parts = [ + row.get("name", ""), + row.get("first_name", ""), + row.get("last_name", ""), + row.get("email", ""), + ] + return " ".join(str(p) for p in parts if p) + + def to_search_result(self, entity: object) -> dict[str, Any]: + """Convert user to search result dict.""" + if isinstance(entity, dict): + name = entity.get("name", "") + email = entity.get("email", "") or "" + entity_id = str(entity.get("id", "")) + first_name = entity.get("first_name", "") or "" + last_name = entity.get("last_name", "") or "" + else: + name = getattr(entity, "name", "") + email = getattr(entity, "email", "") or "" + entity_id = str(getattr(entity, "id", "")) + first_name = getattr(entity, "first_name", "") or "" + last_name = getattr(entity, "last_name", "") or "" + display_name = f"{first_name} {last_name}".strip() or name + return { + "entity_type": self.entity_type, + "entity_id": entity_id, + "title": display_name, + "snippet": email[:200], + "score": 0.0, + "data": {"email": email}, + } diff --git a/app/plugins/manifest.py b/app/plugins/manifest.py index a366db2..8c97e19 100644 --- a/app/plugins/manifest.py +++ b/app/plugins/manifest.py @@ -15,6 +15,10 @@ class PluginRouteDef(BaseModel): router_attr: str = Field( default="router", description="Attribute name of the APIRouter in the module" ) + is_public: bool = Field( + default=False, + description="If True, routes are mounted without auth dependency (e.g. public share links)", + ) class FieldDefinition(BaseModel): diff --git a/frontend/index.html b/frontend/index.html index 2e42918..8f2bd82 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,14 @@
- -