Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions backend/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,6 @@ def invalidate(self):
evidence_audit_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=1)
closure_last_hash_cache = ThreadSafeCache(ttl=3600, max_size=1)
user_issues_cache = ThreadSafeCache(ttl=300, max_size=50) # 5 minutes TTL
grievance_list_cache = ThreadSafeCache(ttl=300, max_size=20)
escalation_stats_cache = ThreadSafeCache(ttl=300, max_size=10)
visit_stats_cache = ThreadSafeCache(ttl=300, max_size=10)
10 changes: 9 additions & 1 deletion backend/grievance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from backend.routing_service import RoutingService
from backend.sla_config_service import SLAConfigService
from backend.escalation_engine import EscalationEngine
from backend.cache import grievance_last_hash_cache
from backend.cache import (
grievance_last_hash_cache,
grievance_list_cache,
escalation_stats_cache
)

class GrievanceService:
"""
Expand Down Expand Up @@ -130,6 +134,10 @@ def create_grievance(self, grievance_data: Dict[str, Any], db: Session = None) -
db.commit()
db.refresh(grievance)

# Invalidate caches
grievance_list_cache.clear()
escalation_stats_cache.clear()

# Update cache after successful commit
grievance_last_hash_cache.set(data=integrity_hash, key="last_hash")
Comment on lines +137 to 142
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing cache invalidation on status updates and escalations.

Invalidation on create_grievance is good, but update_grievance_status (Line 232) and the escalation paths (escalate_grievance_severity, manual_escalate, run_escalation_check) also change Grievance.status, which is the primary dimension both grievance_list_cache (filterable by status) and escalation_stats_cache (counts by status) are keyed on.

Without matching invalidation, UI will show stale list contents and stale escalation counters for up to the 300s TTL after every status transition — which is exactly the kind of flicker that erodes trust in "live" escalation dashboards.

🛠️ Proposed fix
             db.commit()
+
+            # Invalidate caches that depend on grievance status
+            grievance_list_cache.clear()
+            escalation_stats_cache.clear()
             return True

Apply the same pair of clear() calls after successful commits inside escalation_engine.evaluate_and_escalate_grievances / manual_escalate / escalate_grievance_severity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/grievance_service.py` around lines 137 - 142, The cache invalidation
currently performed after create_grievance is missing for status changes,
causing stale UI; add the same invalidation logic (grievance_list_cache.clear()
and escalation_stats_cache.clear(), and update
grievance_last_hash_cache.set(...)) after successful commits in all code paths
that change Grievance.status — specifically inside update_grievance_status,
escalate_grievance_severity, manual_escalate, and the escalation path invoked by
run_escalation_check / escalation_engine.evaluate_and_escalate_grievances — so
every status transition clears the two caches and updates the last_hash.


Expand Down
8 changes: 4 additions & 4 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,10 @@ async def lifespan(app: FastAPI):
# Startup: Initialize Grievance Service (needed for escalation engine)
try:
logger.info("Initializing grievance service...")
# Temporarily disabled for local dev
# grievance_service = GrievanceService()
# app.state.grievance_service = grievance_service
logger.info("Grievance service initialization skipped for local dev.")
# Re-enabled to avoid redundant re-initialization on every manual escalation
grievance_service = GrievanceService()
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GrievanceService() initialization is done synchronously inside the async lifespan function. Because it performs file I/O (open(grievance_rules.json)) and builds several services, it can block the event loop and delay startup/health checks. Consider initializing it via run_in_threadpool (similar to other startup work) or moving it into the existing background_initialization task and setting app.state.grievance_service when ready.

Suggested change
grievance_service = GrievanceService()
grievance_service = await run_in_threadpool(GrievanceService)

Copilot uses AI. Check for mistakes.
app.state.grievance_service = grievance_service
logger.info("Grievance service initialized successfully.")
except Exception as e:
logger.error(f"Error initializing grievance service: {e}", exc_info=True)

Expand Down
44 changes: 32 additions & 12 deletions backend/routers/field_officer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
Issue #288: Field Officer Check-In System With Location Verification
"""

