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:
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -146,7 +147,6 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
|
|||||||
async def schedule_meeting(
|
async def schedule_meeting(
|
||||||
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
|
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
|
||||||
):
|
):
|
||||||
# 1. Validate Lead Time (2 hours)
|
|
||||||
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
|
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
|
||||||
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
|
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -154,7 +154,6 @@ async def schedule_meeting(
|
|||||||
detail="Meetings must be scheduled at least 2 hours in advance."
|
detail="Meetings must be scheduled at least 2 hours in advance."
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Fetch Participants
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant).where(Participant.id.in_(data.participant_ids))
|
select(Participant).where(Participant.id.in_(data.participant_ids))
|
||||||
)
|
)
|
||||||
@@ -166,9 +165,7 @@ async def schedule_meeting(
|
|||||||
participant_dicts = [
|
participant_dicts = [
|
||||||
{"name": p.name, "email": p.email} for p in participants
|
{"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(
|
email_success = await send_meeting_invite(
|
||||||
participant_dicts,
|
participant_dicts,
|
||||||
data.title,
|
data.title,
|
||||||
@@ -177,10 +174,11 @@ async def schedule_meeting(
|
|||||||
data.end_time
|
data.end_time
|
||||||
)
|
)
|
||||||
|
|
||||||
zulip_success = send_zulip_notification(
|
zulip_success = await asyncio.to_thread(
|
||||||
|
send_zulip_notification,
|
||||||
data.title,
|
data.title,
|
||||||
data.start_time,
|
data.start_time,
|
||||||
participant_names
|
participant_dicts
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,13 +5,44 @@ from datetime import datetime
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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(
|
def send_zulip_notification(
|
||||||
title: str,
|
title: str,
|
||||||
start_time: datetime,
|
start_time: datetime,
|
||||||
participant_names: list[str],
|
participants: list[dict],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if not settings.zulip_site or not settings.zulip_api_key or not settings.zulip_email:
|
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
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -21,13 +52,15 @@ def send_zulip_notification(
|
|||||||
site=settings.zulip_site
|
site=settings.zulip_site
|
||||||
)
|
)
|
||||||
|
|
||||||
formatted_time = start_time.strftime('%Y-%m-%d %H:%M %Z')
|
zulip_users = get_zulip_usernames_by_email(client)
|
||||||
people = ", ".join(participant_names)
|
people = format_participant_mentions(participants, zulip_users)
|
||||||
|
|
||||||
|
zulip_time = f"<time:{start_time.isoformat()}>"
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
f"📅 **Meeting Scheduled**\n"
|
f"📅 **Meeting Scheduled**\n"
|
||||||
f"**What:** {title}\n"
|
f"**What:** {title}\n"
|
||||||
f"**When:** {formatted_time}\n"
|
f"**When:** {zulip_time}\n"
|
||||||
f"**Who:** {people}"
|
f"**Who:** {people}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +73,6 @@ def send_zulip_notification(
|
|||||||
|
|
||||||
result = client.send_message(request)
|
result = client.send_message(request)
|
||||||
if result.get("result") == "success":
|
if result.get("result") == "success":
|
||||||
logger.info("Sent Zulip notification")
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"Zulip API error: {result.get('msg')}")
|
logger.error(f"Zulip API error: {result.get('msg')}")
|
||||||
|
|||||||
52
docker-compose.prod.yml
Normal file
52
docker-compose.prod.yml
Normal 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
34
frontend/Dockerfile.prod
Normal 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;"]
|
||||||
Reference in New Issue
Block a user