Phase 5.1-5.4: PWA, Public Plugin Endpoints, Contacts Embedding, Search Coverage
Check Cross-Plugin Imports / check (push) Has been cancelled

5.1 Public Plugin Endpoints:
- PluginRouteDef.is_public field in manifest.py
- main.py: public routes mounted without auth dependency
- permissions/public_routes.py: token-based share link access (info, verify, download)
- permissions/plugin.py: public share route registered with is_public=True

5.2 PWA:
- vite.config.ts: VitePWA plugin configured (autoUpdate, workbox, runtime caching)
- frontend/public/manifest.json: PWA manifest with icons
- index.html: theme-color, manifest link, apple-touch-icon, apple-mobile-web-app meta
- Build generates sw.js + workbox (90 precache entries)

5.3 Contacts Embedding:
- contact.py: embedding column (Vector(768)) added to Contact model
- Migration 0002_embeddings.sql already exists (adds embedding + HNSW index)
- ContactSearchProvider already queries embedding column

5.4 Search Coverage:
- 5 new search providers: task, contactperson, tag, conversation, user
- All providers implement FTS search with tenant_id + deleted_at filters
- TagSearchProvider also supports vector search (384-dim embedding)
- provider_registry.py: all 5 new providers auto-registered
- Total: 10 search providers (was 5)
This commit is contained in:
Agent Zero
2026-08-04 14:49:35 +02:00
parent f704f7b032
commit cfb4c5ae8b
14 changed files with 994 additions and 8 deletions
+4 -3
View File
@@ -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=[],
@@ -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
@@ -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())
@@ -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},
}
@@ -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},
}
@@ -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},
}
@@ -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},
}
@@ -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},
}