from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Response
from sqlalchemy.orm import Session
from sqlalchemy import func, case
import json
from typing import List, Optional
import logging
import os
Expand All @@ -31,7 +32,7 @@
calculate_visit_metrics,
get_geofencing_service
)
from backend.cache import visit_last_hash_cache
from backend.cache import visit_last_hash_cache, visit_stats_cache
from backend.schemas import BlockchainVerificationResponse

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -144,6 +145,9 @@ def officer_check_in(request: OfficerCheckInRequest, db: Session = Depends(get_d
db.add(new_visit)
db.commit()
db.refresh(new_visit)

# Invalidate stats cache
visit_stats_cache.clear()

# Update cache for next visit AFTER successful DB commit
visit_last_hash_cache.set(data=visit_hash, key="last_hash")
Expand Down Expand Up @@ -225,6 +229,9 @@ def officer_check_out(request: OfficerCheckOutRequest, db: Session = Depends(get

db.commit()
db.refresh(visit)

# Invalidate stats cache
visit_stats_cache.clear()

logger.info(f"Officer checked out from visit {request.visit_id}")

Expand Down Expand Up @@ -422,10 +429,14 @@ def get_issue_visit_history(
def get_visit_statistics(db: Session = Depends(get_db)):
"""
Get aggregate statistics for all field officer visits using optimized SQL queries

Returns metrics like total visits, verification status, geo-fence compliance, etc.
and serialization caching.
"""
try:
# Check cache first
cached_json = visit_stats_cache.get("global_visit_stats")
if cached_json:
return Response(content=cached_json, media_type="application/json")

# Optimized: Use a single aggregate query to fetch multiple statistics in one database roundtrip
stats = db.query(
func.count(FieldOfficerVisit.id).label('total'),
Expand All @@ -449,14 +460,20 @@ def get_visit_statistics(db: Session = Depends(get_db)):
else:
average_distance = 0.0

return VisitStatsResponse(
total_visits=total_visits,
verified_visits=verified_visits,
within_geofence_count=within_geofence_count,
outside_geofence_count=outside_geofence_count,
unique_officers=unique_officers,
average_distance_from_site=average_distance
)
data = {
"total_visits": total_visits,
"verified_visits": verified_visits,
"within_geofence_count": within_geofence_count,
"outside_geofence_count": outside_geofence_count,
"unique_officers": unique_officers,
"average_distance_from_site": average_distance
}

# Cache pre-serialized JSON to bypass Pydantic overhead
json_data = json.dumps(data)
visit_stats_cache.set(json_data, "global_visit_stats")

return Response(content=json_data, media_type="application/json")

except Exception as e:
logger.error(f"Error calculating visit statistics: {e}", exc_info=True)
Expand Down Expand Up @@ -492,6 +509,9 @@ def verify_visit(
visit.updated_at = datetime.now(timezone.utc)

db.commit()

# Invalidate stats cache
visit_stats_cache.clear()

logger.info(f"Visit {visit_id} verified by {verifier_email}")

Expand Down
137 changes: 88 additions & 49 deletions backend/routers/grievances.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from sqlalchemy.orm import Session, joinedload, selectinload
from sqlalchemy import func, case
from typing import List, Optional
Expand All @@ -23,6 +23,7 @@
)
from backend.grievance_service import GrievanceService
from backend.closure_service import ClosureService
from backend.cache import grievance_list_cache, escalation_stats_cache

logger = logging.getLogger(__name__)

Expand All @@ -38,9 +39,15 @@ def get_grievances(
):
"""
Get list of grievances with escalation history.
Optimized: Uses selectinload for audit_logs to avoid Cartesian product and improve O(N) fetching.
Optimized: Uses selectinload for audit_logs and serialization caching.
"""
try:
# Check cache first
cache_key = f"grievances_{status}_{category}_{limit}_{offset}"
cached_json = grievance_list_cache.get(cache_key)
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Caching was added for grievances/stats, but manual escalation does not invalidate those caches, so clients can receive stale data after escalation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/grievances.py, line 47:

<comment>Caching was added for grievances/stats, but manual escalation does not invalidate those caches, so clients can receive stale data after escalation.</comment>

<file context>
@@ -38,9 +39,15 @@ def get_grievances(
     try:
+        # Check cache first
+        cache_key = f"grievances_{status}_{category}_{limit}_{offset}"
+        cached_json = grievance_list_cache.get(cache_key)
+        if cached_json:
+            return Response(content=cached_json, media_type="application/json")
</file context>
Fix with Cubic

if cached_json:
return Response(content=cached_json, media_type="application/json")

Comment on lines +45 to +50
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_grievances is declared with response_model=List[GrievanceSummaryResponse], but it now returns a raw Response containing pre-serialized JSON. FastAPI will bypass response_model validation/serialization and the OpenAPI schema will no longer reflect the real response (and type errors/missing fields won’t be caught). Consider removing/adjusting response_model (or using response_class), or returning a Python object and letting FastAPI serialize it if you need to preserve schema/validation.

Copilot uses AI. Check for mistakes.
query = db.query(Grievance).options(
selectinload(Grievance.audit_logs),
joinedload(Grievance.jurisdiction)
Expand All @@ -57,37 +64,41 @@ def get_grievances(
result = []
for grievance in grievances:
escalation_history = [
EscalationAuditResponse(
id=audit.id,
grievance_id=audit.grievance_id,
previous_authority=audit.previous_authority,
new_authority=audit.new_authority,
timestamp=audit.timestamp,
reason=audit.reason.value
)
{
"id": audit.id,
"grievance_id": audit.grievance_id,
"previous_authority": audit.previous_authority,
"new_authority": audit.new_authority,
"timestamp": audit.timestamp.isoformat() if audit.timestamp else None,
"reason": audit.reason.value
}
for audit in grievance.audit_logs
]

result.append(GrievanceSummaryResponse(
id=grievance.id,
unique_id=grievance.unique_id,
category=grievance.category,
severity=grievance.severity.value,
pincode=grievance.pincode,
city=grievance.city,
district=grievance.district,
state=grievance.state,
current_jurisdiction_id=grievance.current_jurisdiction_id,
assigned_authority=grievance.assigned_authority,
sla_deadline=grievance.sla_deadline,
status=grievance.status.value,
created_at=grievance.created_at,
updated_at=grievance.updated_at,
resolved_at=grievance.resolved_at,
escalation_history=escalation_history
))

return result
result.append({
"id": grievance.id,
"unique_id": grievance.unique_id,
"category": grievance.category,
"severity": grievance.severity.value,
"pincode": grievance.pincode,
"city": grievance.city,
"district": grievance.district,
"state": grievance.state,
"current_jurisdiction_id": grievance.current_jurisdiction_id,
"assigned_authority": grievance.assigned_authority,
"sla_deadline": grievance.sla_deadline.isoformat() if grievance.sla_deadline else None,
"status": grievance.status.value,
"created_at": grievance.created_at.isoformat() if grievance.created_at else None,
"updated_at": grievance.updated_at.isoformat() if grievance.updated_at else None,
"resolved_at": grievance.resolved_at.isoformat() if grievance.resolved_at else None,
"escalation_history": escalation_history
})

# Cache pre-serialized JSON to bypass Pydantic overhead
json_data = json.dumps(result)
grievance_list_cache.set(json_data, cache_key)

Comment on lines +97 to +100
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that grievance_list_cache/escalation_stats_cache can serve stale JSON for up to the TTL, they need to be invalidated on all grievance mutations. Some mutation paths (e.g., automatic escalations in EscalationEngine._escalate_grievance and status updates in GrievanceService.update_grievance_status) currently don’t clear these caches, so list/stats endpoints can return outdated data. Add cache invalidation in those write paths (or centralize invalidation in a shared helper).

Copilot uses AI. Check for mistakes.
return Response(content=json_data, media_type="application/json")

except Exception as e:
logger.error(f"Error getting grievances: {e}", exc_info=True)
Expand Down Expand Up @@ -149,32 +160,43 @@ def get_grievance(grievance_id: int, db: Session = Depends(get_db)):
def get_escalation_stats(db: Session = Depends(get_db)):
"""
Get escalation statistics.
Optimized: Uses a single GROUP BY query instead of 4 separate count queries.
Optimized: Uses a single multi-metric aggregate query and serialization caching.
"""
try:
# Perform aggregation in a single query for performance
status_counts = db.query(
Grievance.status,
func.count(Grievance.id)
).group_by(Grievance.status).all()
# Check cache first
cached_json = escalation_stats_cache.get("global_stats")
if cached_json:
return Response(content=cached_json, media_type="application/json")

Comment on lines +166 to 170
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_escalation_stats is declared with response_model=EscalationStatsResponse, but returns a raw Response with cached JSON. This bypasses response_model validation and can make the OpenAPI schema inaccurate. Consider removing/adjusting response_model (or using response_class) if you intend to return pre-serialized JSON.

Copilot uses AI. Check for mistakes.
# Process results into a dictionary for easy lookup
counts_dict = {status.value if hasattr(status, 'value') else status: count for status, count in status_counts}
# Perform aggregation in a single query for maximum performance
# Using func.sum(case(...)) is faster than group_by for a fixed set of statuses
stats = db.query(
func.count(Grievance.id).label('total'),
func.sum(case((Grievance.status == 'escalated', 1), else_=0)).label('escalated'),
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new status aggregates compare enum values using lowercase strings, which can produce incorrect escalation stats for Enum-backed Grievance.status.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/grievances.py, line 175:

<comment>The new status aggregates compare enum values using lowercase strings, which can produce incorrect escalation stats for Enum-backed `Grievance.status`.</comment>

<file context>
@@ -149,32 +160,43 @@ def get_grievance(grievance_id: int, db: Session = Depends(get_db)):
+        # Using func.sum(case(...)) is faster than group_by for a fixed set of statuses
+        stats = db.query(
+            func.count(Grievance.id).label('total'),
+            func.sum(case((Grievance.status == 'escalated', 1), else_=0)).label('escalated'),
+            func.sum(case((Grievance.status == 'open', 1), (Grievance.status == 'in_progress', 1), else_=0)).label('active'),
+            func.sum(case((Grievance.status == 'resolved', 1), else_=0)).label('resolved')
</file context>
Fix with Cubic

func.sum(case((Grievance.status == 'open', 1), (Grievance.status == 'in_progress', 1), else_=0)).label('active'),
func.sum(case((Grievance.status == 'resolved', 1), else_=0)).label('resolved')
).first()
Comment on lines +171 to +178
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The escalation-stats aggregate compares Grievance.status (a SQLAlchemy Enum of GrievanceStatus) to plain strings like 'open'/'resolved'. With SQLAlchemy Enum columns this is likely to miscount (often the DB stores enum names like OPEN/RESOLVED). Use GrievanceStatus enum values (or Grievance.status.in_(...)) in the case expressions to ensure correct counts across DB backends.

Copilot uses AI. Check for mistakes.

total_grievances = sum(counts_dict.values())
escalated_grievances = counts_dict.get("escalated", 0)
active_grievances = counts_dict.get("open", 0) + counts_dict.get("in_progress", 0)
resolved_grievances = counts_dict.get("resolved", 0)
total_grievances = stats.total or 0
escalated_grievances = int(stats.escalated or 0)
active_grievances = int(stats.active or 0)
resolved_grievances = int(stats.resolved or 0)

escalation_rate = (escalated_grievances / total_grievances * 100) if total_grievances > 0 else 0

return EscalationStatsResponse(
total_grievances=total_grievances,
escalated_grievances=escalated_grievances,
active_grievances=active_grievances,
resolved_grievances=resolved_grievances,
escalation_rate=escalation_rate
)
data = {
"total_grievances": total_grievances,
"escalated_grievances": escalated_grievances,
"active_grievances": active_grievances,
"resolved_grievances": resolved_grievances,
"escalation_rate": escalation_rate
}

# Cache pre-serialized JSON to bypass Pydantic overhead
json_data = json.dumps(data)
escalation_stats_cache.set(json_data, "global_stats")

return Response(content=json_data, media_type="application/json")

except Exception as e:
logger.error(f"Error getting escalation stats: {e}", exc_info=True)
Expand Down Expand Up @@ -276,6 +298,9 @@ def follow_grievance(
)
db.add(follower)
db.commit()

# Invalidate caches
grievance_list_cache.clear()

# Count total followers
total_followers = db.query(func.count(GrievanceFollower.id)).filter(
Expand Down Expand Up @@ -314,6 +339,9 @@ def unfollow_grievance(

db.delete(follower)
db.commit()

# Invalidate caches
grievance_list_cache.clear()

return {"message": "Successfully unfollowed grievance"}

Expand All @@ -335,6 +363,9 @@ def request_grievance_closure(
result = ClosureService.request_closure(grievance_id, db)

if result.get("skip_confirmation"):
# Invalidate caches as status might have changed to resolved
grievance_list_cache.clear()
escalation_stats_cache.clear()
return RequestClosureResponse(
grievance_id=grievance_id,
message=result["message"],
Expand All @@ -343,6 +374,10 @@ def request_grievance_closure(
required_confirmations=0
)

# Invalidate caches
grievance_list_cache.clear()
escalation_stats_cache.clear()

return RequestClosureResponse(
grievance_id=grievance_id,
message=result["message"],
Expand All @@ -366,6 +401,10 @@ def confirm_grievance_closure(
):
"""Confirm or dispute a grievance closure (followers only)"""
try:
# Invalidate caches as status or approval might change
grievance_list_cache.clear()
escalation_stats_cache.clear()

result = ClosureService.submit_confirmation(
grievance_id=grievance_id,
user_email=confirmation.user_email,
Expand Down
Loading
Loading