fix: 4 API bugs found by integration tests
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
1. tags.owner_id column missing — Migration 0105 adds owner_id to tags table 2. contacts trigger first_name vs firstname — Migration 0105 recreates unified_search TSV trigger with correct column names (firstname, surname, etc.) 3. create_webhook() missing is_system_admin param — Add to webhook_service.py 4. Missing GET /api/v1/search endpoint — Add to unified_search/routes.py with shared _do_search() helper for GET+POST Alembic head: 0104 → 0105
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
"""Fix tags.owner_id missing column and contacts_tsv_trigger column mismatch.
|
||||||
|
|
||||||
|
Bug 1: tags.owner_id — Migration 0102 tried to add owner_id to tags but only
|
||||||
|
if the table existed at that point. If the tags table was created later (by
|
||||||
|
plugin migration), owner_id was never added. This migration ensures owner_id
|
||||||
|
exists on the tags table.
|
||||||
|
|
||||||
|
Bug 2: contacts_tsv_trigger — The unified_search plugin migration 0001 created
|
||||||
|
a trigger function referencing NEW.first_name, NEW.last_name, NEW.email,
|
||||||
|
NEW.phone, NEW.mobile, NEW.notes. After migration 0021 unified contacts,
|
||||||
|
the columns are named firstname, surname, email_1, phone_1, phone_2,
|
||||||
|
projectnote. The trigger must be recreated with correct column names.
|
||||||
|
|
||||||
|
Revision ID: 0105
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
|
||||||
|
revision = "0105"
|
||||||
|
down_revision = "0104"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM information_schema.tables "
|
||||||
|
"WHERE table_name = :name)"
|
||||||
|
),
|
||||||
|
{"name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :table AND column_name = :col)"
|
||||||
|
),
|
||||||
|
{"table": table_name, "col": column_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ── Bug 1: Add owner_id to tags table if missing ──
|
||||||
|
if _table_exists(conn, "tags"):
|
||||||
|
if not _column_exists(conn, "tags", "owner_id"):
|
||||||
|
op.add_column(
|
||||||
|
"tags",
|
||||||
|
sa.Column(
|
||||||
|
"owner_id",
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_tags_owner_id",
|
||||||
|
"tags",
|
||||||
|
["owner_id"],
|
||||||
|
)
|
||||||
|
print("[0105] Added owner_id to tags table")
|
||||||
|
else:
|
||||||
|
print("[0105] tags.owner_id already exists — skipping")
|
||||||
|
else:
|
||||||
|
print("[0105] tags table does not exist — skipping")
|
||||||
|
|
||||||
|
# ── Bug 2: Recreate contacts_tsv_trigger with correct column names ──
|
||||||
|
if _table_exists(conn, "contacts"):
|
||||||
|
# Drop old trigger and function
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS contacts_tsv_update ON contacts")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS contacts_tsv_trigger()")
|
||||||
|
|
||||||
|
# Recreate trigger function with current column names
|
||||||
|
# Contacts table after migration 0021 uses: firstname, surname, email_1,
|
||||||
|
# email_2, phone_1, phone_2, name, displayname, code, mailing_city,
|
||||||
|
# mailing_postalcode, tags, projectnote
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION contacts_tsv_trigger() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_tsv :=
|
||||||
|
setweight(to_tsvector('pg_catalog.german',
|
||||||
|
coalesce(NEW.name, '') || ' ' || coalesce(NEW.displayname, '') ||
|
||||||
|
' ' || coalesce(NEW.firstname, '') || ' ' || coalesce(NEW.surname, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('pg_catalog.german',
|
||||||
|
coalesce(NEW.email_1, '') || ' ' || coalesce(NEW.email_2, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('pg_catalog.german',
|
||||||
|
coalesce(NEW.phone_1, '') || ' ' || coalesce(NEW.phone_2, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('pg_catalog.german',
|
||||||
|
coalesce(NEW.code, '') || ' ' || coalesce(NEW.mailing_city, '') ||
|
||||||
|
' ' || coalesce(NEW.mailing_postalcode, '') || ' ' || coalesce(NEW.tags, '') ||
|
||||||
|
' ' || coalesce(NEW.projectnote, '')), 'D');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recreate trigger
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER contacts_tsv_update
|
||||||
|
BEFORE INSERT OR UPDATE ON contacts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION contacts_tsv_trigger();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print("[0105] Recreated contacts_tsv_trigger with correct column names")
|
||||||
|
else:
|
||||||
|
print("[0105] contacts table does not exist — skipping trigger fix")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Restore old trigger function (with incorrect column names for rollback)
|
||||||
|
if _table_exists(conn, "contacts"):
|
||||||
|
op.execute("DROP TRIGGER IF EXISTS contacts_tsv_update ON contacts")
|
||||||
|
op.execute("DROP FUNCTION IF EXISTS contacts_tsv_trigger()")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION contacts_tsv_trigger() RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.search_tsv :=
|
||||||
|
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.first_name, '') || ' ' || coalesce(NEW.last_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.email, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.phone, '') || ' ' || coalesce(NEW.mobile, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('pg_catalog.german', coalesce(NEW.notes, '')), 'D');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER contacts_tsv_update
|
||||||
|
BEFORE INSERT OR UPDATE ON contacts
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION contacts_tsv_trigger();
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove owner_id from tags
|
||||||
|
if _table_exists(conn, "tags") and _column_exists(conn, "tags", "owner_id"):
|
||||||
|
op.drop_index("ix_tags_owner_id", table_name="tags")
|
||||||
|
op.drop_column("tags", "owner_id")
|
||||||
|
""
|
||||||
@@ -42,13 +42,27 @@ router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
|||||||
|
|
||||||
# ─── Search ───
|
# ─── Search ───
|
||||||
|
|
||||||
@router.post("", dependencies=[Depends(require_permission("search:read"))])
|
@router.get("", dependencies=[Depends(require_permission("search:read"))])
|
||||||
async def search(
|
async def search_get(
|
||||||
req: SearchRequest,
|
q: str = Query(..., min_length=1, max_length=500, description="Search query"),
|
||||||
|
entity_types: str | None = Query(None, description="Comma-separated entity types to search"),
|
||||||
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> SearchResponse:
|
) -> SearchResponse:
|
||||||
"""Perform hybrid search with KI query understanding."""
|
"""Perform hybrid search via GET (same as POST but with query params)."""
|
||||||
|
types_list = entity_types.split(",") if entity_types else None
|
||||||
|
req = SearchRequest(query=q, entity_types=types_list, limit=limit, offset=offset)
|
||||||
|
return await _do_search(req, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_search(
|
||||||
|
req: SearchRequest,
|
||||||
|
current_user: dict,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> SearchResponse:
|
||||||
|
"""Shared search logic used by both GET and POST endpoints."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
is_system_admin = current_user.get("is_system_admin", False)
|
is_system_admin = current_user.get("is_system_admin", False)
|
||||||
@@ -107,6 +121,16 @@ async def search(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", dependencies=[Depends(require_permission("search:read"))])
|
||||||
|
async def search(
|
||||||
|
req: SearchRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SearchResponse:
|
||||||
|
"""Perform hybrid search with KI query understanding."""
|
||||||
|
return await _do_search(req, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
# ─── Suggest / Autocomplete ───
|
# ─── Suggest / Autocomplete ───
|
||||||
|
|
||||||
@router.get("/suggest", dependencies=[Depends(require_permission("search:read"))])
|
@router.get("/suggest", dependencies=[Depends(require_permission("search:read"))])
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ async def create_webhook(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
|
is_system_admin: bool = False,
|
||||||
) -> Webhook:
|
) -> Webhook:
|
||||||
"""Create a new webhook subscription."""
|
"""Create a new webhook subscription."""
|
||||||
webhook = Webhook(
|
webhook = Webhook(
|
||||||
|
|||||||
Reference in New Issue
Block a user