diff --git a/.jules/bolt.md b/.jules/bolt.md index bf784525..268cde17 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -25,3 +25,7 @@ ## 2025-07-15 - Fast Bounding Box Pre-filter **Learning:** Calculating great circle distance (Haversine) for every issue against a target location is computationally expensive (O(N) with heavy math ops like sin, cos, atan2). In high-traffic aggregations, this can become a bottleneck. **Action:** Use a fast bounding box pre-filter (`get_bounding_box` with a 5% epsilon) to quickly discard issues that are definitely outside the search radius before running the expensive exact haversine distance calculation. + +## 2026-07-20 - Unbounded In-Memory Caches & Process Safety +**Learning:** Class-level in-memory dictionaries used to achieve O(1) blockchain lookups can leak memory if they grow unboundedly in high-traffic applications. Additionally, single-column select queries in SQLAlchemy provide a highly performant and process-safe database-level alternative when multiple worker processes are involved. +**Action:** Always bound in-memory cache growth (e.g., limit to 1000 items with eviction) and combine with optimized database column-selection queries to guarantee process-safety and prevent memory leaks. diff --git a/backend/escalation_engine.py b/backend/escalation_engine.py index 67137b9b..781de0fb 100644 --- a/backend/escalation_engine.py +++ b/backend/escalation_engine.py @@ -4,6 +4,8 @@ """ import datetime +import hashlib +import threading from typing import List, Dict, Any, Optional from sqlalchemy.orm import Session from sqlalchemy import and_, or_ @@ -17,6 +19,11 @@ class EscalationEngine: Engine for handling grievance escalations based on SLA breaches and severity changes. """ + # Cache for O(1) blockchain integrity hash lookups + # Stores {grievance_id: last_integrity_hash} + _audit_last_hash_cache = {} + _cache_lock = threading.Lock() + def __init__(self, routing_service: RoutingService, sla_service: SLAConfigService, rules_config: Dict[str, Any]): """ @@ -248,18 +255,34 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, # Recalculate SLA self._recalculate_sla(grievance, db) - # Create audit log + # Retrieve previous hash (O(1) from cache or O(log N) from indexed DB) + prev_hash = self._get_last_audit_hash(grievance.id, db) + + # Calculate new integrity hash: SHA256(grievance_id | reason.value | prev_hash) + reason_val = reason.value if hasattr(reason, "value") else str(reason) + hash_input = f"{grievance.id}|{reason_val}|{prev_hash or 'GENESIS'}" + new_hash = hashlib.sha256(hash_input.encode()).hexdigest() + + # Create audit log with blockchain integration audit_log = EscalationAudit( grievance_id=grievance.id, previous_authority=previous_authority, new_authority=grievance.assigned_authority, reason=reason, - notes=notes + notes=notes, + integrity_hash=new_hash, + previous_integrity_hash=prev_hash ) db.add(audit_log) db.commit() + # Update O(1) cache for next escalation audit (with max size limit to prevent memory leak) + with self._cache_lock: + if len(self._audit_last_hash_cache) >= 1000: + self._audit_last_hash_cache.clear() + self._audit_last_hash_cache[grievance.id] = new_hash + return True except Exception as e: @@ -267,6 +290,33 @@ def _escalate_grievance(self, grievance: Grievance, reason: EscalationReason, print(f"Error during escalation: {e}") return False + def _get_last_audit_hash(self, grievance_id: int, db: Session) -> Optional[str]: + """ + Retrieves the last integrity hash for an escalation audit of a grievance. + Bolt Optimization: Uses thread-safe memory cache for O(1) lookup. + """ + with self._cache_lock: + if grievance_id in self._audit_last_hash_cache: + return self._audit_last_hash_cache[grievance_id] + + # Cache miss: Fallback to indexed DB query using optimized single-column selection + from sqlalchemy import desc + last_audit_hash = db.query(EscalationAudit.integrity_hash)\ + .filter(EscalationAudit.grievance_id == grievance_id)\ + .order_by(desc(EscalationAudit.id))\ + .first() + + last_hash = last_audit_hash[0] if last_audit_hash else None + + # Update cache for next time (with max size limit to prevent memory leak) + if last_hash: + with self._cache_lock: + if len(self._audit_last_hash_cache) >= 1000: + self._audit_last_hash_cache.clear() + self._audit_last_hash_cache[grievance_id] = last_hash + + return last_hash + def _recalculate_sla(self, grievance: Grievance, db: Session) -> None: """ Recalculate SLA deadline for a grievance. diff --git a/backend/grievance_service.py b/backend/grievance_service.py index d0f40502..1cdda535 100644 --- a/backend/grievance_service.py +++ b/backend/grievance_service.py @@ -12,7 +12,7 @@ from sqlalchemy import and_, desc from datetime import datetime, timezone, timedelta -from backend.models import Grievance, Jurisdiction, GrievanceStatus, SeverityLevel, GrievanceFollower +from backend.models import Grievance, Jurisdiction, GrievanceStatus, SeverityLevel, GrievanceFollower, EscalationAudit from backend.database import SessionLocal from backend.routing_service import RoutingService from backend.sla_config_service import SLAConfigService @@ -169,8 +169,10 @@ def follow_grievance(self, grievance_id: int, user_email: str, db: Session = Non db.commit() db.refresh(follower) - # Update O(1) cache for next follower + # Update O(1) cache for next follower (with max size limit to prevent memory leak) with self._cache_lock: + if len(self._follower_last_hash_cache) >= 1000: + self._follower_last_hash_cache.clear() self._follower_last_hash_cache[grievance_id] = new_hash return follower @@ -192,17 +194,19 @@ def _get_last_integrity_hash(self, grievance_id: int, db: Session) -> Optional[s if grievance_id in self._follower_last_hash_cache: return self._follower_last_hash_cache[grievance_id] - # Cache miss: Fallback to indexed DB query - last_follower = db.query(GrievanceFollower)\ + # Cache miss: Fallback to indexed DB query using optimized single-column selection + last_follower_hash = db.query(GrievanceFollower.integrity_hash)\ .filter(GrievanceFollower.grievance_id == grievance_id)\ .order_by(desc(GrievanceFollower.id))\ .first() - last_hash = last_follower.integrity_hash if last_follower else None + last_hash = last_follower_hash[0] if last_follower_hash else None - # Update cache for next time + # Update cache for next time (with max size limit to prevent memory leak) if last_hash: with self._cache_lock: + if len(self._follower_last_hash_cache) >= 1000: + self._follower_last_hash_cache.clear() self._follower_last_hash_cache[grievance_id] = last_hash return last_hash @@ -238,6 +242,38 @@ def verify_follower_integrity(self, follower_id: int, db: Session = None) -> Dic if is_local_session: db.close() + def verify_audit_integrity(self, audit_id: int, db: Session = None) -> Dict[str, Any]: + """ + Verify the blockchain-style integrity of an escalation audit record. + """ + is_local_session = False + if db is None: + db = SessionLocal() + is_local_session = True + + try: + audit = db.query(EscalationAudit).filter(EscalationAudit.id == audit_id).first() + if not audit: + return {"is_valid": False, "message": "Audit record not found"} + + # Re-calculate hash: SHA256(grievance_id | reason.value | prev_hash) + reason_val = audit.reason.value if hasattr(audit.reason, "value") else str(audit.reason) + hash_input = f"{audit.grievance_id}|{reason_val}|{audit.previous_integrity_hash or 'GENESIS'}" + calculated_hash = hashlib.sha256(hash_input.encode()).hexdigest() + + is_valid = (calculated_hash == audit.integrity_hash) + + return { + "is_valid": is_valid, + "current_hash": audit.integrity_hash, + "calculated_hash": calculated_hash, + "previous_hash": audit.previous_integrity_hash, + "message": "Integrity verified" if is_valid else "INTEGRITY BREACH DETECTED" + } + finally: + if is_local_session: + db.close() + def get_grievance(self, grievance_id: int, db: Session = None) -> Optional[Grievance]: """ Get a grievance by ID. @@ -305,7 +341,7 @@ def update_grievance_status(self, grievance_id: int, status: GrievanceStatus, db.close() def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityLevel, - reason: str = "") -> bool: + reason: str = "", db: Session = None) -> bool: """ Escalate grievance severity. @@ -313,24 +349,26 @@ def escalate_grievance_severity(self, grievance_id: int, new_severity: SeverityL grievance_id: Grievance ID new_severity: New severity level reason: Reason for escalation + db: Database session Returns: True if escalation successful """ - return self.escalation_engine.escalate_grievance_severity(grievance_id, new_severity, reason) + return self.escalation_engine.escalate_grievance_severity(grievance_id, new_severity, reason, db) - def manual_escalate(self, grievance_id: int, reason: str = "") -> bool: + def manual_escalate(self, grievance_id: int, reason: str = "", db: Session = None) -> bool: """ Manually escalate a grievance. Args: grievance_id: Grievance ID reason: Reason for escalation + db: Database session Returns: True if escalation successful """ - return self.escalation_engine.manual_escalate(grievance_id, reason) + return self.escalation_engine.manual_escalate(grievance_id, reason, db) def run_escalation_check(self) -> Dict[str, int]: """ diff --git a/backend/main.py b/backend/main.py index 6697f0ee..90f5b0d2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,137 +1,272 @@ -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query +from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Request, Depends, BackgroundTasks +from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.concurrency import run_in_threadpool from sqlalchemy.orm import Session -from database import engine, get_db -from models import Base, Issue -from ai_service import generate_action_plan, chat_with_civic_assistant -from maharashtra_locator import find_constituency_by_pincode, find_mla_by_constituency from pydantic import BaseModel -from gemini_summary import generate_mla_summary +from contextlib import asynccontextmanager +from functools import lru_cache +from typing import List +from datetime import datetime, timedelta, timezone +from PIL import Image + import json import os -import io - -# Add the project root to sys.path so we can import 'backend' modules -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from fastapi import FastAPI, HTTPException, UploadFile, File, Form, BackgroundTasks, Depends, Query -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from fastapi.concurrency import run_in_threadpool -from pydantic import BaseModel -from sqlalchemy.orm import Session -from database import SessionLocal, engine, Base -from models import Issue -from contextlib import asynccontextmanager import shutil -import datetime -from sqlalchemy import text -from typing import Optional, List -import PIL.Image import uuid +import asyncio +import logging +import time +import magic +import httpx + +from backend.cache import recent_issues_cache +from backend.database import engine, Base, SessionLocal, get_db +from backend.models import Issue +from backend.schemas import ( + IssueResponse, IssueCreateRequest, IssueCreateResponse, ChatRequest, ChatResponse, + VoteRequest, VoteResponse, DetectionResponse, VisionAnalysisResponse, + UrgencyAnalysisRequest, UrgencyAnalysisResponse, HealthResponse, MLStatusResponse, + ResponsibilityMapResponse, ErrorResponse, SuccessResponse, IssueCategory, IssueStatus, + FollowerCreateRequest, FollowerResponse, BlockchainVerificationResponse +) +from backend.exceptions import EXCEPTION_HANDLERS +from backend.bot import application +from backend.ai_factory import create_all_ai_services +from backend.ai_service import ( + generate_action_plan, chat_with_civic_assistant, + analyze_issue_image, analyze_issue_with_ai, + VISION_MODEL, API_MODE +) +from backend.maharashtra_locator import ( + load_maharashtra_pincode_data, + load_maharashtra_mla_data, + find_constituency_by_pincode, + find_mla_by_constituency +) +from backend.init_db import migrate_db +from backend.grievance_service import GrievanceService +from backend.pothole_detection import detect_potholes, validate_image_for_processing +from backend.garbage_detection import detect_garbage +from backend.local_ml_service import ( + detect_infrastructure_local, + detect_flooding_local, + detect_vandalism_local, + get_detection_status +) +from backend.gemini_services import get_ai_services, initialize_ai_services +from backend.hf_api_service import ( + detect_illegal_parking_clip, + detect_street_light_clip, + detect_fire_clip, + detect_stray_animal_clip, + detect_blocked_road_clip, + detect_tree_hazard_clip, + detect_pest_clip, + detect_severity_clip, + detect_smart_scan_clip, + generate_image_caption, + analyze_urgency_text +) -# Import specialized detection modules -from pothole_detection import detect_potholes -from garbage_detection import detect_garbage -from vandalism_detection import detect_vandalism -from flood_detection import detect_flooding +# Configure structured logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# File upload validation constants +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB +ALLOWED_MIME_TYPES = { + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/tiff' +} + +def _validate_uploaded_file_sync(file: UploadFile) -> None: + """ + Synchronous validation logic to be run in a threadpool. + """ + # Check file size + file.file.seek(0, 2) # Seek to end + file_size = file.file.tell() + file.file.seek(0) # Reset to beginning + + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size allowed is {MAX_FILE_SIZE // (1024*1024)}MB" + ) + + # Check MIME type from content using python-magic + try: + # Read first 1024 bytes for MIME detection + file_content = file.file.read(1024) + file.file.seek(0) # Reset file pointer + + detected_mime = magic.from_buffer(file_content, mime=True) + + if detected_mime not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Only image files are allowed. Detected: {detected_mime}" + ) + except Exception as e: + logger.error(f"Error validating file {file.filename}: {e}") + raise HTTPException( + status_code=400, + detail="Unable to validate file content. Please ensure it's a valid image file." + ) + +async def validate_uploaded_file(file: UploadFile) -> None: + """ + Validate uploaded file for security and safety (async wrapper). + + Args: + file: The uploaded file to validate -# Import AI and Logic services -from ai_service import analyze_issue_image, chat_with_civic_assistant, analyze_issue_with_ai, generate_action_plan -from maharashtra_locator import get_district_by_pincode_range, find_constituency_by_pincode, find_mla_by_constituency, load_maharashtra_pincode_data, load_maharashtra_mla_data -from responsibility_mapper import get_responsible_authority -from bot import application # Import the Telegram Application -from gemini_summary import generate_mla_summary + Raises: + HTTPException: If validation fails + """ + await run_in_threadpool(_validate_uploaded_file_sync, file) -# Create the database tables +# Create tables if they don't exist Base.metadata.create_all(bind=engine) +async def process_action_plan_background(issue_id: int, description: str, category: str, image_path: str): + db = SessionLocal() + try: + # Generate Action Plan (AI) + action_plan = await generate_action_plan(description, category, image_path) + + # Update issue in DB + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if issue: + issue.action_plan = action_plan + db.commit() + + # Invalidate cache to ensure users get the updated action plan + recent_issues_cache.invalidate() + except Exception as e: + logger.error(f"Background action plan generation failed for issue {issue_id}: {e}", exc_info=True) + finally: + db.close() + @asynccontextmanager async def lifespan(app: FastAPI): - # --- Startup --- - print("Starting up backend...") + # Startup: Migrate DB + migrate_db() - # Initialize the Telegram bot + # Startup: Initialize Shared HTTP Client for external APIs (Connection Pooling) + app.state.http_client = httpx.AsyncClient() + logger.info("Shared HTTP Client initialized.") + + # Startup: Initialize AI services try: - await application.initialize() - await application.updater.start_polling() - await application.start() - print("Telegram bot started.") + action_plan_service, chat_service, mla_summary_service = create_all_ai_services() + + initialize_ai_services( + action_plan_service=action_plan_service, + chat_service=chat_service, + mla_summary_service=mla_summary_service + ) + logger.info("AI services initialized successfully.") except Exception as e: - print(f"Error starting Telegram bot: {e}") + logger.error(f"Error initializing AI services: {e}", exc_info=True) + raise - # Preload data + # Startup: Load static data to avoid first-request latency try: + # These functions use lru_cache, so calling them once loads the data into memory load_maharashtra_pincode_data() load_maharashtra_mla_data() logger.info("Maharashtra data pre-loaded successfully.") except Exception as e: logger.error(f"Error pre-loading Maharashtra data: {e}") - # Run database migrations + # Startup: Start Telegram Bot try: - with engine.connect() as conn: - try: - conn.execute(text("CREATE INDEX ix_issues_created_at ON issues (created_at)")) - except Exception: pass - try: - conn.execute(text("CREATE INDEX ix_issues_status ON issues (status)")) - except Exception: pass - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN upvotes INTEGER DEFAULT 0")) - except Exception: pass - try: - conn.execute(text("ALTER TABLE issues ADD COLUMN user_email VARCHAR")) - except Exception: pass - conn.commit() + if application: + await application.initialize() + await application.updater.start_polling() + await application.start() + logger.info("Telegram bot started.") except Exception as e: - print(f"Migration warning: {e}") + logger.error(f"Error starting Telegram bot: {e}") yield - # --- Shutdown --- - print("Shutting down backend...") + + # Shutdown: Close Shared HTTP Client + await app.state.http_client.aclose() + logger.info("Shared HTTP Client closed.") + + # Shutdown: Stop Telegram Bot try: - await application.updater.stop() - await application.stop() - await application.shutdown() - print("Telegram bot stopped.") + if application: + await application.updater.stop() + await application.stop() + await application.shutdown() + logger.info("Telegram bot stopped.") except Exception as e: - print(f"Error stopping Telegram bot: {e}") + logger.error(f"Error stopping Telegram bot: {e}") + +app = FastAPI( + title="VishwaGuru Backend", + description="AI-powered civic issue reporting and resolution platform", + version="1.0.0", + lifespan=lifespan +) + +# Add centralized exception handlers +for exception_type, handler in EXCEPTION_HANDLERS.items(): + app.add_exception_handler(exception_type, handler) + +# CORS Configuration - Security Enhanced +frontend_url = os.environ.get("FRONTEND_URL") +if not frontend_url: + raise ValueError( + "FRONTEND_URL environment variable is required for security. " + "Set it to your frontend URL (e.g., https://your-app.netlify.app). " + "For development, use http://localhost:5173 or similar." + ) + +# Validate URL format (basic check) +if not (frontend_url.startswith("http://") or frontend_url.startswith("https://")): + raise ValueError( + f"FRONTEND_URL must be a valid HTTP/HTTPS URL. Got: {frontend_url}" + ) -app = FastAPI(lifespan=lifespan) +# Build allowed origins list +allowed_origins = [frontend_url] + +# Allow localhost origins for development +if os.environ.get("ENVIRONMENT", "").lower() != "production": + # Add common development origins + dev_origins = [ + "http://localhost:3000", # React default + "http://localhost:5173", # Vite default + "http://127.0.0.1:3000", + "http://127.0.0.1:5173", + "http://localhost:8080", # Alternative dev port + ] + allowed_origins.extend(dev_origins) -# Enable CORS +# Allow CORS for frontend app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=allowed_origins, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], ) -# Dependency to get the database session -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() - -class PincodeRequest(BaseModel): - pincode: str - -class ChatRequest(BaseModel): - message: str - history: List[dict] = [] - -@app.get("/") -def read_root(): - return { - "status": "ok", - "service": "VishwaGuru API", - "version": "1.0.0" - } +# Enable Gzip compression +app.add_middleware(GZipMiddleware, minimum_size=500) @app.get("/", response_model=SuccessResponse) def root(): @@ -155,33 +290,6 @@ def health(): } ) -@app.get("/api/stats", response_model=StatsResponse) -def get_stats(db: Session = Depends(get_db)): - cached_stats = recent_issues_cache.get("stats") - if cached_stats: - return JSONResponse(content=cached_stats) - - total = db.query(func.count(Issue.id)).scalar() - resolved = db.query(func.count(Issue.id)).filter(Issue.status.in_(['resolved', 'verified'])).scalar() - # Pending is everything else - pending = total - resolved - - # By category - cat_counts = db.query(Issue.category, func.count(Issue.id)).group_by(Issue.category).all() - issues_by_category = {cat: count for cat, count in cat_counts} - - response = StatsResponse( - total_issues=total, - resolved_issues=resolved, - pending_issues=pending, - issues_by_category=issues_by_category - ) - - data = response.model_dump(mode='json') - recent_issues_cache.set(data, "stats") - - return response - @app.get("/api/ml-status", response_model=MLStatusResponse) async def ml_status(): """ @@ -196,185 +304,633 @@ async def ml_status(): ) def save_file_blocking(file_obj, path): - """ - Save uploaded file with security measures: - - Strip EXIF metadata from images to protect privacy - - For non-images, save as-is - """ - try: - # Try to open as image with PIL - img = Image.open(file_obj) - # Strip EXIF data by creating a new image without metadata - img_no_exif = Image.new(img.mode, img.size) - img_no_exif.putdata(list(img.getdata())) - # Save without EXIF - img_no_exif.save(path, format=img.format) - logger.info(f"Saved image {path} with EXIF metadata stripped") - except Exception: - # If not an image or PIL fails, save as binary - file_obj.seek(0) # Reset in case PIL read some - with open(path, "wb") as buffer: - shutil.copyfileobj(file_obj, buffer) - logger.info(f"Saved file {path} as binary (not an image or PIL failed)") - -@app.post("/api/issues") + with open(path, "wb") as buffer: + shutil.copyfileobj(file_obj, buffer) + +def save_issue_db(db: Session, issue: Issue): + db.add(issue) + db.commit() + db.refresh(issue) + return issue + +@app.post("/api/issues", response_model=IssueCreateResponse, status_code=201) async def create_issue( - description: str = Form(...), - category: str = Form(...), - source: str = Form("web"), - user_email: Optional[str] = Form(None), - image: UploadFile = File(...), + background_tasks: BackgroundTasks, + description: str = Form(..., min_length=10, max_length=1000), + category: str = Form(..., pattern=f"^({'|'.join([cat.value for cat in IssueCategory])})$"), + user_email: str = Form(None), + latitude: float = Form(None, ge=-90, le=90), + longitude: float = Form(None, ge=-180, le=180), + location: str = Form(None, max_length=200), + image: UploadFile = File(None), db: Session = Depends(get_db) ): - try: - # Save the uploaded image - os.makedirs("data/uploads", exist_ok=True) - filename = f"{uuid.uuid4()}_{image.filename}" - file_location = f"data/uploads/{filename}" + image_path = None - # Offload blocking file I/O to a thread - def save_file(): - with open(image_path, "wb") as buffer: - shutil.copyfileobj(image.file, buffer) + try: + # Validate uploaded image if provided + if image: + await validate_uploaded_file(image) - await asyncio.to_thread(save_file) + # Save image if provided + if image: + upload_dir = "data/uploads" + os.makedirs(upload_dir, exist_ok=True) + filename = f"{uuid.uuid4()}_{image.filename}" + image_path = os.path.join(upload_dir, filename) + await run_in_threadpool(save_file_blocking, image.file, image_path) + except HTTPException: + # Re-raise HTTP exceptions (from validation) + raise + except OSError as e: + logger.error(f"File I/O error while saving image: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to save uploaded file") + except Exception as e: + logger.error(f"Unexpected error during file processing: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") - # Offload blocking DB operations to a thread - def save_to_db(): + try: + # Save to DB new_issue = Issue( description=description, category=category, image_path=image_path, - source="web" + source="web", + user_email=user_email, + latitude=latitude, + longitude=longitude, + location=location, + action_plan=None ) - db.add(new_issue) - db.commit() - db.refresh(new_issue) - return new_issue - new_issue = await asyncio.to_thread(save_to_db) + # Offload blocking DB operations to threadpool + await run_in_threadpool(save_issue_db, db, new_issue) + except Exception as e: + # Clean up uploaded file if DB save failed + if image_path and os.path.exists(image_path): + try: + os.remove(image_path) + except OSError: + pass # Ignore cleanup errors - # Generate Action Plan (AI) - action_plan = await generate_action_plan(description, category, file_location) + logger.error(f"Database error while creating issue: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to save issue to database") - db_issue = Issue( - description=description, - category=category, - image_path=file_location, - source=source, - user_email=user_email - ) - db.add(db_issue) - db.commit() - db.refresh(db_issue) - - return { - "id": new_issue.id, - "message": "Issue reported successfully", - "action_plan": action_plan - } + # Add background task for AI generation + background_tasks.add_task(process_action_plan_background, new_issue.id, description, category, image_path) + + # Optimistic Cache Update + try: + current_cache = recent_issues_cache.get() + if current_cache: + # Create a dict representation of the new issue (similar to IssueResponse) + new_issue_dict = IssueResponse( + id=new_issue.id, + category=new_issue.category, + description=new_issue.description[:100] + "..." if len(new_issue.description) > 100 else new_issue.description, + created_at=new_issue.created_at, + image_path=new_issue.image_path, + status=new_issue.status, + upvotes=new_issue.upvotes if new_issue.upvotes is not None else 0, + location=new_issue.location, + latitude=new_issue.latitude, + longitude=new_issue.longitude, + action_plan=new_issue.action_plan + ).model_dump(mode='json') + + # Prepend new issue to the list + current_cache.insert(0, new_issue_dict) + + # Keep only last 10 (or matching the limit in get_recent_issues) + if len(current_cache) > 10: + current_cache.pop() + + recent_issues_cache.set(current_cache) + except Exception as e: + logger.error(f"Error updating cache optimistically: {e}") + # Failure to update cache is not critical, don't fail the request + + return IssueCreateResponse( + id=new_issue.id, + message="Issue reported successfully. Action plan will be generated shortly.", + action_plan=None + ) + +@app.post("/api/issues/{issue_id}/vote", response_model=VoteResponse) +def upvote_issue(issue_id: int, db: Session = Depends(get_db)): + issue = db.query(Issue).filter(Issue.id == issue_id).first() + if not issue: + raise HTTPException(status_code=404, detail="Issue not found") + + # Increment upvotes + if issue.upvotes is None: + issue.upvotes = 0 + issue.upvotes += 1 + + db.commit() + db.refresh(issue) + + return VoteResponse( + id=issue.id, + upvotes=issue.upvotes, + message="Issue upvoted successfully" + ) @lru_cache(maxsize=1) def _load_responsibility_map(): - # Assuming the data folder is at the root level relative to where backend is run - # Adjust path as necessary. If running from root, it is "data/responsibility_map.json" file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "responsibility_map.json") - with open(file_path, "r") as f: return json.load(f) -@app.get("/api/responsibility-map") +@app.get("/api/responsibility-map", response_model=ResponsibilityMapResponse) def get_responsibility_map(): - # In a real app, this might read from the file or database - # For MVP, we can return the structure directly or read the file + """Get responsibility mapping data for civic authorities""" try: - return _load_responsibility_map() + data = _load_responsibility_map() + return ResponsibilityMapResponse(data=data) except FileNotFoundError: - return {"error": "Data file not found"} + logger.error("Responsibility map file not found", exc_info=True) + raise HTTPException(status_code=404, detail="Responsibility map data not found") + except Exception as e: + logger.error(f"Error loading responsibility map: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Failed to load responsibility map") -class ChatRequest(BaseModel): - query: str +@app.post("/api/analyze-urgency", response_model=UrgencyAnalysisResponse) +async def analyze_urgency_endpoint(request: Request, urgency_req: UrgencyAnalysisRequest): + try: + client = request.app.state.http_client + result = await analyze_urgency_text(urgency_req.description, client=client) + return UrgencyAnalysisResponse( + urgency_level=result.get("urgency_level", "medium"), + reasoning=result.get("reasoning", "Analysis completed"), + recommended_actions=result.get("recommended_actions", []) + ) + except Exception as e: + logger.error(f"Urgency analysis error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Urgency analysis service temporarily unavailable") -@app.post("/api/chat") +@app.post("/api/chat", response_model=ChatResponse) async def chat_endpoint(request: ChatRequest): - response = await chat_with_civic_assistant(request.query) - return {"response": response} + try: + response = await chat_with_civic_assistant(request.query) + return ChatResponse(response=response) + except Exception as e: + logger.error(f"Chat service error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Chat service temporarily unavailable") + + +# ── NVIDIA NIM Vision Analysis ───────────────────────────────────────────────── + +@app.post("/api/vision/analyze", response_model=VisionAnalysisResponse) +async def vision_analyze_endpoint( + image: UploadFile = File(...), + description: str = Form(""), +): + """ + Analyze an uploaded image using NVIDIA NIM vision model + (meta/llama-3.2-90b-vision-instruct). + + Detects civic issues, categorizes them, and assesses severity. + Optionally accepts a text description for enhanced analysis. + """ + if API_MODE == "none": + raise HTTPException( + status_code=503, + detail="Vision analysis unavailable — no AI API key configured" + ) + + # Validate the uploaded file + await validate_uploaded_file(image) -@app.get("/api/issues/recent") + # Save temporarily + upload_dir = "data/uploads" + os.makedirs(upload_dir, exist_ok=True) + filename = f"{uuid.uuid4()}_{image.filename}" + image_path = os.path.join(upload_dir, filename) + + try: + await run_in_threadpool(save_file_blocking, image.file, image_path) + + if description.strip(): + # Combined text + image analysis + result = await analyze_issue_with_ai(description, image_path) + return VisionAnalysisResponse( + description=result.get("category", "Unknown") + " issue detected", + category=result.get("category", "Unknown"), + severity=result.get("severity", "Medium"), + authority=result.get("authority"), + action_plan=result.get("action_plan"), + model_used=VISION_MODEL or "fallback", + ) + else: + # Image-only analysis + result = await analyze_issue_image(image_path) + return VisionAnalysisResponse( + description=result.get("description", "Could not analyze image"), + category=result.get("category", "Unknown"), + severity=result.get("severity", "Unknown"), + authority=None, + action_plan=None, + model_used=VISION_MODEL or "fallback", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Vision analysis error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Vision analysis failed") + finally: + # Clean up temp file + try: + if os.path.exists(image_path): + os.remove(image_path) + except OSError: + pass + + +# Initialize GrievanceService for the endpoints +grievance_service = GrievanceService() + +@app.post("/api/grievances/{grievance_id}/follow", response_model=FollowerResponse) +async def follow_grievance_endpoint( + grievance_id: int, + request: FollowerCreateRequest, + db: Session = Depends(get_db) +): + """ + Follow a grievance with blockchain-style integrity hash. + Bolt Optimization: Uses O(1) in-memory cache for hash chaining. + """ + follower = await run_in_threadpool( + grievance_service.follow_grievance, + grievance_id, + request.user_email, + db + ) + if not follower: + raise HTTPException(status_code=400, detail="Failed to follow grievance") + return follower + +@app.get("/api/follower/{follower_id}/blockchain-verify", response_model=BlockchainVerificationResponse) +async def verify_follower_endpoint(follower_id: int, db: Session = Depends(get_db)): + """ + Verify the cryptographic integrity of a follower record. + """ + result = await run_in_threadpool( + grievance_service.verify_follower_integrity, + follower_id, + db + ) + return result + +@app.get("/api/issues/recent", response_model=List[IssueResponse]) def get_recent_issues(db: Session = Depends(get_db)): + cached_data = recent_issues_cache.get() + if cached_data: + return JSONResponse(content=cached_data) + # Fetch last 10 issues issues = db.query(Issue).order_by(Issue.created_at.desc()).limit(10).all() - # Sanitize data (no emails) - return [ - { - "id": i.id, - "category": i.category, - "description": i.description[:100] + "..." if len(i.description) > 100 else i.description, - "created_at": i.created_at, - "image_path": i.image_path, - "status": i.status - } - for i in issues - ] -@app.post("/api/detect-pothole") + # Convert to Pydantic models for validation and serialization + data = [] + for i in issues: + data.append(IssueResponse( + id=i.id, + category=i.category, + description=i.description[:100] + "..." if len(i.description) > 100 else i.description, + created_at=i.created_at, + image_path=i.image_path, + status=i.status, + upvotes=i.upvotes if i.upvotes is not None else 0, + location=i.location, + latitude=i.latitude, + longitude=i.longitude, + action_plan=i.action_plan + ).model_dump(mode='json')) + + recent_issues_cache.set(data) + return data + +# FIXED: Standardized Detection Endpoints with Consistent Validation +@app.post("/api/detect-pothole", response_model=DetectionResponse) async def detect_pothole_endpoint(image: UploadFile = File(...)): - # Read image - contents = await image.read() - # Convert to PIL Image + # Validate uploaded file + await validate_uploaded_file(image) + + # Convert to PIL Image directly from file object to save memory try: - pil_image = Image.open(io.BytesIO(contents)) - except Exception: - raise HTTPException(status_code=400, detail="Invalid image file") + pil_image = await run_in_threadpool(Image.open, image.file) + # Validate image for processing + await run_in_threadpool(validate_image_for_processing, pil_image) + except HTTPException: + raise # Re-raise HTTP exceptions from validation + except Exception as e: + logger.error(f"Invalid image file for pothole detection: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") # Run detection (blocking, so run in threadpool) try: detections = await run_in_threadpool(detect_potholes, pil_image) + return DetectionResponse(detections=detections) except Exception as e: - print(f"Error creating issue: {e}") - return JSONResponse(status_code=500, content={"message": str(e)}) + logger.error(f"Pothole detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Pothole detection service temporarily unavailable") -@app.get("/api/issues") -def get_issues( - skip: int = 0, - limit: int = 100, - db: Session = Depends(get_db) -): - # Added pagination - issues = db.query(Issue).offset(skip).limit(limit).all() - return issues +@app.post("/api/detect-infrastructure", response_model=DetectionResponse) +async def detect_infrastructure_endpoint(request: Request, image: UploadFile = File(...)): + # Validate uploaded file + await validate_uploaded_file(image) -@app.get("/api/issues/recent") -def get_recent_issues(db: Session = Depends(get_db)): - # Fetch top 10 most recent issues - issues = db.query(Issue).order_by(Issue.created_at.desc()).limit(10).all() - return issues + # Convert to PIL Image directly from file object to save memory + try: + pil_image = await run_in_threadpool(Image.open, image.file) + # Validate image for processing + await run_in_threadpool(validate_image_for_processing, pil_image) + except HTTPException: + raise # Re-raise HTTP exceptions from validation + except Exception as e: + logger.error(f"Invalid image file for infrastructure detection: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") -@app.post("/api/mh/rep-contacts") -async def get_rep_contacts_post(request: PincodeRequest): - return await get_maharashtra_rep_contacts_logic(request.pincode) + # Run detection using unified service (local ML by default) + try: + # Use shared HTTP client from app state + client = request.app.state.http_client + detections = await detect_infrastructure_local(pil_image, client=client) + return DetectionResponse(detections=detections) + except Exception as e: + logger.error(f"Infrastructure detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Infrastructure detection service temporarily unavailable") -@app.get("/api/mh/rep-contacts") -async def get_rep_contacts_get(pincode: str = Query(..., min_length=6, max_length=6)): - return await get_maharashtra_rep_contacts_logic(pincode) +# FIXED: Single flooding detection endpoint with proper async validation +@app.post("/api/detect-flooding", response_model=DetectionResponse) +async def detect_flooding_endpoint(request: Request, image: UploadFile = File(...)): + # Validate uploaded file + await validate_uploaded_file(image) -async def get_maharashtra_rep_contacts_logic(pincode: str): - # Logic extracted to support both GET and POST + # Convert to PIL Image directly from file object to save memory + try: + pil_image = await run_in_threadpool(Image.open, image.file) + # Validate image for processing + await run_in_threadpool(validate_image_for_processing, pil_image) + except HTTPException: + raise # Re-raise HTTP exceptions from validation + except Exception as e: + logger.error(f"Invalid image file for flooding detection: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + # Run detection using unified service (local ML by default) + try: + # Use shared HTTP client from app state + client = request.app.state.http_client + detections = await detect_flooding_local(pil_image, client=client) + return DetectionResponse(detections=detections) + except Exception as e: + logger.error(f"Flooding detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Flooding detection service temporarily unavailable") + +@app.post("/api/detect-vandalism", response_model=DetectionResponse) +async def detect_vandalism_endpoint(request: Request, image: UploadFile = File(...)): + # Validate uploaded file + await validate_uploaded_file(image) + + # Convert to PIL Image directly from file object to save memory + try: + pil_image = await run_in_threadpool(Image.open, image.file) + # Validate image for processing + await run_in_threadpool(validate_image_for_processing, pil_image) + except HTTPException: + raise # Re-raise HTTP exceptions from validation + except Exception as e: + logger.error(f"Invalid image file for vandalism detection: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + # Run detection using unified service (local ML by default) + try: + # Use shared HTTP client from app state + client = request.app.state.http_client + detections = await detect_vandalism_local(pil_image, client=client) + return DetectionResponse(detections=detections) + except Exception as e: + logger.error(f"Vandalism detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Detection service temporarily unavailable") + +@app.post("/api/detect-garbage", response_model=DetectionResponse) +async def detect_garbage_endpoint(image: UploadFile = File(...)): + # Validate uploaded file + await validate_uploaded_file(image) + + # Convert to PIL Image directly from file object to save memory + try: + pil_image = await run_in_threadpool(Image.open, image.file) + # Validate image for processing + await run_in_threadpool(validate_image_for_processing, pil_image) + except HTTPException: + raise # Re-raise HTTP exceptions from validation + except Exception as e: + logger.error(f"Invalid image file for garbage detection: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + # Run detection (blocking, so run in threadpool) + try: + detections = await run_in_threadpool(detect_garbage, pil_image) + return DetectionResponse(detections=detections) + except Exception as e: + logger.error(f"Garbage detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Detection service temporarily unavailable") + +# External API Detection Endpoints (HuggingFace CLIP-based) +@app.post("/api/detect-illegal-parking") +async def detect_illegal_parking_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_illegal_parking_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Illegal parking detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-street-light") +async def detect_street_light_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_street_light_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Street light detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-fire") +async def detect_fire_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_fire_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Fire detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-stray-animal") +async def detect_stray_animal_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_stray_animal_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Stray animal detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-blocked-road") +async def detect_blocked_road_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_blocked_road_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Blocked road detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-tree-hazard") +async def detect_tree_hazard_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_tree_hazard_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Tree hazard detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-pest") +async def detect_pest_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + detections = await detect_pest_clip(image_bytes, client=client) + return {"detections": detections} + except Exception as e: + logger.error(f"Pest detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-severity") +async def detect_severity_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + result = await detect_severity_clip(image_bytes, client=client) + return result + except Exception as e: + logger.error(f"Severity detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/detect-smart-scan") +async def detect_smart_scan_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + result = await detect_smart_scan_clip(image_bytes, client=client) + return result + except Exception as e: + logger.error(f"Smart scan detection error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.post("/api/generate-description") +async def generate_description_endpoint(request: Request, image: UploadFile = File(...)): + try: + image_bytes = await image.read() + except Exception as e: + logger.error(f"Invalid image file: {e}", exc_info=True) + raise HTTPException(status_code=400, detail="Invalid image file") + + try: + client = request.app.state.http_client + description = await generate_image_caption(image_bytes, client=client) + if not description: + return {"description": "", "error": "Could not generate description"} + return {"description": description} + except Exception as e: + logger.error(f"Description generation error: {e}", exc_info=True) + raise HTTPException(status_code=500, detail="Internal server error") + +@app.get("/api/mh/rep-contacts") +async def get_maharashtra_rep_contacts(pincode: str = Query(..., min_length=6, max_length=6)): + """ + Get MLA and representative contact information for Maharashtra by pincode. + """ + # Validate pincode format if not pincode.isdigit(): - raise HTTPException(status_code=400, detail="Invalid pincode") + raise HTTPException( + status_code=400, + detail="Invalid pincode format. Must be 6 digits." + ) + # Find constituency by pincode constituency_info = find_constituency_by_pincode(pincode) if not constituency_info: - # Fallback to just district check - raise HTTPException(status_code=404, detail="Unknown pincode") + raise HTTPException( + status_code=404, + detail="Unknown pincode for Maharashtra MVP. Currently only supporting limited pincodes." + ) + # Find MLA by constituency assembly_constituency = constituency_info.get("assembly_constituency") mla_info = None if assembly_constituency: mla_info = find_mla_by_constituency(assembly_constituency) + # If explicit MLA lookup failed or wasn't possible, create a generic placeholder if not mla_info: mla_info = { "mla_name": "MLA Info Unavailable", @@ -383,11 +939,14 @@ async def get_maharashtra_rep_contacts_logic(pincode: str): "email": "N/A", "twitter": "Not Available" } + # If we have a district but no constituency, explain it if not assembly_constituency: constituency_info["assembly_constituency"] = "Unknown (District Found)" + # Generate AI summary (optional) description = None try: + # Only generate summary if we have a valid constituency and MLA if assembly_constituency and mla_info["mla_name"] != "MLA Info Unavailable": ai_services = get_ai_services() description = await ai_services.mla_summary_service.generate_mla_summary( @@ -395,9 +954,11 @@ async def get_maharashtra_rep_contacts_logic(pincode: str): assembly_constituency=assembly_constituency, mla_name=mla_info["mla_name"] ) - except Exception: - pass + except Exception as e: + logger.error(f"Error generating MLA summary: {e}") + # Continue without description + # Build response response = { "pincode": pincode, "state": constituency_info["state"], @@ -417,112 +978,13 @@ async def get_maharashtra_rep_contacts_logic(pincode: str): } } + # Add description if generated if description: response["description"] = description elif mla_info["mla_name"] == "MLA Info Unavailable": - response["description"] = f"We found that {pincode} belongs to {constituency_info['district']} district." + response["description"] = f"We found that {pincode} belongs to {constituency_info['district']} district, but we don't have the specific MLA details for this exact pincode yet." return response -@app.get("/api/mh/districts") -async def get_districts(): - return {"districts": [d[2] for d in DISTRICT_RANGES]} if 'DISTRICT_RANGES' in globals() else {"districts": []} - -@app.post("/api/detect-pothole") -async def api_detect_pothole(file: UploadFile = File(...)): - try: - def process_image(): - img = PIL.Image.open(file.file) - return detect_potholes(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.post("/api/detect-garbage") -async def api_detect_garbage(file: UploadFile = File(...)): - try: - def process_image(): - img = PIL.Image.open(file.file) - return detect_garbage(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.post("/api/detect-vandalism") -async def api_detect_vandalism(file: UploadFile = File(...)): - try: - if not os.getenv("HF_TOKEN") and not os.getenv("HUGGINGFACE_HUB_TOKEN"): - print("Warning: HF_TOKEN not set.") - def process_image(): - img = PIL.Image.open(file.file) - return detect_vandalism(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.post("/api/detect-flooding") -async def api_detect_flooding(file: UploadFile = File(...)): - try: - def process_image(): - img = PIL.Image.open(file.file) - return detect_flooding(img) - result = await run_in_threadpool(process_image) - return {"detections": result} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.post("/api/chat") -async def chat_endpoint(request: ChatRequest): - try: - response = await chat_with_civic_assistant(request.message, request.history) - return {"response": response} - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.get("/api/responsibility-map") -async def get_responsibility_map_endpoint(): - try: - data = await run_in_threadpool(get_responsible_authority) - return data - except Exception as e: - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.post("/api/analyze-issue") -async def analyze_issue_endpoint( - description: str = Form(...), - image: Optional[UploadFile] = File(None) -): - try: - image_path = None - if image: - os.makedirs("data/temp", exist_ok=True) - image_path = f"data/temp/{uuid.uuid4()}_{image.filename}" - # save blocking - await run_in_threadpool(save_file_blocking, image.file, image_path) - - result = await analyze_issue_with_ai(description, image_path) - - # Cleanup - if image_path and os.path.exists(image_path): - os.remove(image_path) - - return result - except Exception as e: - print(f"Analysis error: {e}") - return JSONResponse(status_code=500, content={"error": str(e)}) - -@app.post("/api/issues/{issue_id}/upvote") -def upvote_issue(issue_id: int, db: Session = Depends(get_db)): - issue = db.query(Issue).filter(Issue.id == issue_id).first() - if not issue: - raise HTTPException(status_code=404, detail="Issue not found") - - if issue.upvotes is None: - issue.upvotes = 0 - issue.upvotes += 1 - db.commit() - db.refresh(issue) - return {"status": "success", "upvotes": issue.upvotes} +# Note: Frontend serving code removed for separate deployment +# The frontend will be deployed on Netlify and make API calls to this backend \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index d60cf281..fe60fee1 100644 --- a/backend/models.py +++ b/backend/models.py @@ -105,6 +105,10 @@ class EscalationAudit(Base): reason = Column(Enum(EscalationReason), nullable=False) notes = Column(Text, nullable=True) # Additional context + # Blockchain-style integrity fields + integrity_hash = Column(String, nullable=True, index=True) + previous_integrity_hash = Column(String, nullable=True) + # Relationships grievance = relationship("Grievance", back_populates="audit_logs") diff --git a/backend/schemas.py b/backend/schemas.py index 2119ca4c..c8fa6940 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -206,6 +206,8 @@ class EscalationAuditResponse(BaseModel): new_authority: str = Field(..., description="New authority after escalation") timestamp: datetime = Field(..., description="When the escalation occurred") reason: str = Field(..., description="Reason for escalation (SLA_BREACH, SEVERITY_UPGRADE, MANUAL)") + integrity_hash: Optional[str] = Field(None, description="Cryptographic integrity hash") + previous_integrity_hash: Optional[str] = Field(None, description="Hash of the previous escalation audit record") class GrievanceSummaryResponse(BaseModel): id: int = Field(..., description="Grievance ID") diff --git a/backend/tests/test_escalation_blockchain.py b/backend/tests/test_escalation_blockchain.py new file mode 100644 index 00000000..66c618d3 --- /dev/null +++ b/backend/tests/test_escalation_blockchain.py @@ -0,0 +1,249 @@ +import pytest +import hashlib +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Import Base exactly the same way backend.models imports it to avoid duplicate Base instances +from database import Base +from backend.models import Grievance, SeverityLevel, EscalationAudit, EscalationReason, Jurisdiction, JurisdictionLevel, SLAConfig +from backend.grievance_service import GrievanceService +from backend.escalation_engine import EscalationEngine + +# Setup in-memory SQLite database for testing +SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" + +def seed_database(db): + # Create sample jurisdictions + jurisdictions_data = [ + { + "level": JurisdictionLevel.LOCAL, + "geographic_coverage": {"cities": ["Mumbai"], "districts": ["Mumbai"]}, + "responsible_authority": "Mumbai Municipal Corporation", + "default_sla_hours": 24 + }, + { + "level": JurisdictionLevel.DISTRICT, + "geographic_coverage": {"districts": ["Mumbai", "Pune"], "states": ["Maharashtra"]}, + "responsible_authority": "Maharashtra District Administration", + "default_sla_hours": 48 + }, + { + "level": JurisdictionLevel.STATE, + "geographic_coverage": {"states": ["Maharashtra"]}, + "responsible_authority": "Maharashtra State Government", + "default_sla_hours": 72 + }, + { + "level": JurisdictionLevel.NATIONAL, + "geographic_coverage": {"states": ["Maharashtra", "Karnataka", "Delhi"]}, + "responsible_authority": "Government of India", + "default_sla_hours": 168 # 1 week + } + ] + + for jur_data in jurisdictions_data: + jurisdiction = Jurisdiction(**jur_data) + db.add(jurisdiction) + + # Create sample SLA configurations + sla_configs_data = [ + { + "severity": SeverityLevel.CRITICAL, + "jurisdiction_level": JurisdictionLevel.LOCAL, + "department": "health", + "sla_hours": 4 + }, + { + "severity": SeverityLevel.HIGH, + "jurisdiction_level": JurisdictionLevel.DISTRICT, + "department": "police", + "sla_hours": 12 + }, + { + "severity": SeverityLevel.MEDIUM, + "jurisdiction_level": JurisdictionLevel.STATE, + "department": "education", + "sla_hours": 48 + }, + { + "severity": SeverityLevel.LOW, + "jurisdiction_level": JurisdictionLevel.NATIONAL, + "department": "infrastructure", + "sla_hours": 168 + } + ] + + for sla_data in sla_configs_data: + sla_config = SLAConfig(**sla_data) + db.add(sla_config) + + db.commit() + +@pytest.fixture(name="db_session") +def fixture_db_session(): + # Set up in-memory sqlite with static pool so connections share state + from sqlalchemy.pool import StaticPool + engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool + ) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(autouse=True) +def clear_hash_cache(): + # Clear in-memory hash cache before and after each test case + EscalationEngine._audit_last_hash_cache.clear() + yield + EscalationEngine._audit_last_hash_cache.clear() + +def test_escalation_blockchain_creation_and_verification(db_session): + """ + Test that EscalationAudit records are correctly created with blockchain-style + integrity hashes and previous integrity hashes, and can be verified. + """ + # 1. Initialize the system + seed_database(db_session) + + service = GrievanceService() + + # 2. Create a test grievance + grievance_data = { + "category": "health", + "severity": "medium", + "city": "Mumbai", + "district": "Mumbai", + "state": "Maharashtra", + "description": "Public health hazard reported" + } + + grievance = service.create_grievance(grievance_data, db=db_session) + assert grievance is not None + + # Retrieve grievance with loaded relationships + grievance = service.get_grievance(grievance.id, db=db_session) + assert grievance is not None + + # Clear cache to simulate starting clean (will fetch from DB if needed) + EscalationEngine._audit_last_hash_cache.clear() + + # 3. Perform first escalation (Severity Escalation) + success = service.escalate_grievance_severity( + grievance.id, + SeverityLevel.CRITICAL, + reason="Upgrading severity to Critical due to urgent conditions", + db=db_session + ) + assert success is True + + # Retrieve audit logs + audit_logs = db_session.query(EscalationAudit).filter(EscalationAudit.grievance_id == grievance.id).all() + assert len(audit_logs) == 1 + audit1 = audit_logs[0] + + # Check blockchain fields of first audit + assert audit1.integrity_hash is not None + assert audit1.previous_integrity_hash is None + + # Verify first hash: SHA256(grievance_id | reason.value | 'GENESIS') + expected_hash_input1 = f"{grievance.id}|{audit1.reason.value}|GENESIS" + expected_hash1 = hashlib.sha256(expected_hash_input1.encode()).hexdigest() + assert audit1.integrity_hash == expected_hash1 + + # Verify integrity verification endpoint/method + verification1 = service.verify_audit_integrity(audit1.id, db=db_session) + assert verification1["is_valid"] is True + assert verification1["message"] == "Integrity verified" + + # Verify that the cache now contains this hash (O(1) lookup check) + assert EscalationEngine._audit_last_hash_cache[grievance.id] == expected_hash1 + + # 4. Perform second escalation (Manual Escalation) + success2 = service.manual_escalate( + grievance.id, + reason="Urgent administrative escalation", + db=db_session + ) + assert success2 is True + + # Retrieve updated audit logs + audit_logs2 = db_session.query(EscalationAudit).filter(EscalationAudit.grievance_id == grievance.id).order_by(EscalationAudit.id).all() + assert len(audit_logs2) == 2 + audit2 = audit_logs2[1] + + # Check blockchain fields of second audit + assert audit2.integrity_hash is not None + assert audit2.previous_integrity_hash == audit1.integrity_hash + + # Verify second hash: SHA256(grievance_id | reason.value | audit1.integrity_hash) + expected_hash_input2 = f"{grievance.id}|{audit2.reason.value}|{audit1.integrity_hash}" + expected_hash2 = hashlib.sha256(expected_hash_input2.encode()).hexdigest() + assert audit2.integrity_hash == expected_hash2 + + # Verify integrity of second record + verification2 = service.verify_audit_integrity(audit2.id, db=db_session) + assert verification2["is_valid"] is True + + # 5. Tamper Detection Test + # If someone tampers with the reason or the previous hash, verification must fail. + # Let's modify the reason of the second audit record in the DB + audit2.reason = EscalationReason.SLA_BREACH # changed from MANUAL + db_session.commit() + + verification_tampered = service.verify_audit_integrity(audit2.id, db=db_session) + assert verification_tampered["is_valid"] is False + assert verification_tampered["message"] == "INTEGRITY BREACH DETECTED" + +def test_cache_miss_fallback(db_session): + """ + Test that if the cache is empty, the system falls back to fetching from database + and successfully continues the hash chain without issues. + """ + seed_database(db_session) + + service = GrievanceService() + grievance_data = { + "category": "police", + "severity": "medium", + "city": "Mumbai", + "district": "Mumbai", + "state": "Maharashtra", + "description": "Noise complaint" + } + + grievance = service.create_grievance(grievance_data, db=db_session) + assert grievance is not None + + # First escalation + service.escalate_grievance_severity( + grievance.id, + SeverityLevel.CRITICAL, + reason="Noise level rose significantly", + db=db_session + ) + + # Verify cache is populated + assert grievance.id in EscalationEngine._audit_last_hash_cache + first_hash = EscalationEngine._audit_last_hash_cache[grievance.id] + + # Explicitly clear the in-memory cache to force a DB query (cache miss fallback) + EscalationEngine._audit_last_hash_cache.clear() + + # Second escalation should query DB, find first_hash, chain it correctly, and re-cache + service.manual_escalate( + grievance.id, + reason="Forced manual escalation", + db=db_session + ) + + audit_logs = db_session.query(EscalationAudit).filter(EscalationAudit.grievance_id == grievance.id).order_by(EscalationAudit.id).all() + assert len(audit_logs) == 2 + assert audit_logs[1].previous_integrity_hash == first_hash + assert EscalationEngine._audit_last_hash_cache[grievance.id] == audit_logs[1].integrity_hash diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 20253be7..ba2c153f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "dexie": "^4.4.4", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -2485,9 +2488,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2497,7 +2500,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -2552,9 +2555,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -3827,9 +3830,9 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -4086,9 +4089,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { @@ -4447,9 +4450,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.2", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", - "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", "dev": true, "funding": [ { @@ -4467,8 +4470,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.4", - "caniuse-lite": "^1.0.30001799", + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -4659,9 +4662,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4685,9 +4688,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4709,9 +4712,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -4730,9 +4733,9 @@ "license": "MIT", "dependencies": { "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -4840,9 +4843,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -5378,6 +5381,12 @@ "node": ">=8" } }, + "node_modules/dexie": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz", + "integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==", + "license": "Apache-2.0" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5456,9 +5465,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.388", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", - "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", "dev": true, "license": "ISC" }, @@ -5751,9 +5760,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -5762,8 +5771,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -6136,9 +6145,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -6206,9 +6215,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -6855,9 +6864,9 @@ } }, "node_modules/i18next": { - "version": "26.3.4", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz", - "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==", + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", "funding": [ { "type": "individual", @@ -6873,9 +6882,8 @@ } ], "license": "MIT", - "peer": true, "peerDependencies": { - "typescript": "^5 || ^6" + "typescript": "^5 || ^6 || ^7" }, "peerDependenciesMeta": { "typescript": { @@ -6883,6 +6891,15 @@ } } }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8842,13 +8859,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9043,9 +9053,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.14.7", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.7.tgz", - "integrity": "sha512-HrQZpxtwMhindpMvlu0fAeSwvRzXhBQnOoS8g0/9Z0tQ3V5o4u2QAwo8bMrnharfZaseYimeh21u/7hVl7eJrg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9139,9 +9149,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -9172,9 +9182,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -9506,9 +9516,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -9586,9 +9596,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "dev": true, "funding": [ { @@ -9606,7 +9616,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10413,9 +10423,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", - "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", "dev": true, "license": "MIT" }, @@ -11044,9 +11054,9 @@ } }, "node_modules/terser": { - "version": "5.48.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", - "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -11167,22 +11177,22 @@ } }, "node_modules/tldts": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz", - "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.4.7" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz", - "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, @@ -12074,46 +12084,13 @@ } }, "node_modules/workbox-build/node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, "engines": { - "node": ">= 8" - } - }, - "node_modules/workbox-build/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/workbox-build/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/workbox-build/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" + "node": ">= 12" } }, "node_modules/workbox-cacheable-response": { @@ -12293,9 +12270,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index e2cfa9e2..c4ed9216 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,9 @@ "test:coverage": "jest --coverage" }, "dependencies": { + "dexie": "^4.4.4", + "i18next": "^26.3.6", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.562.0", "react": "^19.2.0", "react-dom": "^19.2.0",