Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .agents/rules/changes-blocked.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
trigger: always_on
---

1. Do not change the nav bar and footer until i request you to change.
2. Do not replace the documents in frontend home page unless i tell you to change.
Comment on lines +5 to +6
698 changes: 388 additions & 310 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ DATA_BACKEND=csv
# RBAC_DEFAULT_ROLE=FIELD_OFFICER

# Dotted JWT claim paths (checked in order) used to resolve the role.
# RBAC_ROLE_CLAIM_PATHS=["app_metadata.role","user_metadata.role","role"]
# RBAC_ROLE_CLAIM_PATHS=["app_metadata.role","role"]
15 changes: 14 additions & 1 deletion backend/app-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,20 @@
"command": "python3 main.py",
"build_path": "./",
"stack": "python_3_10",
"env_variables": {},
"env_variables": {
"REQUIRE_AUTH": "true",
"ENVIRONMENT": "production",
"CACHE_L2_ENABLED": "true",
"CACHE_L2_SEGMENT_ID": "42154000000087004",
"CACHE_L2_TTL_SECONDS": "600",
"CACHE_ENABLED": "true",
"CACHE_TTL_SECONDS": "600",
"CACHE_MAX_ENTRIES": "1000",
"SUPABASE_PROJECT_REF": "gcxppkdtbvmleynrzqao",
"SUPABASE_JWKS_URL": "https://gcxppkdtbvmleynrzqao.supabase.co/auth/v1/.well-known/jwks.json",
"SUPABASE_JWT_ISSUER": "https://gcxppkdtbvmleynrzqao.supabase.co/auth/v1",
"SUPABASE_JWT_AUDIENCE": "authenticated"
},
"memory": 512,
"scripts": {
"preserve": "python -m pip install --platform manylinux2014_x86_64 --target ./vendor --implementation cp --python-version 310 --only-binary=:all: --upgrade -r ./requirements.txt",
Expand Down
28 changes: 19 additions & 9 deletions backend/app/api/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@

from __future__ import annotations

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request

from app.api.rbac_deps import require_permission
from app.core.cache import CatalystCacheService, get_cache_service
from app.schemas.analytics import (
DashboardMLSummaryPayload,
ForecastPayload,
HotspotsPayload,
StationRiskPayload,
)
from app.schemas.auth import AuthenticatedIdentity
from app.services.ml_analytics_service import MLAnalyticsService

router = APIRouter(prefix="/analytics", tags=["analytics"])
Expand All @@ -33,17 +35,19 @@ def get_ml_analytics_service() -> MLAnalyticsService:
description="Returns pre-computed DBSCAN spatial cluster summaries and assigned FIR hotspot records.",
)
async def get_hotspots(
request: Request,
service: MLAnalyticsService = Depends(get_ml_analytics_service),
cache: CatalystCacheService = Depends(get_cache_service),
_identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")),
) -> HotspotsPayload:
"""Get DBSCAN geospatial hotspots analysis payload."""
cache_key = "analytics_hotspots"
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return HotspotsPayload(**cached)

data = service.get_hotspots()
cache.put(cache_key, data)
cache.put(cache_key, data, req=request)
return HotspotsPayload(**data)


Expand All @@ -54,17 +58,19 @@ async def get_hotspots(
description="Returns station-level CCRI risk ranks, scores, tiers, and indicator factor breakdowns.",
)
async def get_risk_scores(
request: Request,
service: MLAnalyticsService = Depends(get_ml_analytics_service),
cache: CatalystCacheService = Depends(get_cache_service),
_identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")),
) -> StationRiskPayload:
"""Get station risk scores payload."""
cache_key = "analytics_risk_scores"
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return StationRiskPayload(**cached)

data = service.get_station_risk_scores()
cache.put(cache_key, data)
cache.put(cache_key, data, req=request)
return StationRiskPayload(**data)


