From f15c3bec46c5a7caf72e9ea6ff1b0276e46b6043 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 4 Aug 2026 22:57:37 +0200 Subject: [PATCH] fix: 4 API bugs found by integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ..._fix_tags_owner_id_and_contacts_trigger.py | 153 ++++++++++++++++++ app/plugins/builtins/unified_search/routes.py | 32 +++- app/services/webhook_service.py | 1 + 3 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/0105_fix_tags_owner_id_and_contacts_trigger.py diff --git a/alembic/versions/0105_fix_tags_owner_id_and_contacts_trigger.py b/alembic/versions/0105_fix_tags_owner_id_and_contacts_trigger.py new file mode 100644 index 0000000..ba8e561 --- /dev/null +++ b/alembic/versions/0105_fix_tags_owner_id_and_contacts_trigger.py @@ -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") +"" \ No newline at end of file diff --git a/app/plugins/builtins/unified_search/routes.py b/app/plugins/builtins/unified_search/routes.py index c2fe5c3..188ac57 100644 --- a/app/plugins/builtins/unified_search/routes.py +++ b/app/plugins/builtins/unified_search/routes.py @@ -42,13 +42,27 @@ router = APIRouter(prefix="/api/v1/search", tags=["search"]) # ─── Search ─── -@router.post("", dependencies=[Depends(require_permission("search:read"))]) -async def search( - req: SearchRequest, +@router.get("", dependencies=[Depends(require_permission("search:read"))]) +async def search_get( + 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), db: AsyncSession = Depends(get_db), ) -> 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"]) user_id = uuid.UUID(current_user["user_id"]) 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 ─── @router.get("/suggest", dependencies=[Depends(require_permission("search:read"))]) diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index b9a2f27..1d6b4c7 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -113,6 +113,7 @@ async def create_webhook( tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict[str, Any], + is_system_admin: bool = False, ) -> Webhook: """Create a new webhook subscription.""" webhook = Webhook(