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>
This commit is contained in:
Joyce
2026-01-21 14:29:52 -05:00
parent 26311c867a
commit 7fefd634f5
4 changed files with 129 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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"<time:{start_time.isoformat()}>"
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')}")

52
docker-compose.prod.yml Normal file
View File

@@ -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:

34
frontend/Dockerfile.prod Normal file
View File

@@ -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;"]