From 65699f7b85614ea8de60734da146915ba6fadebb Mon Sep 17 00:00:00 2001 From: Mike Yavorsky Date: Tue, 7 Apr 2026 20:09:48 -0400 Subject: [PATCH 1/4] disable some tabs, enable officer/agency/unit --- backend/routes/search.py | 244 +++++++++++------- .../components/search/SearchResultsTabs.tsx | 7 +- frontend/providers/SearchProvider.tsx | 4 + frontend/utils/apiRoutes.ts | 4 +- 4 files changed, 155 insertions(+), 104 deletions(-) diff --git a/backend/routes/search.py b/backend/routes/search.py index c6002ea58..ef4317524 100644 --- a/backend/routes/search.py +++ b/backend/routes/search.py @@ -174,105 +174,150 @@ def fetch_details(uids: List[str], type: str) -> Dict[str, dict]: return details -def build_agency_result(node, details_row: dict) -> Searchresult: - if not isinstance(node, Agency): - a = Agency.inflate(node) - else: - a = node - uid = a.uid - details = [] - - subtitle = "{} Agency in {}, {}".format( - a.jurisdiction_enum.describe() if a.jurisdiction_enum else "", - a.hq_city if a.hq_city else "Unknown City", - a.hq_state if a.hq_state else "Unknown State" - ) - - details.append("{} Unit(s), {} Officer(s), {} Complaint(s)".format( - details_row.get("units", 0), - details_row.get("officers", 0), - details_row.get("complaints", 0) - )) - return Searchresult( - uid=uid, - title=a.name, - subtitle=subtitle, - details=details, - content_type="Agency", - source=details_row.get("source", "Unknown Source"), - last_updated=details_row.get("last_updated", None), - href=f"/api/v1/agencies/{uid}" - ) - - -def build_unit_result(node, details_row: dict) -> Searchresult: - if not isinstance(node, Unit): - u = Unit.inflate(node) - else: - u = node - uid = u.uid - details = [] - - subtitle = "Established by {}".format( - details_row.get("agency_name", "Unknown Agency") - ) - details.append("{} Officer(s), {} Complaint(s)".format( - details_row.get("officers", 0), - details_row.get("complaints", 0) - )) - - return Searchresult( - uid=uid, - title=u.name, - subtitle=subtitle, - details=details, - content_type="Unit", - source=details_row.get("source", "Unknown Source"), - last_updated=details_row.get("last_updated", None), - href=f"/api/v1/units/{uid}" - ) - - -def build_officer_result(node, details_row: dict) -> Searchresult: - if not isinstance(node, Officer): - o = Officer.inflate(node) - else: - o = node - uid = o.uid - details = [] - - subtitle = "{ethnicity} {gender}, {rank} at the {agency}".format( - ethnicity=( - o.ethnicity_enum.describe() - if o.ethnicity_enum else "Unknown Ethnicity" - ), - gender=( - o.gender_enum.describe() - if o.gender_enum else "Unknown Gender" - ), - rank=( - details_row.get("rank", "Officer") - ), - agency=( +def build_agency_result(node, details_row: dict) -> Searchresult | None: + try: + if not isinstance(node, Agency): + a = Agency.inflate(node) + else: + a = node + uid = a.uid + details = [] + + subtitle = "{} Agency in {}, {}".format( + a.jurisdiction_enum.describe() if a.jurisdiction_enum else "", + a.hq_city if a.hq_city else "Unknown City", + a.hq_state if a.hq_state else "Unknown State" + ) + + details.append("{} Unit(s), {} Officer(s), {} Complaint(s)".format( + details_row.get("units", 0), + details_row.get("officers", 0), + details_row.get("complaints", 0) + )) + return Searchresult( + uid=uid, + title=a.name, + subtitle=subtitle, + details=details, + content_type="Agency", + source=details_row.get("source", "Unknown Source"), + last_updated=details_row.get("last_updated", None), + href=f"/api/v1/agencies/{uid}" + ) + except Exception as e: + failed_uid = None + try: + failed_uid = getattr(node, "get", lambda *_: None)("uid") or getattr(node, "uid", None) + except Exception: + pass + + logging.warning( + "Failed to build agency search result for node uid=%s: %s", + failed_uid, + e, + exc_info=True, + ) + return None + + +def build_unit_result(node, details_row: dict) -> Searchresult | None: + try: + if not isinstance(node, Unit): + u = Unit.inflate(node) + else: + u = node + uid = u.uid + details = [] + + subtitle = "Established by {}".format( details_row.get("agency_name", "Unknown Agency") ) - ) - - details.append("{} Complaints, {} Allegations, {} Substantiated".format( - details_row.get("complaints", 0), - details_row.get("allegations", 0), - details_row.get("substantiated", 0) - )) - return Searchresult( - uid=uid, - title=o.full_name, - subtitle=subtitle, - details=details, - content_type="Officer", - source=details_row.get("source", "Unknown Source"), - last_updated=details_row.get("last_updated", None), - href=f"/api/v1/officers/{uid}" - ) + details.append("{} Officer(s), {} Complaint(s)".format( + details_row.get("officers", 0), + details_row.get("complaints", 0) + )) + + return Searchresult( + uid=uid, + title=u.name, + subtitle=subtitle, + details=details, + content_type="Unit", + source=details_row.get("source", "Unknown Source"), + last_updated=details_row.get("last_updated", None), + href=f"/api/v1/units/{uid}" + ) + except Exception as e: + failed_uid = None + try: + failed_uid = getattr(node, "get", lambda *_: None)("uid") or getattr(node, "uid", None) + except Exception: + pass + + logging.warning( + "Failed to build unit search result for node uid=%s: %s", + failed_uid, + e, + exc_info=True, + ) + return None + + +def build_officer_result(node, details_row: dict) -> Searchresult | None: + try: + if not isinstance(node, Officer): + o = Officer.inflate(node) + else: + o = node + uid = o.uid + details = [] + + subtitle = "{ethnicity} {gender}, {rank} at the {agency}".format( + ethnicity=( + o.ethnicity_enum.describe() + if o.ethnicity_enum else "Unknown Ethnicity" + ), + gender=( + o.gender_enum.describe() + if o.gender_enum else "Unknown Gender" + ), + rank=( + details_row.get("rank", "Officer") + ), + agency=( + details_row.get("agency_name", "Unknown Agency") + ) + ) + + details.append("{} Complaints, {} Allegations, {} Substantiated".format( + details_row.get("complaints", 0), + details_row.get("allegations", 0), + details_row.get("substantiated", 0) + )) + return Searchresult( + uid=uid, + title=o.full_name, + subtitle=subtitle, + details=details, + content_type="Officer", + source=details_row.get("source", "Unknown Source"), + last_updated=details_row.get("last_updated", None), + href=f"/api/v1/officers/{uid}" + ) + except Exception as e: + failed_uid = None + try: + failed_uid = getattr(node, "get", lambda *_: None)("uid") or getattr(node, "uid", None) + except Exception: + pass + + logging.warning( + "Failed to build officer search result for node uid=%s: %s", + failed_uid, + e, + exc_info=True, + ) + return None def group_nodes_by_type(results) -> Dict[str, List]: @@ -307,7 +352,7 @@ def group_nodes_by_type(results) -> Dict[str, List]: # Text Search Endpoint -@bp.route("/", methods=["GET"]) +@bp.route("/", methods=["GET", "OPTIONS"]) @jwt_required() @min_role_required(UserRole.PUBLIC) def text_search(): @@ -411,7 +456,8 @@ def text_search(): else: continue - page.append(item.model_dump()) + if item: + page.append(item.model_dump()) response = add_pagination_wrapper( page_data=page, total=total_results, diff --git a/frontend/components/search/SearchResultsTabs.tsx b/frontend/components/search/SearchResultsTabs.tsx index 90dd7ade1..1eb83e9a6 100644 --- a/frontend/components/search/SearchResultsTabs.tsx +++ b/frontend/components/search/SearchResultsTabs.tsx @@ -18,14 +18,13 @@ export default function SearchResultsTabs({ tab, updateTab }: SearchResultsTabsP slotProps={{ indicator: { style: { backgroundColor: "black" } } }} sx={{ "& .MuiTab-root": { color: "black" } - }} - > + }}> - - + + ) diff --git a/frontend/providers/SearchProvider.tsx b/frontend/providers/SearchProvider.tsx index 4d99c3d10..5aa8dec55 100644 --- a/frontend/providers/SearchProvider.tsx +++ b/frontend/providers/SearchProvider.tsx @@ -86,6 +86,10 @@ function useHook(): SearchContext { switch (tab) { case 1: return API_ROUTES.search.officers + case 2: + return API_ROUTES.search.agencies + case 3: + return API_ROUTES.search.units default: return API_ROUTES.search.all } diff --git a/frontend/utils/apiRoutes.ts b/frontend/utils/apiRoutes.ts index ce23f3d50..7d7ab13eb 100644 --- a/frontend/utils/apiRoutes.ts +++ b/frontend/utils/apiRoutes.ts @@ -9,7 +9,9 @@ const API_ROUTES = { search: { all: "/search/", incidents: "/incidents/search", - officers: "/officers/" + officers: "/officers", + agencies: "/agencies", + units: "/units/" }, users: { self: "/users/self" From 765957e8be1070e8f463dd7a7e433712e9475055 Mon Sep 17 00:00:00 2001 From: Mike Yavorsky Date: Tue, 7 Apr 2026 20:15:50 -0400 Subject: [PATCH 2/4] no JWT needed for options? --- backend/auth/jwt.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/auth/jwt.py b/backend/auth/jwt.py index f1a05af8a..ab324a39d 100644 --- a/backend/auth/jwt.py +++ b/backend/auth/jwt.py @@ -1,7 +1,7 @@ from flask_jwt_extended import JWTManager, verify_jwt_in_request, get_jwt from functools import wraps from ..database.models.user import User -from flask import abort +from flask import abort, request jwt = JWTManager() @@ -52,6 +52,9 @@ def min_role_required(*roles): def wrapper(fn): @wraps(fn) def decorator(*args, **kwargs): + # Skip auth checks for CORS preflight requests + if request.method == "OPTIONS": + return fn(*args, **kwargs) if verify_roles_or_abort(roles): return fn(*args, **kwargs) @@ -64,6 +67,9 @@ def contributor_has_source(): def wrapper(fn): @wraps(fn) def decorator(*args, **kwargs): + # Skip auth checks for CORS preflight requests + if request.method == "OPTIONS": + return fn(*args, **kwargs) if verify_contributor_has_source_or_abort(): return fn(*args, **kwargs) From 91e36b3d5f913c931304204c23dcf43167554993 Mon Sep 17 00:00:00 2001 From: Mike Yavorsky Date: Tue, 7 Apr 2026 20:33:46 -0400 Subject: [PATCH 3/4] transform officer search results --- frontend/providers/SearchProvider.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/providers/SearchProvider.tsx b/frontend/providers/SearchProvider.tsx index 5aa8dec55..c24405217 100644 --- a/frontend/providers/SearchProvider.tsx +++ b/frontend/providers/SearchProvider.tsx @@ -7,6 +7,7 @@ import API_ROUTES, { apiBaseUrl } from "@/utils/apiRoutes" import { ApiError } from "@/utils/apiError" import { useRouter, useSearchParams } from "next/navigation" import { getParamKeys } from "./config" +import { transformSearchResults } from "@/utils/searchTransform" interface SearchContext { searchAll: ( @@ -143,9 +144,25 @@ function useHook(): SearchContext { } const data: PaginatedSearchResponses = await response.json() - setResults(data) + + // Determine content type from current tab + let contentType: string | undefined + switch (updatedTab !== undefined ? updatedTab : tab) { + case 1: + contentType = "Officer" + break + case 2: + contentType = "Agency" + break + case 3: + contentType = "Unit" + break + } + + const transformedData = transformSearchResults(data, contentType) + setResults(transformedData) setError(null) - return data + return transformedData } catch (err) { if (err instanceof Error && err.name === "AbortError") { return { results: [] } From ea9586928675f7ef943d50cd8be6fe80aa8fe0bb Mon Sep 17 00:00:00 2001 From: Mike Yavorsky Date: Tue, 7 Apr 2026 20:38:41 -0400 Subject: [PATCH 4/4] try to resolve CORS issues --- backend/auth/jwt.py | 6 ++++++ backend/routes/agencies.py | 2 +- backend/routes/officers.py | 2 +- backend/routes/units.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/auth/jwt.py b/backend/auth/jwt.py index ab324a39d..9e49c0878 100644 --- a/backend/auth/jwt.py +++ b/backend/auth/jwt.py @@ -8,6 +8,9 @@ def verify_roles_or_abort(min_role): + # Skip auth checks for CORS preflight requests + if request.method == "OPTIONS": + return True verify_jwt_in_request() jwt_decoded = get_jwt() current_user = User.get(jwt_decoded["sub"]) @@ -21,6 +24,9 @@ def verify_roles_or_abort(min_role): def verify_contributor_has_source_or_abort(): + # Skip auth checks for CORS preflight requests + if request.method == "OPTIONS": + return True verify_jwt_in_request() jwt_decoded = get_jwt() current_user = User.get(jwt_decoded["sub"]) diff --git a/backend/routes/agencies.py b/backend/routes/agencies.py index 6bfcde03b..9a710b6cd 100644 --- a/backend/routes/agencies.py +++ b/backend/routes/agencies.py @@ -148,7 +148,7 @@ def delete_agency(agency_id: str): # Get all agencies -@bp.route("/", methods=["GET"]) +@bp.route("/", methods=["GET", "OPTIONS"], strict_slashes=False) @jwt_required() @min_role_required(UserRole.PUBLIC) def get_all_agencies(): diff --git a/backend/routes/officers.py b/backend/routes/officers.py index d4ba35ef6..48710e0b3 100644 --- a/backend/routes/officers.py +++ b/backend/routes/officers.py @@ -73,7 +73,7 @@ def get_officer(officer_uid: str): # Get all officers -@bp.route("", methods=["GET"]) +@bp.route("", methods=["GET", "OPTIONS"], strict_slashes=False) @jwt_required() @min_role_required(UserRole.PUBLIC) def get_all_officers(): diff --git a/backend/routes/units.py b/backend/routes/units.py index ca95e38f2..d3b76b8f0 100644 --- a/backend/routes/units.py +++ b/backend/routes/units.py @@ -13,7 +13,7 @@ unit_service = UnitService() -@bp.route("", methods=["GET"]) +@bp.route("", methods=["GET", "OPTIONS"], strict_slashes=False) @jwt_required() @min_role_required(UserRole.PUBLIC) def get_all_units():