init commit

This commit is contained in:
2025-09-25 08:05:25 +09:00
commit 4d7551d4f1
56 changed files with 5977 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import httpx
import time
from typing import Dict
from shared.config import settings
import asyncio
app = FastAPI(title="API Gateway", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Service registry
SERVICES = {
"users": "http://localhost:8001",
"emergency": "http://localhost:8002",
"location": "http://localhost:8003",
"calendar": "http://localhost:8004",
"notifications": "http://localhost:8005"
}
# Rate limiting (simple in-memory implementation)
request_counts: Dict[str, Dict[str, int]] = {}
RATE_LIMIT_REQUESTS = 100 # requests per minute
RATE_LIMIT_WINDOW = 60 # seconds
def get_client_ip(request: Request) -> str:
"""Get client IP address"""
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.client.host
def is_rate_limited(client_ip: str) -> bool:
"""Check if client is rate limited"""
current_time = int(time.time())
window_start = current_time - RATE_LIMIT_WINDOW
if client_ip not in request_counts:
request_counts[client_ip] = {}
# Clean old entries
request_counts[client_ip] = {
timestamp: count for timestamp, count in request_counts[client_ip].items()
if int(timestamp) > window_start
}
# Count requests in current window
total_requests = sum(request_counts[client_ip].values())
if total_requests >= RATE_LIMIT_REQUESTS:
return True
# Add current request
timestamp_key = str(current_time)
request_counts[client_ip][timestamp_key] = request_counts[client_ip].get(timestamp_key, 0) + 1
return False
async def proxy_request(service_url: str, path: str, method: str, headers: dict, body: bytes = None, params: dict = None):
"""Proxy request to microservice"""
url = f"{service_url}{path}"
# Remove hop-by-hop headers
filtered_headers = {
k: v for k, v in headers.items()
if k.lower() not in ["host", "connection", "upgrade", "proxy-connection",
"proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding"]
}
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.request(
method=method,
url=url,
headers=filtered_headers,
content=body,
params=params
)
return response
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="Service timeout")
except httpx.ConnectError:
raise HTTPException(status_code=503, detail="Service unavailable")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Proxy error: {str(e)}")
@app.middleware("http")
async def rate_limiting_middleware(request: Request, call_next):
"""Rate limiting middleware"""
client_ip = get_client_ip(request)
# Skip rate limiting for health checks
if request.url.path.endswith("/health"):
return await call_next(request)
if is_rate_limited(client_ip):
return JSONResponse(
status_code=429,
content={"detail": "Rate limit exceeded"}
)
return await call_next(request)
# User Service routes
@app.api_route("/api/v1/register", methods=["POST"])
@app.api_route("/api/v1/login", methods=["POST"])
@app.api_route("/api/v1/profile", methods=["GET", "PUT"])
async def user_service_proxy(request: Request):
"""Proxy requests to User Service"""
body = await request.body()
response = await proxy_request(
SERVICES["users"],
request.url.path,
request.method,
dict(request.headers),
body,
dict(request.query_params)
)
return JSONResponse(
status_code=response.status_code,
content=response.json(),
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
)
# Emergency Service routes
@app.api_route("/api/v1/alert", methods=["POST"])
@app.api_route("/api/v1/alert/{alert_id}/respond", methods=["POST"])
@app.api_route("/api/v1/alert/{alert_id}/resolve", methods=["PUT"])
@app.api_route("/api/v1/alerts/my", methods=["GET"])
@app.api_route("/api/v1/alerts/active", methods=["GET"])
async def emergency_service_proxy(request: Request):
"""Proxy requests to Emergency Service"""
body = await request.body()
response = await proxy_request(
SERVICES["emergency"],
request.url.path,
request.method,
dict(request.headers),
body,
dict(request.query_params)
)
return JSONResponse(
status_code=response.status_code,
content=response.json(),
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
)
# Location Service routes
@app.api_route("/api/v1/update-location", methods=["POST"])
@app.api_route("/api/v1/user-location/{user_id}", methods=["GET"])
@app.api_route("/api/v1/nearby-users", methods=["GET"])
@app.api_route("/api/v1/location-history", methods=["GET"])
@app.api_route("/api/v1/location", methods=["DELETE"])
async def location_service_proxy(request: Request):
"""Proxy requests to Location Service"""
body = await request.body()
response = await proxy_request(
SERVICES["location"],
request.url.path,
request.method,
dict(request.headers),
body,
dict(request.query_params)
)
return JSONResponse(
status_code=response.status_code,
content=response.json(),
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
)
# Calendar Service routes
@app.api_route("/api/v1/entries", methods=["GET", "POST"])
@app.api_route("/api/v1/entries/{entry_id}", methods=["DELETE"])
@app.api_route("/api/v1/cycle-overview", methods=["GET"])
@app.api_route("/api/v1/insights", methods=["GET"])
async def calendar_service_proxy(request: Request):
"""Proxy requests to Calendar Service"""
body = await request.body()
response = await proxy_request(
SERVICES["calendar"],
request.url.path,
request.method,
dict(request.headers),
body,
dict(request.query_params)
)
return JSONResponse(
status_code=response.status_code,
content=response.json(),
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
)
# Notification Service routes
@app.api_route("/api/v1/register-device", methods=["POST"])
@app.api_route("/api/v1/send-notification", methods=["POST"])
@app.api_route("/api/v1/device-token", methods=["DELETE"])
@app.api_route("/api/v1/my-devices", methods=["GET"])
async def notification_service_proxy(request: Request):
"""Proxy requests to Notification Service"""
body = await request.body()
response = await proxy_request(
SERVICES["notifications"],
request.url.path,
request.method,
dict(request.headers),
body,
dict(request.query_params)
)
return JSONResponse(
status_code=response.status_code,
content=response.json(),
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
)
@app.get("/api/v1/health")
async def gateway_health_check():
"""Gateway health check"""
return {"status": "healthy", "service": "api-gateway"}
@app.get("/api/v1/services-status")
async def check_services_status():
"""Check status of all microservices"""
service_status = {}
async def check_service(name: str, url: str):
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{url}/api/v1/health")
service_status[name] = {
"status": "healthy" if response.status_code == 200 else "unhealthy",
"response_time_ms": response.elapsed.total_seconds() * 1000,
"url": url
}
except Exception as e:
service_status[name] = {
"status": "unhealthy",
"error": str(e),
"url": url
}
# Check all services concurrently
tasks = [check_service(name, url) for name, url in SERVICES.items()]
await asyncio.gather(*tasks)
all_healthy = all(status["status"] == "healthy" for status in service_status.values())
return {
"gateway_status": "healthy",
"all_services_healthy": all_healthy,
"services": service_status
}
@app.get("/")
async def root():
"""Root endpoint with API information"""
return {
"service": "Women Safety App API Gateway",
"version": "1.0.0",
"status": "running",
"endpoints": {
"auth": "/api/v1/register, /api/v1/login",
"profile": "/api/v1/profile",
"emergency": "/api/v1/alert, /api/v1/alerts/*",
"location": "/api/v1/update-location, /api/v1/nearby-users",
"calendar": "/api/v1/entries, /api/v1/cycle-overview",
"notifications": "/api/v1/register-device, /api/v1/send-notification"
},
"docs": "/docs"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,413 @@
from fastapi import FastAPI, HTTPException, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, desc
from shared.config import settings
from shared.database import get_db
from services.calendar_service.models import CalendarEntry, CycleData, HealthInsights
from services.user_service.main import get_current_user
from services.user_service.models import User
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime, date, timedelta
from enum import Enum
app = FastAPI(title="Calendar Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class EntryType(str, Enum):
PERIOD = "period"
OVULATION = "ovulation"
SYMPTOMS = "symptoms"
MEDICATION = "medication"
MOOD = "mood"
EXERCISE = "exercise"
APPOINTMENT = "appointment"
class FlowIntensity(str, Enum):
LIGHT = "light"
MEDIUM = "medium"
HEAVY = "heavy"
SPOTTING = "spotting"
class MoodType(str, Enum):
HAPPY = "happy"
SAD = "sad"
ANXIOUS = "anxious"
IRRITATED = "irritated"
ENERGETIC = "energetic"
TIRED = "tired"
class CalendarEntryCreate(BaseModel):
entry_date: date
entry_type: EntryType
flow_intensity: Optional[FlowIntensity] = None
period_symptoms: Optional[str] = Field(None, max_length=500)
mood: Optional[MoodType] = None
energy_level: Optional[int] = Field(None, ge=1, le=5)
sleep_hours: Optional[int] = Field(None, ge=0, le=24)
symptoms: Optional[str] = Field(None, max_length=1000)
medications: Optional[str] = Field(None, max_length=500)
notes: Optional[str] = Field(None, max_length=1000)
class CalendarEntryResponse(BaseModel):
id: int
uuid: str
entry_date: date
entry_type: str
flow_intensity: Optional[str]
period_symptoms: Optional[str]
mood: Optional[str]
energy_level: Optional[int]
sleep_hours: Optional[int]
symptoms: Optional[str]
medications: Optional[str]
notes: Optional[str]
is_predicted: bool
confidence_score: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class CycleDataResponse(BaseModel):
id: int
cycle_start_date: date
cycle_length: Optional[int]
period_length: Optional[int]
ovulation_date: Optional[date]
fertile_window_start: Optional[date]
fertile_window_end: Optional[date]
next_period_predicted: Optional[date]
avg_cycle_length: Optional[int]
avg_period_length: Optional[int]
class Config:
from_attributes = True
class HealthInsightResponse(BaseModel):
id: int
insight_type: str
title: str
description: str
recommendation: Optional[str]
confidence_level: str
created_at: datetime
class Config:
from_attributes = True
class CycleOverview(BaseModel):
current_cycle_day: Optional[int]
current_phase: str # menstrual, follicular, ovulation, luteal
next_period_date: Optional[date]
days_until_period: Optional[int]
cycle_regularity: str # very_regular, regular, irregular, very_irregular
avg_cycle_length: Optional[int]
def calculate_cycle_phase(cycle_start: date, cycle_length: int, current_date: date) -> str:
"""Calculate current cycle phase"""
days_since_start = (current_date - cycle_start).days
if days_since_start <= 5:
return "menstrual"
elif days_since_start <= cycle_length // 2 - 2:
return "follicular"
elif cycle_length // 2 - 2 < days_since_start <= cycle_length // 2 + 2:
return "ovulation"
else:
return "luteal"
async def calculate_predictions(user_id: int, db: AsyncSession):
"""Calculate cycle predictions based on historical data"""
# Get last 6 cycles for calculations
cycles = await db.execute(
select(CycleData)
.filter(CycleData.user_id == user_id)
.order_by(desc(CycleData.cycle_start_date))
.limit(6)
)
cycle_list = cycles.scalars().all()
if len(cycle_list) < 2:
return None
# Calculate averages
cycle_lengths = [c.cycle_length for c in cycle_list if c.cycle_length]
period_lengths = [c.period_length for c in cycle_list if c.period_length]
if not cycle_lengths:
return None
avg_cycle = sum(cycle_lengths) / len(cycle_lengths)
avg_period = sum(period_lengths) / len(period_lengths) if period_lengths else 5
# Predict next period
last_cycle = cycle_list[0]
next_period_date = last_cycle.cycle_start_date + timedelta(days=int(avg_cycle))
return {
"avg_cycle_length": int(avg_cycle),
"avg_period_length": int(avg_period),
"next_period_predicted": next_period_date,
"ovulation_date": last_cycle.cycle_start_date + timedelta(days=int(avg_cycle // 2))
}
@app.post("/api/v1/entries", response_model=CalendarEntryResponse)
async def create_calendar_entry(
entry_data: CalendarEntryCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create new calendar entry"""
# Check if entry already exists for this date and type
existing = await db.execute(
select(CalendarEntry).filter(
and_(
CalendarEntry.user_id == current_user.id,
CalendarEntry.entry_date == entry_data.entry_date,
CalendarEntry.entry_type == entry_data.entry_type.value
)
)
)
if existing.scalars().first():
raise HTTPException(
status_code=400,
detail="Entry already exists for this date and type"
)
db_entry = CalendarEntry(
user_id=current_user.id,
entry_date=entry_data.entry_date,
entry_type=entry_data.entry_type.value,
flow_intensity=entry_data.flow_intensity.value if entry_data.flow_intensity else None,
period_symptoms=entry_data.period_symptoms,
mood=entry_data.mood.value if entry_data.mood else None,
energy_level=entry_data.energy_level,
sleep_hours=entry_data.sleep_hours,
symptoms=entry_data.symptoms,
medications=entry_data.medications,
notes=entry_data.notes,
)
db.add(db_entry)
await db.commit()
await db.refresh(db_entry)
# If this is a period entry, update cycle data
if entry_data.entry_type == EntryType.PERIOD:
await update_cycle_data(current_user.id, entry_data.entry_date, db)
return CalendarEntryResponse.model_validate(db_entry)
async def update_cycle_data(user_id: int, period_date: date, db: AsyncSession):
"""Update cycle data when period is logged"""
# Get last cycle
last_cycle = await db.execute(
select(CycleData)
.filter(CycleData.user_id == user_id)
.order_by(desc(CycleData.cycle_start_date))
.limit(1)
)
last_cycle_data = last_cycle.scalars().first()
if last_cycle_data:
# Calculate cycle length
cycle_length = (period_date - last_cycle_data.cycle_start_date).days
last_cycle_data.cycle_length = cycle_length
# Create new cycle
predictions = await calculate_predictions(user_id, db)
new_cycle = CycleData(
user_id=user_id,
cycle_start_date=period_date,
avg_cycle_length=predictions["avg_cycle_length"] if predictions else None,
next_period_predicted=predictions["next_period_predicted"] if predictions else None,
ovulation_date=predictions["ovulation_date"] if predictions else None,
)
db.add(new_cycle)
await db.commit()
@app.get("/api/v1/entries", response_model=List[CalendarEntryResponse])
async def get_calendar_entries(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
entry_type: Optional[EntryType] = Query(None),
limit: int = Query(100, ge=1, le=365)
):
"""Get calendar entries with optional filtering"""
query = select(CalendarEntry).filter(CalendarEntry.user_id == current_user.id)
if start_date:
query = query.filter(CalendarEntry.entry_date >= start_date)
if end_date:
query = query.filter(CalendarEntry.entry_date <= end_date)
if entry_type:
query = query.filter(CalendarEntry.entry_type == entry_type.value)
query = query.order_by(desc(CalendarEntry.entry_date)).limit(limit)
result = await db.execute(query)
entries = result.scalars().all()
return [CalendarEntryResponse.model_validate(entry) for entry in entries]
@app.get("/api/v1/cycle-overview", response_model=CycleOverview)
async def get_cycle_overview(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get current cycle overview and predictions"""
# Get current cycle
current_cycle = await db.execute(
select(CycleData)
.filter(CycleData.user_id == current_user.id)
.order_by(desc(CycleData.cycle_start_date))
.limit(1)
)
cycle_data = current_cycle.scalars().first()
if not cycle_data:
return CycleOverview(
current_cycle_day=None,
current_phase="unknown",
next_period_date=None,
days_until_period=None,
cycle_regularity="unknown",
avg_cycle_length=None
)
today = date.today()
current_cycle_day = (today - cycle_data.cycle_start_date).days + 1
# Calculate current phase
cycle_length = cycle_data.avg_cycle_length or 28
current_phase = calculate_cycle_phase(cycle_data.cycle_start_date, cycle_length, today)
# Days until next period
next_period_date = cycle_data.next_period_predicted
days_until_period = None
if next_period_date:
days_until_period = (next_period_date - today).days
# Calculate regularity
cycles = await db.execute(
select(CycleData)
.filter(CycleData.user_id == current_user.id)
.order_by(desc(CycleData.cycle_start_date))
.limit(6)
)
cycle_list = cycles.scalars().all()
regularity = "unknown"
if len(cycle_list) >= 3:
lengths = [c.cycle_length for c in cycle_list if c.cycle_length]
if lengths:
variance = max(lengths) - min(lengths)
if variance <= 2:
regularity = "very_regular"
elif variance <= 5:
regularity = "regular"
elif variance <= 10:
regularity = "irregular"
else:
regularity = "very_irregular"
return CycleOverview(
current_cycle_day=current_cycle_day,
current_phase=current_phase,
next_period_date=next_period_date,
days_until_period=days_until_period,
cycle_regularity=regularity,
avg_cycle_length=cycle_data.avg_cycle_length
)
@app.get("/api/v1/insights", response_model=List[HealthInsightResponse])
async def get_health_insights(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
limit: int = Query(10, ge=1, le=50)
):
"""Get personalized health insights"""
result = await db.execute(
select(HealthInsights)
.filter(
HealthInsights.user_id == current_user.id,
HealthInsights.is_dismissed == False
)
.order_by(desc(HealthInsights.created_at))
.limit(limit)
)
insights = result.scalars().all()
return [HealthInsightResponse.model_validate(insight) for insight in insights]
@app.delete("/api/v1/entries/{entry_id}")
async def delete_calendar_entry(
entry_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete calendar entry"""
result = await db.execute(
select(CalendarEntry).filter(
and_(
CalendarEntry.id == entry_id,
CalendarEntry.user_id == current_user.id
)
)
)
entry = result.scalars().first()
if not entry:
raise HTTPException(status_code=404, detail="Entry not found")
await db.delete(entry)
await db.commit()
return {"message": "Entry deleted successfully"}
@app.get("/api/v1/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "calendar-service"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8004)

View File

@@ -0,0 +1,77 @@
from sqlalchemy import Column, String, Integer, Date, Text, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from shared.database import BaseModel
import uuid
class CalendarEntry(BaseModel):
__tablename__ = "calendar_entries"
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
entry_date = Column(Date, nullable=False, index=True)
entry_type = Column(String(50), nullable=False) # period, ovulation, symptoms, medication, etc.
# Period tracking
flow_intensity = Column(String(20)) # light, medium, heavy
period_symptoms = Column(Text) # cramps, headache, mood, etc.
# General health
mood = Column(String(20)) # happy, sad, anxious, irritated, etc.
energy_level = Column(Integer) # 1-5 scale
sleep_hours = Column(Integer)
# Symptoms and notes
symptoms = Column(Text) # Any symptoms experienced
medications = Column(Text) # Medications taken
notes = Column(Text) # Personal notes
# Predictions and calculations
is_predicted = Column(Boolean, default=False) # If this is a predicted entry
confidence_score = Column(Integer) # Prediction confidence 1-100
def __repr__(self):
return f"<CalendarEntry user_id={self.user_id} date={self.entry_date} type={self.entry_type}>"
class CycleData(BaseModel):
__tablename__ = "cycle_data"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
cycle_start_date = Column(Date, nullable=False)
cycle_length = Column(Integer) # Length of this cycle
period_length = Column(Integer) # Length of period in this cycle
# Calculated fields
ovulation_date = Column(Date)
fertile_window_start = Column(Date)
fertile_window_end = Column(Date)
next_period_predicted = Column(Date)
# Cycle characteristics
cycle_regularity_score = Column(Integer) # 1-100, how regular is this cycle
avg_cycle_length = Column(Integer) # Rolling average
avg_period_length = Column(Integer) # Rolling average
def __repr__(self):
return f"<CycleData user_id={self.user_id} start={self.cycle_start_date}>"
class HealthInsights(BaseModel):
__tablename__ = "health_insights"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
insight_type = Column(String(50), nullable=False) # cycle_pattern, symptom_pattern, etc.
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False)
recommendation = Column(Text)
# Metadata
confidence_level = Column(String(20)) # high, medium, low
data_points_used = Column(Integer) # How many data points were used
is_dismissed = Column(Boolean, default=False)
def __repr__(self):
return f"<HealthInsights user_id={self.user_id} type={self.insight_type}>"

View File

@@ -0,0 +1,319 @@
from fastapi import FastAPI, HTTPException, Depends, status, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from shared.config import settings
from shared.database import get_db, AsyncSessionLocal
from shared.auth import get_current_user_from_token
from services.emergency_service.models import EmergencyAlert, EmergencyResponse
from services.emergency_service.schemas import (
EmergencyAlertCreate, EmergencyAlertResponse,
EmergencyResponseCreate, EmergencyResponseResponse,
EmergencyStats
)
from services.user_service.models import User
import httpx
import asyncio
from datetime import datetime, timedelta
app = FastAPI(title="Emergency Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
async def get_current_user(
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Get current user from token via auth dependency."""
# Get full user object from database
result = await db.execute(select(User).filter(User.id == user_data["user_id"]))
user = result.scalars().first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "emergency_service"}
async def get_nearby_users(latitude: float, longitude: float, radius_km: float = 1.0) -> list:
"""Get users within radius using Location Service"""
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"http://localhost:8003/api/v1/nearby-users",
params={
"latitude": latitude,
"longitude": longitude,
"radius_km": radius_km
},
timeout=5.0
)
if response.status_code == 200:
return response.json()
return []
except Exception:
return []
async def send_emergency_notifications(alert_id: int, nearby_users: list):
"""Send push notifications to nearby users"""
async with httpx.AsyncClient() as client:
try:
await client.post(
"http://localhost:8005/api/v1/send-emergency-notifications",
json={
"alert_id": alert_id,
"user_ids": [user["user_id"] for user in nearby_users]
},
timeout=10.0
)
except Exception as e:
print(f"Failed to send notifications: {e}")
@app.post("/api/v1/alert", response_model=EmergencyAlertResponse)
async def create_emergency_alert(
alert_data: EmergencyAlertCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create new emergency alert and notify nearby users"""
# Create alert
db_alert = EmergencyAlert(
user_id=current_user.id,
latitude=alert_data.latitude,
longitude=alert_data.longitude,
address=alert_data.address,
alert_type=alert_data.alert_type.value,
message=alert_data.message,
)
db.add(db_alert)
await db.commit()
await db.refresh(db_alert)
# Get nearby users and send notifications in background
background_tasks.add_task(
process_emergency_alert,
db_alert.id,
alert_data.latitude,
alert_data.longitude
)
return EmergencyAlertResponse.model_validate(db_alert)
async def process_emergency_alert(alert_id: int, latitude: float, longitude: float):
"""Process emergency alert - get nearby users and send notifications"""
# Get nearby users
nearby_users = await get_nearby_users(latitude, longitude, settings.MAX_EMERGENCY_RADIUS_KM)
# Update alert with notified users count
async with AsyncSessionLocal() as db:
result = await db.execute(select(EmergencyAlert).filter(EmergencyAlert.id == alert_id))
alert = result.scalars().first()
if alert:
alert.notified_users_count = len(nearby_users)
await db.commit()
# Send notifications
if nearby_users:
await send_emergency_notifications(alert_id, nearby_users)
@app.post("/api/v1/alert/{alert_id}/respond", response_model=EmergencyResponseResponse)
async def respond_to_alert(
alert_id: int,
response_data: EmergencyResponseCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Respond to emergency alert"""
# Check if alert exists
result = await db.execute(select(EmergencyAlert).filter(EmergencyAlert.id == alert_id))
alert = result.scalars().first()
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
if alert.is_resolved:
raise HTTPException(status_code=400, detail="Alert already resolved")
# Check if user already responded
existing_response = await db.execute(
select(EmergencyResponse).filter(
EmergencyResponse.alert_id == alert_id,
EmergencyResponse.responder_id == current_user.id
)
)
if existing_response.scalars().first():
raise HTTPException(status_code=400, detail="You already responded to this alert")
# Create response
db_response = EmergencyResponse(
alert_id=alert_id,
responder_id=current_user.id,
response_type=response_data.response_type.value,
message=response_data.message,
eta_minutes=response_data.eta_minutes,
)
db.add(db_response)
# Update responded users count
alert.responded_users_count += 1
await db.commit()
await db.refresh(db_response)
return EmergencyResponseResponse.model_validate(db_response)
@app.put("/api/v1/alert/{alert_id}/resolve")
async def resolve_alert(
alert_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Mark alert as resolved (only by alert creator)"""
result = await db.execute(select(EmergencyAlert).filter(EmergencyAlert.id == alert_id))
alert = result.scalars().first()
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
if alert.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Only alert creator can resolve it")
if alert.is_resolved:
raise HTTPException(status_code=400, detail="Alert already resolved")
alert.is_resolved = True
alert.resolved_at = datetime.utcnow()
alert.resolved_by = current_user.id
await db.commit()
return {"message": "Alert resolved successfully"}
@app.get("/api/v1/alerts/my", response_model=list[EmergencyAlertResponse])
async def get_my_alerts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
limit: int = 50
):
"""Get current user's emergency alerts"""
result = await db.execute(
select(EmergencyAlert)
.filter(EmergencyAlert.user_id == current_user.id)
.order_by(EmergencyAlert.created_at.desc())
.limit(limit)
)
alerts = result.scalars().all()
return [EmergencyAlertResponse.model_validate(alert) for alert in alerts]
@app.get("/api/v1/alerts/active", response_model=list[EmergencyAlertResponse])
async def get_active_alerts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
limit: int = 20
):
"""Get active alerts in user's area (last 2 hours)"""
# Get user's current location first
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"http://localhost:8003/api/v1/user-location/{current_user.id}",
timeout=5.0
)
if response.status_code != 200:
raise HTTPException(status_code=400, detail="User location not available")
location_data = response.json()
except Exception:
raise HTTPException(status_code=400, detail="Location service unavailable")
# Get alerts from last 2 hours
two_hours_ago = datetime.utcnow() - timedelta(hours=2)
result = await db.execute(
select(EmergencyAlert)
.filter(
EmergencyAlert.is_resolved == False,
EmergencyAlert.created_at >= two_hours_ago
)
.order_by(EmergencyAlert.created_at.desc())
.limit(limit)
)
alerts = result.scalars().all()
return [EmergencyAlertResponse.model_validate(alert) for alert in alerts]
@app.get("/api/v1/stats", response_model=EmergencyStats)
async def get_emergency_stats(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get emergency service statistics"""
# Get total alerts
total_result = await db.execute(select(func.count(EmergencyAlert.id)))
total_alerts = total_result.scalar()
# Get active alerts
active_result = await db.execute(
select(func.count(EmergencyAlert.id))
.filter(EmergencyAlert.is_resolved == False)
)
active_alerts = active_result.scalar()
# Get resolved alerts
resolved_alerts = total_alerts - active_alerts
# Get total responders
responders_result = await db.execute(
select(func.count(func.distinct(EmergencyResponse.responder_id)))
)
total_responders = responders_result.scalar()
return EmergencyStats(
total_alerts=total_alerts,
active_alerts=active_alerts,
resolved_alerts=resolved_alerts,
avg_response_time_minutes=None, # TODO: Calculate this
total_responders=total_responders
)
@app.get("/api/v1/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "emergency-service"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8002)

View File

@@ -0,0 +1,44 @@
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
from shared.database import BaseModel
import uuid
class EmergencyAlert(BaseModel):
__tablename__ = "emergency_alerts"
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# Location at time of alert
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
address = Column(String(500))
# Alert details
alert_type = Column(String(50), default="general") # general, medical, violence, etc.
message = Column(Text)
is_resolved = Column(Boolean, default=False)
resolved_at = Column(DateTime(timezone=True))
resolved_by = Column(Integer, ForeignKey("users.id"))
# Response tracking
notified_users_count = Column(Integer, default=0)
responded_users_count = Column(Integer, default=0)
def __repr__(self):
return f"<EmergencyAlert {self.uuid}>"
class EmergencyResponse(BaseModel):
__tablename__ = "emergency_responses"
alert_id = Column(Integer, ForeignKey("emergency_alerts.id"), nullable=False, index=True)
responder_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
response_type = Column(String(50)) # help_on_way, contacted_authorities, etc.
message = Column(Text)
eta_minutes = Column(Integer) # Estimated time of arrival
def __repr__(self):
return f"<EmergencyResponse {self.id}>"

View File

@@ -0,0 +1,80 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class AlertType(str, Enum):
GENERAL = "general"
MEDICAL = "medical"
VIOLENCE = "violence"
HARASSMENT = "harassment"
UNSAFE_AREA = "unsafe_area"
class ResponseType(str, Enum):
HELP_ON_WAY = "help_on_way"
CONTACTED_AUTHORITIES = "contacted_authorities"
SAFE_NOW = "safe_now"
FALSE_ALARM = "false_alarm"
class EmergencyAlertCreate(BaseModel):
latitude: float = Field(..., ge=-90, le=90)
longitude: float = Field(..., ge=-180, le=180)
alert_type: AlertType = AlertType.GENERAL
message: Optional[str] = Field(None, max_length=500)
address: Optional[str] = Field(None, max_length=500)
class EmergencyAlertResponse(BaseModel):
id: int
uuid: str
user_id: int
latitude: float
longitude: float
address: Optional[str]
alert_type: str
message: Optional[str]
is_resolved: bool
resolved_at: Optional[datetime]
notified_users_count: int
responded_users_count: int
created_at: datetime
class Config:
from_attributes = True
class EmergencyResponseCreate(BaseModel):
response_type: ResponseType
message: Optional[str] = Field(None, max_length=500)
eta_minutes: Optional[int] = Field(None, ge=0, le=240) # Max 4 hours
class EmergencyResponseResponse(BaseModel):
id: int
alert_id: int
responder_id: int
response_type: str
message: Optional[str]
eta_minutes: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class NearbyUsersResponse(BaseModel):
user_id: int
distance_meters: float
latitude: float
longitude: float
class EmergencyStats(BaseModel):
total_alerts: int
active_alerts: int
resolved_alerts: int
avg_response_time_minutes: Optional[float]
total_responders: int

View File

@@ -0,0 +1,312 @@
from fastapi import FastAPI, HTTPException, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from shared.config import settings
from shared.database import get_db
from shared.cache import CacheService
from services.location_service.models import UserLocation, LocationHistory
from services.user_service.main import get_current_user
from services.user_service.models import User
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime, timedelta
import math
app = FastAPI(title="Location Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class LocationUpdate(BaseModel):
latitude: float = Field(..., ge=-90, le=90)
longitude: float = Field(..., ge=-180, le=180)
accuracy: Optional[float] = Field(None, ge=0)
altitude: Optional[float] = None
speed: Optional[float] = Field(None, ge=0)
heading: Optional[float] = Field(None, ge=0, le=360)
class LocationResponse(BaseModel):
user_id: int
latitude: float
longitude: float
accuracy: Optional[float]
updated_at: datetime
class Config:
from_attributes = True
class NearbyUserResponse(BaseModel):
user_id: int
latitude: float
longitude: float
distance_meters: float
last_seen: datetime
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points using Haversine formula (in meters)"""
R = 6371000 # Earth's radius in meters
lat1_rad = math.radians(lat1)
lat2_rad = math.radians(lat2)
delta_lat = math.radians(lat2 - lat1)
delta_lon = math.radians(lon2 - lon1)
a = (math.sin(delta_lat / 2) * math.sin(delta_lat / 2) +
math.cos(lat1_rad) * math.cos(lat2_rad) *
math.sin(delta_lon / 2) * math.sin(delta_lon / 2))
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
distance = R * c
return distance
@app.post("/api/v1/update-location")
async def update_user_location(
location_data: LocationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update user's current location"""
if not current_user.location_sharing_enabled:
raise HTTPException(status_code=403, detail="Location sharing is disabled")
# Update or create current location
result = await db.execute(
select(UserLocation).filter(UserLocation.user_id == current_user.id)
)
user_location = result.scalars().first()
if user_location:
user_location.latitude = location_data.latitude
user_location.longitude = location_data.longitude
user_location.accuracy = location_data.accuracy
user_location.altitude = location_data.altitude
user_location.speed = location_data.speed
user_location.heading = location_data.heading
else:
user_location = UserLocation(
user_id=current_user.id,
latitude=location_data.latitude,
longitude=location_data.longitude,
accuracy=location_data.accuracy,
altitude=location_data.altitude,
speed=location_data.speed,
heading=location_data.heading,
)
db.add(user_location)
# Save to history
location_history = LocationHistory(
user_id=current_user.id,
latitude=location_data.latitude,
longitude=location_data.longitude,
accuracy=location_data.accuracy,
recorded_at=datetime.utcnow(),
)
db.add(location_history)
await db.commit()
# Cache location for fast access
await CacheService.set_location(
current_user.id,
location_data.latitude,
location_data.longitude,
expire=300 # 5 minutes
)
return {"message": "Location updated successfully"}
@app.get("/api/v1/user-location/{user_id}", response_model=LocationResponse)
async def get_user_location(
user_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get specific user's location (if sharing is enabled)"""
# Check if requested user exists and has location sharing enabled
result = await db.execute(select(User).filter(User.id == user_id))
target_user = result.scalars().first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
if not target_user.location_sharing_enabled and target_user.id != current_user.id:
raise HTTPException(status_code=403, detail="User has disabled location sharing")
# Try cache first
cached_location = await CacheService.get_location(user_id)
if cached_location:
lat, lng = cached_location
return LocationResponse(
user_id=user_id,
latitude=lat,
longitude=lng,
accuracy=None,
updated_at=datetime.utcnow()
)
# Get from database
result = await db.execute(
select(UserLocation).filter(UserLocation.user_id == user_id)
)
user_location = result.scalars().first()
if not user_location:
raise HTTPException(status_code=404, detail="Location not found")
return LocationResponse.model_validate(user_location)
@app.get("/api/v1/nearby-users", response_model=List[NearbyUserResponse])
async def get_nearby_users(
latitude: float = Query(..., ge=-90, le=90),
longitude: float = Query(..., ge=-180, le=180),
radius_km: float = Query(1.0, ge=0.1, le=10.0),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Find users within specified radius"""
# Convert radius to degrees (approximate)
# 1 degree ≈ 111 km
radius_deg = radius_km / 111.0
# Query for nearby users with location sharing enabled
# Using bounding box for initial filtering (more efficient than distance calculation)
query = text("""
SELECT
ul.user_id,
ul.latitude,
ul.longitude,
ul.updated_at,
u.location_sharing_enabled
FROM user_locations ul
JOIN users u ON ul.user_id = u.id
WHERE u.location_sharing_enabled = true
AND u.is_active = true
AND ul.user_id != :current_user_id
AND ul.latitude BETWEEN :lat_min AND :lat_max
AND ul.longitude BETWEEN :lng_min AND :lng_max
AND ul.updated_at > :time_threshold
LIMIT :limit_val
""")
time_threshold = datetime.utcnow() - timedelta(minutes=15) # Only recent locations
result = await db.execute(query, {
"current_user_id": current_user.id,
"lat_min": latitude - radius_deg,
"lat_max": latitude + radius_deg,
"lng_min": longitude - radius_deg,
"lng_max": longitude + radius_deg,
"time_threshold": time_threshold,
"limit_val": limit
})
nearby_users = []
for row in result:
# Calculate exact distance
distance = calculate_distance(
latitude, longitude,
row.latitude, row.longitude
)
# Filter by exact radius
if distance <= radius_km * 1000: # Convert km to meters
nearby_users.append(NearbyUserResponse(
user_id=row.user_id,
latitude=row.latitude,
longitude=row.longitude,
distance_meters=distance,
last_seen=row.updated_at
))
# Sort by distance
nearby_users.sort(key=lambda x: x.distance_meters)
return nearby_users
@app.get("/api/v1/location-history")
async def get_location_history(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
hours: int = Query(24, ge=1, le=168), # Max 1 week
limit: int = Query(100, ge=1, le=1000)
):
"""Get user's location history"""
time_threshold = datetime.utcnow() - timedelta(hours=hours)
result = await db.execute(
select(LocationHistory)
.filter(
LocationHistory.user_id == current_user.id,
LocationHistory.recorded_at >= time_threshold
)
.order_by(LocationHistory.recorded_at.desc())
.limit(limit)
)
history = result.scalars().all()
return [
{
"latitude": entry.latitude,
"longitude": entry.longitude,
"accuracy": entry.accuracy,
"recorded_at": entry.recorded_at
}
for entry in history
]
@app.delete("/api/v1/location")
async def delete_user_location(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Delete user's current location"""
# Delete current location
result = await db.execute(
select(UserLocation).filter(UserLocation.user_id == current_user.id)
)
user_location = result.scalars().first()
if user_location:
await db.delete(user_location)
await db.commit()
# Clear cache
await CacheService.delete(f"location:{current_user.id}")
return {"message": "Location deleted successfully"}
@app.get("/api/v1/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "location-service"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8003)

View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from shared.database import BaseModel
import uuid
class UserLocation(BaseModel):
__tablename__ = "user_locations"
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
accuracy = Column(Float) # GPS accuracy in meters
altitude = Column(Float)
speed = Column(Float) # Speed in m/s
heading = Column(Float) # Direction in degrees
# Indexes for geospatial queries
__table_args__ = (
Index('idx_location_coords', 'latitude', 'longitude'),
Index('idx_location_user_time', 'user_id', 'created_at'),
)
def __repr__(self):
return f"<UserLocation user_id={self.user_id} lat={self.latitude} lng={self.longitude}>"
class LocationHistory(BaseModel):
__tablename__ = "location_history"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
latitude = Column(Float, nullable=False)
longitude = Column(Float, nullable=False)
accuracy = Column(Float)
recorded_at = Column(DateTime(timezone=True), nullable=False)
# Partition by date for better performance
__table_args__ = (
Index('idx_history_user_date', 'user_id', 'recorded_at'),
Index('idx_history_coords_date', 'latitude', 'longitude', 'recorded_at'),
)
def __repr__(self):
return f"<LocationHistory user_id={self.user_id} at={self.recorded_at}>"

View File

@@ -0,0 +1,361 @@
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from shared.config import settings
from shared.database import get_db
from services.user_service.main import get_current_user
from services.user_service.models import User
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from datetime import datetime
import httpx
import asyncio
import json
app = FastAPI(title="Notification Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class NotificationRequest(BaseModel):
title: str = Field(..., max_length=100)
body: str = Field(..., max_length=500)
data: Optional[Dict[str, Any]] = None
priority: str = Field("normal", pattern="^(low|normal|high)$")
class EmergencyNotificationRequest(BaseModel):
alert_id: int
user_ids: List[int]
alert_type: Optional[str] = "general"
location: Optional[str] = None
class DeviceToken(BaseModel):
token: str = Field(..., min_length=10)
platform: str = Field(..., pattern="^(ios|android|web)$")
class NotificationStats(BaseModel):
total_sent: int
successful_deliveries: int
failed_deliveries: int
emergency_notifications: int
# Mock FCM client for demonstration
class FCMClient:
def __init__(self, server_key: str):
self.server_key = server_key
self.fcm_url = "https://fcm.googleapis.com/fcm/send"
async def send_notification(self, tokens: List[str], notification_data: dict) -> dict:
"""Send push notification via FCM"""
if not self.server_key:
print("FCM Server Key not configured - notification would be sent")
return {"success_count": len(tokens), "failure_count": 0}
headers = {
"Authorization": f"key={self.server_key}",
"Content-Type": "application/json"
}
payload = {
"registration_ids": tokens,
"notification": {
"title": notification_data.get("title"),
"body": notification_data.get("body"),
"sound": "default"
},
"data": notification_data.get("data", {}),
"priority": "high" if notification_data.get("priority") == "high" else "normal"
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self.fcm_url,
headers=headers,
json=payload,
timeout=10.0
)
result = response.json()
return {
"success_count": result.get("success", 0),
"failure_count": result.get("failure", 0),
"results": result.get("results", [])
}
except Exception as e:
print(f"FCM Error: {e}")
return {"success_count": 0, "failure_count": len(tokens)}
# Initialize FCM client
fcm_client = FCMClient(settings.FCM_SERVER_KEY)
# In-memory storage for demo (use Redis or database in production)
user_device_tokens: Dict[int, List[str]] = {}
notification_stats = {
"total_sent": 0,
"successful_deliveries": 0,
"failed_deliveries": 0,
"emergency_notifications": 0
}
@app.post("/api/v1/register-device")
async def register_device_token(
device_data: DeviceToken,
current_user: User = Depends(get_current_user)
):
"""Register device token for push notifications"""
if current_user.id not in user_device_tokens:
user_device_tokens[current_user.id] = []
# Remove existing token if present
if device_data.token in user_device_tokens[current_user.id]:
user_device_tokens[current_user.id].remove(device_data.token)
# Add new token
user_device_tokens[current_user.id].append(device_data.token)
# Keep only last 3 tokens per user
user_device_tokens[current_user.id] = user_device_tokens[current_user.id][-3:]
return {"message": "Device token registered successfully"}
@app.post("/api/v1/send-notification")
async def send_notification(
notification: NotificationRequest,
target_user_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Send notification to specific user"""
# Check if target user exists and accepts notifications
result = await db.execute(select(User).filter(User.id == target_user_id))
target_user = result.scalars().first()
if not target_user:
raise HTTPException(status_code=404, detail="Target user not found")
if not target_user.push_notifications_enabled:
raise HTTPException(status_code=403, detail="User has disabled push notifications")
# Get user's device tokens
tokens = user_device_tokens.get(target_user_id, [])
if not tokens:
raise HTTPException(status_code=400, detail="No device tokens found for user")
# Send notification in background
background_tasks.add_task(
send_push_notification,
tokens,
{
"title": notification.title,
"body": notification.body,
"data": notification.data or {},
"priority": notification.priority
}
)
return {"message": "Notification queued for delivery"}
@app.post("/api/v1/send-emergency-notifications")
async def send_emergency_notifications(
emergency_data: EmergencyNotificationRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Send emergency notifications to nearby users"""
if not emergency_data.user_ids:
return {"message": "No users to notify"}
# Get users who have emergency notifications enabled
result = await db.execute(
select(User).filter(
User.id.in_(emergency_data.user_ids),
User.emergency_notifications_enabled == True,
User.is_active == True
)
)
users = result.scalars().all()
# Collect all device tokens
all_tokens = []
for user in users:
tokens = user_device_tokens.get(user.id, [])
all_tokens.extend(tokens)
if not all_tokens:
return {"message": "No device tokens found for target users"}
# Prepare emergency notification
emergency_title = "🚨 Emergency Alert Nearby"
emergency_body = f"Someone needs help in your area. Alert type: {emergency_data.alert_type}"
if emergency_data.location:
emergency_body += f" Location: {emergency_data.location}"
notification_data = {
"title": emergency_title,
"body": emergency_body,
"data": {
"type": "emergency",
"alert_id": str(emergency_data.alert_id),
"alert_type": emergency_data.alert_type
},
"priority": "high"
}
# Send notifications in background
background_tasks.add_task(
send_emergency_push_notification,
all_tokens,
notification_data
)
return {"message": f"Emergency notifications queued for {len(users)} users"}
async def send_push_notification(tokens: List[str], notification_data: dict):
"""Send push notification using FCM"""
try:
result = await fcm_client.send_notification(tokens, notification_data)
# Update stats
notification_stats["total_sent"] += len(tokens)
notification_stats["successful_deliveries"] += result["success_count"]
notification_stats["failed_deliveries"] += result["failure_count"]
print(f"Notification sent: {result['success_count']} successful, {result['failure_count']} failed")
except Exception as e:
print(f"Failed to send notification: {e}")
notification_stats["failed_deliveries"] += len(tokens)
async def send_emergency_push_notification(tokens: List[str], notification_data: dict):
"""Send emergency push notification with special handling"""
try:
# Emergency notifications are sent immediately with high priority
result = await fcm_client.send_notification(tokens, notification_data)
# Update stats
notification_stats["total_sent"] += len(tokens)
notification_stats["successful_deliveries"] += result["success_count"]
notification_stats["failed_deliveries"] += result["failure_count"]
notification_stats["emergency_notifications"] += len(tokens)
print(f"Emergency notification sent: {result['success_count']} successful, {result['failure_count']} failed")
except Exception as e:
print(f"Failed to send emergency notification: {e}")
notification_stats["emergency_notifications"] += len(tokens)
notification_stats["failed_deliveries"] += len(tokens)
@app.post("/api/v1/send-calendar-reminder")
async def send_calendar_reminder(
title: str,
message: str,
user_ids: List[int],
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Send calendar reminder notifications"""
# Get users who have notifications enabled
result = await db.execute(
select(User).filter(
User.id.in_(user_ids),
User.push_notifications_enabled == True,
User.is_active == True
)
)
users = result.scalars().all()
# Send notifications to each user
for user in users:
tokens = user_device_tokens.get(user.id, [])
if tokens:
background_tasks.add_task(
send_push_notification,
tokens,
{
"title": title,
"body": message,
"data": {"type": "calendar_reminder"},
"priority": "normal"
}
)
return {"message": f"Calendar reminders queued for {len(users)} users"}
@app.delete("/api/v1/device-token")
async def unregister_device_token(
token: str,
current_user: User = Depends(get_current_user)
):
"""Unregister device token"""
if current_user.id in user_device_tokens:
tokens = user_device_tokens[current_user.id]
if token in tokens:
tokens.remove(token)
if not tokens:
del user_device_tokens[current_user.id]
return {"message": "Device token unregistered successfully"}
@app.get("/api/v1/my-devices")
async def get_my_device_tokens(
current_user: User = Depends(get_current_user)
):
"""Get user's registered device tokens (masked for security)"""
tokens = user_device_tokens.get(current_user.id, [])
masked_tokens = [f"{token[:8]}...{token[-8:]}" for token in tokens]
return {
"device_count": len(tokens),
"tokens": masked_tokens
}
@app.get("/api/v1/stats", response_model=NotificationStats)
async def get_notification_stats(current_user: User = Depends(get_current_user)):
"""Get notification service statistics"""
return NotificationStats(**notification_stats)
@app.get("/api/v1/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"service": "notification-service",
"fcm_configured": bool(settings.FCM_SERVER_KEY)
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8005)

View File

@@ -0,0 +1,140 @@
from fastapi import FastAPI, HTTPException, Depends, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import timedelta
from shared.config import settings
from shared.database import get_db
from shared.auth import (
verify_password,
get_password_hash,
create_access_token,
get_current_user_from_token
)
from services.user_service.models import User
from services.user_service.schemas import UserCreate, UserResponse, UserLogin, Token, UserUpdate
app = FastAPI(title="User Service", version="1.0.0")
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # В продакшене ограничить конкретными доменами
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
async def get_current_user(
user_data: dict = Depends(get_current_user_from_token),
db: AsyncSession = Depends(get_db)
):
"""Get current user from token via auth dependency."""
# Get full user object from database
result = await db.execute(select(User).filter(User.id == user_data["user_id"]))
user = result.scalars().first()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "user_service"}
@app.post("/api/v1/register", response_model=UserResponse)
async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
"""Register a new user"""
# Check if user already exists
result = await db.execute(select(User).filter(User.email == user_data.email))
if result.scalars().first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
db_user = User(
email=user_data.email,
phone=user_data.phone,
password_hash=hashed_password,
first_name=user_data.first_name,
last_name=user_data.last_name,
date_of_birth=user_data.date_of_birth,
bio=user_data.bio,
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return UserResponse.model_validate(db_user)
@app.post("/api/v1/login", response_model=Token)
async def login(user_credentials: UserLogin, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return token"""
result = await db.execute(select(User).filter(User.email == user_credentials.email))
user = result.scalars().first()
if not user or not verify_password(user_credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account is deactivated",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": str(user.id), "email": user.email},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/api/v1/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)):
"""Get current user profile"""
return UserResponse.model_validate(current_user)
@app.put("/api/v1/profile", response_model=UserResponse)
async def update_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Update user profile"""
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
await db.commit()
await db.refresh(current_user)
return UserResponse.model_validate(current_user)
@app.get("/api/v1/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "user-service"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)

View File

@@ -0,0 +1,39 @@
from sqlalchemy import Column, String, Integer, Date, Text, Boolean
from sqlalchemy.dialects.postgresql import UUID
from shared.database import BaseModel
import uuid
class User(BaseModel):
__tablename__ = "users"
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
phone = Column(String, unique=True, index=True)
password_hash = Column(String, nullable=False)
# Profile information
first_name = Column(String(50), nullable=False)
last_name = Column(String(50), nullable=False)
date_of_birth = Column(Date)
avatar_url = Column(String)
bio = Column(Text)
# Emergency contacts
emergency_contact_1_name = Column(String(100))
emergency_contact_1_phone = Column(String(20))
emergency_contact_2_name = Column(String(100))
emergency_contact_2_phone = Column(String(20))
# Settings
location_sharing_enabled = Column(Boolean, default=True)
emergency_notifications_enabled = Column(Boolean, default=True)
push_notifications_enabled = Column(Boolean, default=True)
# Security
email_verified = Column(Boolean, default=False)
phone_verified = Column(Boolean, default=False)
is_blocked = Column(Boolean, default=False)
def __repr__(self):
return f"<User {self.email}>"

View File

@@ -0,0 +1,77 @@
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional
from datetime import date
from uuid import UUID
class UserBase(BaseModel):
email: EmailStr
phone: Optional[str] = None
first_name: str = Field(..., min_length=1, max_length=50)
last_name: str = Field(..., min_length=1, max_length=50)
date_of_birth: Optional[date] = None
bio: Optional[str] = Field(None, max_length=500)
class UserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=100)
class UserUpdate(BaseModel):
first_name: Optional[str] = Field(None, min_length=1, max_length=50)
last_name: Optional[str] = Field(None, min_length=1, max_length=50)
phone: Optional[str] = None
date_of_birth: Optional[date] = None
bio: Optional[str] = Field(None, max_length=500)
avatar_url: Optional[str] = None
# Emergency contacts
emergency_contact_1_name: Optional[str] = Field(None, max_length=100)
emergency_contact_1_phone: Optional[str] = Field(None, max_length=20)
emergency_contact_2_name: Optional[str] = Field(None, max_length=100)
emergency_contact_2_phone: Optional[str] = Field(None, max_length=20)
# Settings
location_sharing_enabled: Optional[bool] = None
emergency_notifications_enabled: Optional[bool] = None
push_notifications_enabled: Optional[bool] = None
class UserResponse(UserBase):
id: int
uuid: str
avatar_url: Optional[str] = None
emergency_contact_1_name: Optional[str] = None
emergency_contact_1_phone: Optional[str] = None
emergency_contact_2_name: Optional[str] = None
emergency_contact_2_phone: Optional[str] = None
location_sharing_enabled: bool
emergency_notifications_enabled: bool
push_notifications_enabled: bool
email_verified: bool
phone_verified: bool
is_active: bool
@field_validator('uuid', mode='before')
@classmethod
def convert_uuid_to_str(cls, v):
if isinstance(v, UUID):
return str(v)
return v
class Config:
from_attributes = True
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None