Expand All @@ -75,6 +81,7 @@ async def get_risk_scores(
description="Returns predicted daily crime incident volume for N days ahead (1 to 30 days).",
)
async def get_forecast(
request: Request,
forecast_days: int = Query(
default=30,
ge=1,
Expand All @@ -83,15 +90,16 @@ async def get_forecast(
),
service: MLAnalyticsService = Depends(get_ml_analytics_service),
cache: CatalystCacheService = Depends(get_cache_service),
_identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")),
) -> ForecastPayload:
"""Get daily crime volume forecast payload."""
cache_key = f"analytics_forecast_{forecast_days}"
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return ForecastPayload(**cached)

data = service.get_forecast(forecast_days=forecast_days)
cache.put(cache_key, data)
cache.put(cache_key, data, req=request)
return ForecastPayload(**data)


Expand All @@ -102,15 +110,17 @@ async def get_forecast(
description="Returns aggregate spatial hotspot totals, station risk distributions, and 30-day forecast volume.",
)
async def get_summary(
request: Request,
service: MLAnalyticsService = Depends(get_ml_analytics_service),
cache: CatalystCacheService = Depends(get_cache_service),
_identity: AuthenticatedIdentity = Depends(require_permission("analytics.read")),
) -> DashboardMLSummaryPayload:
"""Get executive ML summary payload."""
cache_key = "analytics_summary"
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return DashboardMLSummaryPayload(**cached)

data = service.get_dashboard_ml_summary()
cache.put(cache_key, data)
cache.put(cache_key, data, req=request)
return DashboardMLSummaryPayload(**data)
3 changes: 2 additions & 1 deletion backend/app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@
async def get_me(
identity: AuthenticatedIdentity = Depends(get_current_identity),
) -> MeResponse:
"""Return verified user identity from the validated JWT."""
"""Return verified user identity and role from the validated JWT."""
return MeResponse(
user_id=identity.user_id,
authenticated=True,
email=identity.email,
role=identity.role,
)
15 changes: 14 additions & 1 deletion backend/app/api/auth_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,20 @@ async def get_current_identity(

request_id = get_request_id(request)

# If auth is disabled (development mode), return a default identity
# Production safety guard: dev-user-000 bypass is strictly prohibited in production
if settings.ENVIRONMENT == "production" and not settings.REQUIRE_AUTH:
logger.error("Security violation: REQUIRE_AUTH=false is prohibited in production environment")
raise HTTPException(
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
detail=_build_auth_error(
AUTH_NOT_CONFIGURED,
"Authentication enforcement is required in production.",
request_id,
),
)

# If auth is disabled (local development / offline test mode only), return mock dev identity
if not settings.REQUIRE_AUTH:
from app.core.rbac import ADMIN, PERMISSIONS

Expand Down
12 changes: 7 additions & 5 deletions backend/app/api/districts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from datetime import date
from typing import Optional

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request

from app.api.rbac_deps import require_permission
from app.core.cache import CatalystCacheService, get_cache_service
Expand Down Expand Up @@ -50,17 +50,18 @@ def _get_district_service(
"transactional data are included with zero-valued statistics.",
)
async def list_districts(
request: Request,
service: DistrictService = Depends(_get_district_service),
cache: CatalystCacheService = Depends(get_cache_service),
_identity: AuthenticatedIdentity = Depends(require_permission("districts.read")),
) -> DistrictListResponse:
cache_key = "districts_list"
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return DistrictListResponse(**cached)

result = service.list_all_districts()
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return DistrictListResponse(**result)


Expand All @@ -78,6 +79,7 @@ async def list_districts(
)
async def get_district_intelligence(
district_id: int,
request: Request,
start_date: Optional[date] = Query(
None, description="Inclusive start date (YYYY-MM-DD)"
),
Expand All @@ -101,7 +103,7 @@ async def get_district_intelligence(
crime_head=crime_head,
status=status,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return DistrictIntelligenceProfile(**cached)

Expand All @@ -112,5 +114,5 @@ async def get_district_intelligence(
crime_head=crime_head,
status=status,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return DistrictIntelligenceProfile(**result)
32 changes: 19 additions & 13 deletions backend/app/api/intelligence_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from datetime import date
from typing import Optional

from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import PlainTextResponse

from app.api.rbac_deps import require_permission
Expand Down Expand Up @@ -55,6 +55,7 @@ def _get_intelligence_service(
"All filters are optional and combine with AND semantics.",
)
async def get_intelligence_analytics(
request: Request,
district: Optional[str] = Query(None, description="Filter by district name"),
station_id: Optional[str] = Query(None, description="Filter by station ID"),
crime_head: Optional[str] = Query(None, description="Filter by crime category"),
Expand All @@ -78,7 +79,7 @@ async def get_intelligence_analytics(
start_date=start_date,
end_date=end_date,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return IntelligenceAnalyticsResponse(**cached)

Expand All @@ -90,7 +91,7 @@ async def get_intelligence_analytics(
start_date=start_date,
end_date=end_date,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return IntelligenceAnalyticsResponse(**result)


Expand All @@ -107,6 +108,7 @@ async def get_intelligence_analytics(
"All filters are optional and combine with AND semantics.",
)
async def get_intelligence_heatmap(
request: Request,
district: Optional[str] = Query(None, description="Filter by district name"),
station_id: Optional[str] = Query(None, description="Filter by station ID"),
crime_head: Optional[str] = Query(None, description="Filter by crime category"),
Expand All @@ -130,7 +132,7 @@ async def get_intelligence_heatmap(
start_date=start_date,
end_date=end_date,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return HeatmapResponse(**cached)

Expand All @@ -142,7 +144,7 @@ async def get_intelligence_heatmap(
start_date=start_date,
end_date=end_date,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return HeatmapResponse(**result)


Expand All @@ -160,6 +162,7 @@ async def get_intelligence_heatmap(
"Not ML clustering.",
)
async def get_intelligence_clusters(
request: Request,
district: Optional[str] = Query(None, description="Filter by district name"),
station_id: Optional[str] = Query(None, description="Filter by station ID"),
crime_head: Optional[str] = Query(None, description="Filter by crime category"),
Expand All @@ -183,7 +186,7 @@ async def get_intelligence_clusters(
start_date=start_date,
end_date=end_date,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return ClusterResponse(**cached)

Expand All @@ -195,7 +198,7 @@ async def get_intelligence_clusters(
start_date=start_date,
end_date=end_date,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return ClusterResponse(**result)


Expand All @@ -212,6 +215,7 @@ async def get_intelligence_clusters(
"Shared implementation with field officer hotspots.",
)
async def get_intelligence_hotspots(
request: Request,
district: Optional[str] = Query(None, description="Filter by district name"),
station_id: Optional[str] = Query(None, description="Filter by station ID"),
crime_head: Optional[str] = Query(None, description="Filter by crime category"),
Expand All @@ -235,7 +239,7 @@ async def get_intelligence_hotspots(
start_date=start_date,
end_date=end_date,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return HotspotResponse(**cached)

Expand All @@ -247,7 +251,7 @@ async def get_intelligence_hotspots(
start_date=start_date,
end_date=end_date,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return HotspotResponse(**result)


Expand All @@ -265,6 +269,7 @@ async def get_intelligence_hotspots(
"status breakdown.",
)
async def get_intelligence_district_comparison(
request: Request,
district: Optional[str] = Query(None, description="Filter by district name"),
station_id: Optional[str] = Query(None, description="Filter by station ID"),
crime_head: Optional[str] = Query(None, description="Filter by crime category"),
Expand All @@ -288,7 +293,7 @@ async def get_intelligence_district_comparison(
start_date=start_date,
end_date=end_date,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return DistrictComparisonResponse(**cached)

Expand All @@ -300,7 +305,7 @@ async def get_intelligence_district_comparison(
start_date=start_date,
end_date=end_date,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return DistrictComparisonResponse(**result)


Expand All @@ -317,6 +322,7 @@ async def get_intelligence_district_comparison(
"Supports 'daily' and 'monthly' granularity (default: monthly).",
)
async def get_intelligence_timeline(
request: Request,
district: Optional[str] = Query(None, description="Filter by district name"),
station_id: Optional[str] = Query(None, description="Filter by station ID"),
crime_head: Optional[str] = Query(None, description="Filter by crime category"),
Expand Down Expand Up @@ -345,7 +351,7 @@ async def get_intelligence_timeline(
end_date=end_date,
granularity=granularity,
)
cached = cache.get(cache_key)
cached = cache.get(cache_key, req=request)
if cached is not None:
return TimelineResponse(**cached)

Expand All @@ -358,7 +364,7 @@ async def get_intelligence_timeline(
end_date=end_date,
granularity=granularity,
)
cache.put(cache_key, result)
cache.put(cache_key, result, req=request)
return TimelineResponse(**result)


Expand Down
Loading
Loading