Files
common-availability/backend/src/app/main.py
Joyce 7fefd634f5 feat: add production deployment config
- Add docker-compose.prod.yml with env var support
- Add frontend/Dockerfile.prod with nginx for static serving
- Fix Zulip notification to run in thread pool (avoid blocking)
- Use Zulip time format for timezone-aware display
- Add Zulip @mentions for users matched by email

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:29:52 -05:00

189 lines
5.7 KiB
Python

import asyncio
import logging
from uuid import UUID
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.availability_service import calculate_availability
from app.database import get_db
from app.ics_service import sync_all_calendars, sync_participant_calendar
from app.models import Participant
from app.schemas import (
AvailabilityRequest,
AvailabilityResponse,
ParticipantCreate,
ParticipantResponse,
SyncResponse,
ScheduleRequest,
)
from app.scheduler import start_scheduler, stop_scheduler
from app.email_service import send_meeting_invite
from app.zulip_service import send_zulip_notification
from contextlib import asynccontextmanager
from datetime import datetime, timezone, timedelta
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
start_scheduler()
yield
stop_scheduler()
app = FastAPI(title="Common Availability API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.post("/api/participants", response_model=ParticipantResponse)
async def create_participant(
data: ParticipantCreate, db: AsyncSession = Depends(get_db)
):
existing = await db.execute(
select(Participant).where(Participant.email == data.email)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
participant = Participant(
name=data.name,
email=data.email,
ics_url=data.ics_url,
)
db.add(participant)
await db.commit()
await db.refresh(participant)
try:
if participant.ics_url:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.warning(f"Initial sync failed for {participant.email}: {e}")
return participant
@app.get("/api/participants", response_model=list[ParticipantResponse])
async def list_participants(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Participant))
return result.scalars().all()
@app.get("/api/participants/{participant_id}", response_model=ParticipantResponse)
async def get_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
return participant
@app.delete("/api/participants/{participant_id}")
async def delete_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
await db.delete(participant)
await db.commit()
return {"status": "deleted"}
@app.post("/api/availability", response_model=AvailabilityResponse)
async def get_availability(
request: AvailabilityRequest, db: AsyncSession = Depends(get_db)
):
slots = await calculate_availability(db, request.participant_ids)
return {"slots": slots}
@app.post("/api/sync", response_model=SyncResponse)
async def sync_calendars(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Participant))
participants = result.scalars().all()
results = await sync_all_calendars(db, list(participants))
return {"results": results}
@app.post("/api/sync/{participant_id}")
async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Participant).where(Participant.id == participant_id)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=404, detail="Participant not found")
try:
if participant.ics_url:
count = await sync_participant_calendar(db, participant)
return {"status": "success", "blocks_synced": count}
return {"status": "skipped", "message": "No ICS URL provided"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/schedule")
async def schedule_meeting(
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
):
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
raise HTTPException(
status_code=400,
detail="Meetings must be scheduled at least 2 hours in advance."
)
result = await db.execute(
select(Participant).where(Participant.id.in_(data.participant_ids))
)
participants = result.scalars().all()
if len(participants) != len(data.participant_ids):
raise HTTPException(status_code=400, detail="Some participants not found")
participant_dicts = [
{"name": p.name, "email": p.email} for p in participants
]
email_success = await send_meeting_invite(
participant_dicts,
data.title,
data.description,
data.start_time,
data.end_time
)
zulip_success = await asyncio.to_thread(
send_zulip_notification,
data.title,
data.start_time,
participant_dicts
)
return {
"status": "success",
"email_sent": email_success,
"zulip_sent": zulip_success
}