diff --git a/backend/src/app/main.py b/backend/src/app/main.py index 3b49be3..06a834c 100644 --- a/backend/src/app/main.py +++ b/backend/src/app/main.py @@ -1,3 +1,4 @@ +import asyncio import logging from uuid import UUID @@ -146,7 +147,6 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_ async def schedule_meeting( data: ScheduleRequest, db: AsyncSession = Depends(get_db) ): - # 1. Validate Lead Time (2 hours) min_start_time = datetime.now(timezone.utc) + timedelta(hours=2) if data.start_time.replace(tzinfo=timezone.utc) < min_start_time: raise HTTPException( @@ -154,7 +154,6 @@ async def schedule_meeting( detail="Meetings must be scheduled at least 2 hours in advance." ) - # 2. Fetch Participants result = await db.execute( select(Participant).where(Participant.id.in_(data.participant_ids)) ) @@ -166,9 +165,7 @@ async def schedule_meeting( participant_dicts = [ {"name": p.name, "email": p.email} for p in participants ] - participant_names = [p.name for p in participants] - # 3. Send Notifications email_success = await send_meeting_invite( participant_dicts, data.title, @@ -177,10 +174,11 @@ async def schedule_meeting( data.end_time ) - zulip_success = send_zulip_notification( + zulip_success = await asyncio.to_thread( + send_zulip_notification, data.title, data.start_time, - participant_names + participant_dicts ) return { diff --git a/backend/src/app/zulip_service.py b/backend/src/app/zulip_service.py index 57ca7cb..6ced82d 100644 --- a/backend/src/app/zulip_service.py +++ b/backend/src/app/zulip_service.py @@ -5,13 +5,44 @@ from datetime import datetime logger = logging.getLogger(__name__) + +def get_zulip_usernames_by_email(client: zulip.Client) -> dict[str, str]: + """Fetch all Zulip users and return a mapping of email -> full_name for mentions.""" + try: + result = client.get_users() + if result.get("result") == "success": + users_map = { + user["email"].lower(): user["full_name"] + for user in result.get("members", []) + if not user.get("is_bot", False) + } + return users_map + except Exception as e: + logger.warning(f"Failed to fetch Zulip users: {e}") + return {} + + +def format_participant_mentions( + participants: list[dict], + zulip_users: dict[str, str], +) -> str: + """Format participants as Zulip mentions where possible, plain names otherwise.""" + formatted = [] + for p in participants: + email = p["email"].lower() + if email in zulip_users: + formatted.append(f'@**{zulip_users[email]}**') + else: + formatted.append(p["name"]) + return ", ".join(formatted) + + def send_zulip_notification( title: str, start_time: datetime, - participant_names: list[str], + participants: list[dict], ) -> bool: if not settings.zulip_site or not settings.zulip_api_key or not settings.zulip_email: - logger.warning("Zulip credentials not configured. Skipping notification.") return False try: @@ -21,13 +52,15 @@ def send_zulip_notification( site=settings.zulip_site ) - formatted_time = start_time.strftime('%Y-%m-%d %H:%M %Z') - people = ", ".join(participant_names) - + zulip_users = get_zulip_usernames_by_email(client) + people = format_participant_mentions(participants, zulip_users) + + zulip_time = f"" + content = ( f"📅 **Meeting Scheduled**\n" f"**What:** {title}\n" - f"**When:** {formatted_time}\n" + f"**When:** {zulip_time}\n" f"**Who:** {people}" ) @@ -40,7 +73,6 @@ def send_zulip_notification( result = client.send_message(request) if result.get("result") == "success": - logger.info("Sent Zulip notification") return True else: logger.error(f"Zulip API error: {result.get('msg')}") diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..725fb6b --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,52 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + POSTGRES_DB: ${POSTGRES_DB:-availability} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability} + SYNC_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + ZULIP_SITE: ${ZULIP_SITE:-} + ZULIP_EMAIL: ${ZULIP_EMAIL:-} + ZULIP_API_KEY: ${ZULIP_API_KEY:-} + ZULIP_STREAM: ${ZULIP_STREAM:-general} + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + args: + VITE_API_URL: ${VITE_API_URL:?VITE_API_URL required} + ports: + - "8080:8080" + depends_on: + - backend + restart: unless-stopped + +volumes: + postgres_data: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..a74cbe4 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,34 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +# VITE_API_URL must be set at build time +ARG VITE_API_URL +ENV VITE_API_URL=${VITE_API_URL} + +RUN npm run build + +# Production stage +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +# Handle client-side routing +RUN echo 'server { \ + listen 8080; \ + root /usr/share/nginx/html; \ + index index.html; \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 8080 + +CMD ["nginx", "-g", "daemon off;"]