cleanup-and-test #1
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
@@ -9,6 +10,10 @@ config = context.config
|
|||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Use SYNC_DATABASE_URL env var if set, otherwise fall back to alembic.ini
|
||||||
|
if os.getenv("SYNC_DATABASE_URL"):
|
||||||
|
config.set_main_option("sqlalchemy.url", os.getenv("SYNC_DATABASE_URL"))
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("id", sa.UUID(), nullable=False),
|
sa.Column("id", sa.UUID(), nullable=False),
|
||||||
sa.Column("name", sa.String(255), nullable=False),
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
sa.Column("email", sa.String(255), nullable=False),
|
sa.Column("email", sa.String(255), nullable=False),
|
||||||
sa.Column("ics_url", sa.Text(), nullable=False),
|
sa.Column("ics_url", sa.Text(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("id"),
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ dependencies = [
|
|||||||
"python-dateutil>=2.9.0",
|
"python-dateutil>=2.9.0",
|
||||||
"pydantic[email]>=2.10.0",
|
"pydantic[email]>=2.10.0",
|
||||||
"pydantic-settings>=2.6.0",
|
"pydantic-settings>=2.6.0",
|
||||||
|
"apscheduler>=3.10.4",
|
||||||
|
"aiosmtplib>=3.0.1",
|
||||||
|
"zulip>=0.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async def calculate_availability(
|
|||||||
availability = "none"
|
availability = "none"
|
||||||
|
|
||||||
slots.append({
|
slots.append({
|
||||||
"day": day_name,
|
"day": slot_start.strftime("%Y-%m-%d"),
|
||||||
"hour": hour,
|
"hour": hour,
|
||||||
"availability": availability,
|
"availability": availability,
|
||||||
"availableParticipants": available_participants,
|
"availableParticipants": available_participants,
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ class Settings(BaseSettings):
|
|||||||
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
|
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
|
||||||
ics_refresh_interval_minutes: int = 15
|
ics_refresh_interval_minutes: int = 15
|
||||||
|
|
||||||
|
# SMTP Settings
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_user: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
|
||||||
|
# Zulip Settings
|
||||||
|
zulip_site: str | None = None
|
||||||
|
zulip_email: str | None = None
|
||||||
|
zulip_api_key: str | None = None
|
||||||
|
zulip_stream: str = "general"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
82
backend/src/app/email_service.py
Normal file
82
backend/src/app/email_service.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import aiosmtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from icalendar import Calendar, Event, vCalAddress, vText
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def send_meeting_invite(
|
||||||
|
participants: list[dict],
|
||||||
|
title: str,
|
||||||
|
description: str,
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> bool:
|
||||||
|
if not settings.smtp_host or not settings.smtp_user or not settings.smtp_password:
|
||||||
|
logger.warning("SMTP credentials not configured. Skipping email invite.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create the ICS content
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add('prodid', '-//Common Availability//m.com//')
|
||||||
|
cal.add('version', '2.0')
|
||||||
|
cal.add('method', 'REQUEST')
|
||||||
|
|
||||||
|
event = Event()
|
||||||
|
event.add('summary', title)
|
||||||
|
event.add('dtstart', start_time)
|
||||||
|
event.add('dtend', end_time)
|
||||||
|
event.add('dtstamp', datetime.now(timezone.utc))
|
||||||
|
event.add('description', description)
|
||||||
|
event.add('uid', str(uuid.uuid4()))
|
||||||
|
event.add('organizer', vCalAddress(f'MAILTO:{settings.smtp_user}'))
|
||||||
|
|
||||||
|
attendee_emails = []
|
||||||
|
for p in participants:
|
||||||
|
attendee = vCalAddress(f'MAILTO:{p["email"]}')
|
||||||
|
attendee.params['cn'] = vText(p["name"])
|
||||||
|
attendee.params['ROLE'] = vText('REQ-PARTICIPANT')
|
||||||
|
event.add('attendee', attendee, encode=0)
|
||||||
|
attendee_emails.append(p["email"])
|
||||||
|
|
||||||
|
cal.add_component(event)
|
||||||
|
ics_data = cal.to_ical()
|
||||||
|
|
||||||
|
# Create Email
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = f"Invitation: {title}"
|
||||||
|
msg["From"] = settings.smtp_user
|
||||||
|
msg["To"] = ", ".join(attendee_emails)
|
||||||
|
msg.set_content(
|
||||||
|
f"You have been invited to: {title}\n"
|
||||||
|
f"When: {start_time.strftime('%Y-%m-%d %H:%M %Z')}\n\n"
|
||||||
|
f"{description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach ICS
|
||||||
|
msg.add_attachment(
|
||||||
|
ics_data,
|
||||||
|
maintype="text",
|
||||||
|
subtype="calendar",
|
||||||
|
filename="invite.ics",
|
||||||
|
params={"method": "REQUEST"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_user,
|
||||||
|
password=settings.smtp_password,
|
||||||
|
start_tls=True
|
||||||
|
)
|
||||||
|
logger.info(f"Sent meeting invites to {len(attendee_emails)} participants")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email invite: {e}")
|
||||||
|
return False
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -16,12 +17,24 @@ from app.schemas import (
|
|||||||
ParticipantCreate,
|
ParticipantCreate,
|
||||||
ParticipantResponse,
|
ParticipantResponse,
|
||||||
SyncResponse,
|
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)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(title="Common Availability API")
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
start_scheduler()
|
||||||
|
yield
|
||||||
|
stop_scheduler()
|
||||||
|
|
||||||
|
app = FastAPI(title="Common Availability API", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -57,6 +70,7 @@ async def create_participant(
|
|||||||
await db.refresh(participant)
|
await db.refresh(participant)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if participant.ics_url:
|
||||||
await sync_participant_calendar(db, participant)
|
await sync_participant_calendar(db, participant)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Initial sync failed for {participant.email}: {e}")
|
logger.warning(f"Initial sync failed for {participant.email}: {e}")
|
||||||
@@ -121,7 +135,54 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
|
|||||||
raise HTTPException(status_code=404, detail="Participant not found")
|
raise HTTPException(status_code=404, detail="Participant not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if participant.ics_url:
|
||||||
count = await sync_participant_calendar(db, participant)
|
count = await sync_participant_calendar(db, participant)
|
||||||
return {"status": "success", "blocks_synced": count}
|
return {"status": "success", "blocks_synced": count}
|
||||||
|
return {"status": "skipped", "message": "No ICS URL provided"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Participant(Base):
|
|||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
ics_url: Mapped[str] = mapped_column(Text, nullable=False)
|
ics_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, default=datetime.utcnow, nullable=False
|
DateTime, default=datetime.utcnow, nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
39
backend/src/app/scheduler.py
Normal file
39
backend/src/app/scheduler.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import logging
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.database import async_session_maker
|
||||||
|
from app.models import Participant
|
||||||
|
from app.ics_service import sync_participant_calendar
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
async def run_sync_job():
|
||||||
|
logger.info("Starting background calendar sync...")
|
||||||
|
async with async_session_maker() as db:
|
||||||
|
result = await db.execute(select(Participant).where(Participant.ics_url.is_not(None)))
|
||||||
|
participants = result.scalars().all()
|
||||||
|
|
||||||
|
for participant in participants:
|
||||||
|
try:
|
||||||
|
await sync_participant_calendar(db, participant)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Background sync failed for {participant.email}: {e}")
|
||||||
|
logger.info("Background calendar sync completed.")
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
scheduler.add_job(
|
||||||
|
run_sync_job,
|
||||||
|
IntervalTrigger(minutes=settings.ics_refresh_interval_minutes),
|
||||||
|
id="calendar_sync",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
scheduler.start()
|
||||||
|
logger.info("Scheduler started.")
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
scheduler.shutdown()
|
||||||
|
logger.info("Scheduler stopped.")
|
||||||
@@ -7,14 +7,14 @@ from pydantic import BaseModel, EmailStr
|
|||||||
class ParticipantCreate(BaseModel):
|
class ParticipantCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
ics_url: str
|
ics_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ParticipantResponse(BaseModel):
|
class ParticipantResponse(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
name: str
|
name: str
|
||||||
email: str
|
email: str
|
||||||
ics_url: str
|
ics_url: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -39,3 +39,11 @@ class AvailabilityResponse(BaseModel):
|
|||||||
|
|
||||||
class SyncResponse(BaseModel):
|
class SyncResponse(BaseModel):
|
||||||
results: dict[str, dict]
|
results: dict[str, dict]
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleRequest(BaseModel):
|
||||||
|
participant_ids: list[UUID]
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
|||||||
83
backend/src/app/zulip_service.py
Normal file
83
backend/src/app/zulip_service.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import logging
|
||||||
|
import zulip
|
||||||
|
from app.config import settings
|
||||||
|
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,
|
||||||
|
participants: list[dict],
|
||||||
|
) -> bool:
|
||||||
|
if not settings.zulip_site or not settings.zulip_api_key or not settings.zulip_email:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = zulip.Client(
|
||||||
|
email=settings.zulip_email,
|
||||||
|
api_key=settings.zulip_api_key,
|
||||||
|
site=settings.zulip_site
|
||||||
|
)
|
||||||
|
|
||||||
|
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:** {zulip_time}\n"
|
||||||
|
f"**Who:** {people}"
|
||||||
|
)
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"type": "stream",
|
||||||
|
"to": settings.zulip_stream,
|
||||||
|
"topic": "Meeting Announcements",
|
||||||
|
"content": content
|
||||||
|
}
|
||||||
|
|
||||||
|
result = client.send_message(request)
|
||||||
|
if result.get("result") == "success":
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Zulip API error: {result.get('msg')}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Zulip notification: {e}")
|
||||||
|
return False
|
||||||
50
docker-compose.dev.yml
Normal file
50
docker-compose.dev.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: availability
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/availability
|
||||||
|
SYNC_DATABASE_URL: postgresql://postgres:postgres@db:5432/availability
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "8001:8000"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./backend/src:/app/src
|
||||||
|
- ./backend/alembic:/app/alembic
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "5174:8080"
|
||||||
|
environment:
|
||||||
|
VITE_API_URL: http://localhost:8001
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
volumes:
|
||||||
|
- ./frontend/src:/app/src
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -2,47 +2,49 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
|
||||||
POSTGRES_DB: availability
|
POSTGRES_DB: ${POSTGRES_DB:-availability}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/availability
|
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability}
|
||||||
SYNC_DATABASE_URL: postgresql://postgres:postgres@db:5432/availability
|
SYNC_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-availability}
|
||||||
ports:
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
- "8000:8000"
|
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}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
restart: unless-stopped
|
||||||
- ./backend/src:/app/src
|
|
||||||
- ./backend/alembic:/app/alembic
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.prod
|
||||||
ports:
|
args:
|
||||||
- "5173:8080"
|
VITE_API_URL: ${VITE_API_URL:-}
|
||||||
environment:
|
|
||||||
VITE_API_URL: http://localhost:8000
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
volumes:
|
restart: unless-stopped
|
||||||
- ./frontend/src:/app/src
|
expose:
|
||||||
|
- '80'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
28
frontend/Dockerfile.prod
Normal file
28
frontend/Dockerfile.prod
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 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 built assets
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
19
frontend/nginx.conf
Normal file
19
frontend/nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
}
|
||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -2859,7 +2859,6 @@
|
|||||||
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
|
"integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2877,7 +2876,6 @@
|
|||||||
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -2889,7 +2887,6 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@@ -2940,7 +2937,6 @@
|
|||||||
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.38.0",
|
"@typescript-eslint/scope-manager": "8.38.0",
|
||||||
"@typescript-eslint/types": "8.38.0",
|
"@typescript-eslint/types": "8.38.0",
|
||||||
@@ -3173,7 +3169,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3378,7 +3373,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3712,7 +3706,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@@ -3794,8 +3787,7 @@
|
|||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
|
||||||
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/embla-carousel-react": {
|
"node_modules/embla-carousel-react": {
|
||||||
"version": "8.6.0",
|
"version": "8.6.0",
|
||||||
@@ -3893,7 +3885,6 @@
|
|||||||
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
|
"integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5415,7 +5406,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5602,7 +5592,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5629,7 +5618,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5643,7 +5631,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz",
|
||||||
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
|
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -6197,7 +6184,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
|
||||||
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
"@alloc/quick-lru": "^5.2.0",
|
||||||
"arg": "^5.0.2",
|
"arg": "^5.0.2",
|
||||||
@@ -6322,7 +6308,6 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6502,7 +6487,6 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
// Use VITE_API_URL if set at build time, otherwise derive from current origin
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || `${window.location.origin}/api`;
|
||||||
|
|
||||||
export interface ParticipantAPI {
|
export interface ParticipantAPI {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
ics_url: string;
|
ics_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -19,7 +20,7 @@ export interface TimeSlotAPI {
|
|||||||
export interface CreateParticipantRequest {
|
export interface CreateParticipantRequest {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
ics_url: string;
|
ics_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
@@ -76,3 +77,24 @@ export async function syncParticipant(id: string): Promise<void> {
|
|||||||
throw new Error('Failed to sync participant calendar');
|
throw new Error('Failed to sync participant calendar');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function scheduleMeeting(
|
||||||
|
participantIds: string[],
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
startTime: string,
|
||||||
|
endTime: string,
|
||||||
|
): Promise<{ email_sent: boolean; zulip_sent: boolean }> {
|
||||||
|
const response = await fetch(`${API_URL}/api/schedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
participant_ids: participantIds,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return handleResponse(response);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,23 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Check, X, Loader2 } from 'lucide-react';
|
import { Check, X, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
||||||
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
|
const hours = [9, 10, 11, 12, 13, 14, 15, 16, 17];
|
||||||
|
|
||||||
|
// Get the dates for Mon-Fri of the current week
|
||||||
|
const getWeekDates = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const monday = new Date(now);
|
||||||
|
monday.setDate(now.getDate() - now.getDay() + 1);
|
||||||
|
monday.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return dayNames.map((_, i) => {
|
||||||
|
const date = new Date(monday);
|
||||||
|
date.setDate(monday.getDate() + i);
|
||||||
|
return date.toISOString().split('T')[0]; // "YYYY-MM-DD"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface AvailabilityHeatmapProps {
|
interface AvailabilityHeatmapProps {
|
||||||
slots: TimeSlot[];
|
slots: TimeSlot[];
|
||||||
selectedParticipants: Participant[];
|
selectedParticipants: Participant[];
|
||||||
@@ -26,8 +40,10 @@ export const AvailabilityHeatmap = ({
|
|||||||
showPartialAvailability = false,
|
showPartialAvailability = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: AvailabilityHeatmapProps) => {
|
}: AvailabilityHeatmapProps) => {
|
||||||
const getSlot = (day: string, hour: number) => {
|
const weekDates = getWeekDates();
|
||||||
return slots.find((s) => s.day === day && s.hour === hour);
|
|
||||||
|
const getSlot = (dateStr: string, hour: number) => {
|
||||||
|
return slots.find((s) => s.day === dateStr && s.hour === hour);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEffectiveAvailability = (slot: TimeSlot) => {
|
const getEffectiveAvailability = (slot: TimeSlot) => {
|
||||||
@@ -41,6 +57,13 @@ export const AvailabilityHeatmap = ({
|
|||||||
return `${hour.toString().padStart(2, '0')}:00`;
|
return `${hour.toString().padStart(2, '0')}:00`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSlotTooSoon = (dateStr: string, hour: number) => {
|
||||||
|
const slotTime = new Date(`${dateStr}T${formatHour(hour)}:00Z`);
|
||||||
|
const now = new Date();
|
||||||
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
return slotTime < twoHoursFromNow;
|
||||||
|
};
|
||||||
|
|
||||||
const getWeekDateRange = () => {
|
const getWeekDateRange = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monday = new Date(now);
|
const monday = new Date(now);
|
||||||
@@ -87,12 +110,12 @@ export const AvailabilityHeatmap = ({
|
|||||||
<div className="min-w-[600px]">
|
<div className="min-w-[600px]">
|
||||||
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
|
<div className="grid grid-cols-[60px_repeat(5,1fr)] gap-1 mb-2">
|
||||||
<div></div>
|
<div></div>
|
||||||
{days.map((day) => (
|
{dayNames.map((dayName) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={dayName}
|
||||||
className="text-center text-sm font-medium text-muted-foreground py-2"
|
className="text-center text-sm font-medium text-muted-foreground py-2"
|
||||||
>
|
>
|
||||||
{day}
|
{dayName}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -103,18 +126,22 @@ export const AvailabilityHeatmap = ({
|
|||||||
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
|
<div className="text-xs text-muted-foreground flex items-center justify-end pr-3">
|
||||||
{formatHour(hour)}
|
{formatHour(hour)}
|
||||||
</div>
|
</div>
|
||||||
{days.map((day) => {
|
{weekDates.map((dateStr, dayIndex) => {
|
||||||
const slot = getSlot(day, hour);
|
const slot = getSlot(dateStr, hour);
|
||||||
if (!slot) return <div key={`${day}-${hour}`} className="h-12 bg-muted rounded" />;
|
const dayName = dayNames[dayIndex];
|
||||||
|
const tooSoon = isSlotTooSoon(dateStr, hour);
|
||||||
|
if (!slot) return <div key={`${dateStr}-${hour}`} className="h-12 bg-muted rounded" />;
|
||||||
|
|
||||||
const effectiveAvailability = getEffectiveAvailability(slot);
|
const effectiveAvailability = getEffectiveAvailability(slot);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover key={`${day}-${hour}`}>
|
<Popover key={`${dateStr}-${hour}`}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 rounded-md transition-all duration-200 hover:scale-105 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
"h-12 rounded-md transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
tooSoon && "opacity-40 cursor-not-allowed",
|
||||||
|
!tooSoon && "hover:scale-105 hover:shadow-md",
|
||||||
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
|
effectiveAvailability === 'full' && "bg-availability-full hover:bg-availability-full/90",
|
||||||
effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90",
|
effectiveAvailability === 'partial' && "bg-availability-partial hover:bg-availability-partial/90",
|
||||||
effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90"
|
effectiveAvailability === 'none' && "bg-availability-none hover:bg-availability-none/90"
|
||||||
@@ -124,8 +151,13 @@ export const AvailabilityHeatmap = ({
|
|||||||
<PopoverContent className="w-64 p-4 animate-scale-in" align="center">
|
<PopoverContent className="w-64 p-4 animate-scale-in" align="center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="font-semibold text-foreground">
|
<div className="font-semibold text-foreground">
|
||||||
{day} {formatHour(hour)}–{formatHour(hour + 1)}
|
{dayName} {formatHour(hour)}–{formatHour(hour + 1)}
|
||||||
</div>
|
</div>
|
||||||
|
{tooSoon && (
|
||||||
|
<div className="text-sm text-muted-foreground italic">
|
||||||
|
This time slot has passed or is too soon to schedule
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedParticipants.map((participant) => {
|
{selectedParticipants.map((participant) => {
|
||||||
const isAvailable = slot.availableParticipants.includes(participant.name);
|
const isAvailable = slot.availableParticipants.includes(participant.name);
|
||||||
@@ -148,7 +180,7 @@ export const AvailabilityHeatmap = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{effectiveAvailability !== 'none' && (
|
{effectiveAvailability !== 'none' && !tooSoon && (
|
||||||
<Button
|
<Button
|
||||||
variant="schedule"
|
variant="schedule"
|
||||||
className="w-full mt-2"
|
className="w-full mt-2"
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ export const ParticipantManager = ({
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!name.trim() || !email.trim() || !icsLink.trim()) {
|
if (!name.trim() || !email.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: "Missing fields",
|
title: "Missing fields",
|
||||||
description: "Please fill in all fields",
|
description: "Please fill in name and email",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() });
|
onAddParticipant({ name: name.trim(), email: email.trim(), icsLink: icsLink.trim() || '' });
|
||||||
setName('');
|
setName('');
|
||||||
setEmail('');
|
setEmail('');
|
||||||
setIcsLink('');
|
setIcsLink('');
|
||||||
@@ -89,7 +89,7 @@ export const ParticipantManager = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="icsLink">Calendar ICS Link</Label>
|
<Label htmlFor="icsLink">Calendar ICS Link (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="icsLink"
|
id="icsLink"
|
||||||
placeholder="https://calendar.google.com/..."
|
placeholder="https://calendar.google.com/..."
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { TimeSlot, Participant } from '@/types/calendar';
|
import { TimeSlot, Participant } from '@/types/calendar';
|
||||||
|
import { scheduleMeeting } from '@/api/client';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -11,7 +12,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Calendar, Clock, Users, Send } from 'lucide-react';
|
import { Calendar, Clock, Users, Send, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface ScheduleModalProps {
|
interface ScheduleModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -34,7 +35,23 @@ export const ScheduleModal = ({
|
|||||||
return `${hour.toString().padStart(2, '0')}:00`;
|
return `${hour.toString().padStart(2, '0')}:00`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr + 'T00:00:00');
|
||||||
|
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTooSoon = () => {
|
||||||
|
if (!slot) return false;
|
||||||
|
// Use UTC to match backend timezone
|
||||||
|
const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`);
|
||||||
|
const now = new Date();
|
||||||
|
const twoHoursFromNow = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
return startDateTime < twoHoursFromNow;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooSoon = isTooSoon();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast({
|
toast({
|
||||||
title: "Please enter a meeting title",
|
title: "Please enter a meeting title",
|
||||||
@@ -43,19 +60,41 @@ export const ScheduleModal = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!slot) return;
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
// Simulate API call
|
try {
|
||||||
setTimeout(() => {
|
// Calculate start and end times
|
||||||
setIsSubmitting(false);
|
// slot.day is YYYY-MM-DD
|
||||||
|
const startDateTime = new Date(`${slot.day}T${formatHour(slot.hour)}:00Z`);
|
||||||
|
const endDateTime = new Date(`${slot.day}T${formatHour(slot.hour + 1)}:00Z`);
|
||||||
|
|
||||||
|
await scheduleMeeting(
|
||||||
|
participants.map(p => p.id),
|
||||||
|
title,
|
||||||
|
notes,
|
||||||
|
startDateTime.toISOString(),
|
||||||
|
endDateTime.toISOString()
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Meeting scheduled",
|
title: "Meeting scheduled",
|
||||||
description: "Invitations sent to all participants",
|
description: "Invitations sent via Email and Zulip",
|
||||||
});
|
});
|
||||||
|
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
onClose();
|
onClose();
|
||||||
}, 1000);
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Scheduling failed",
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!slot) return null;
|
if (!slot) return null;
|
||||||
@@ -72,7 +111,7 @@ export const ScheduleModal = ({
|
|||||||
<div className="bg-accent/50 rounded-lg p-4 space-y-3">
|
<div className="bg-accent/50 rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Calendar className="w-4 h-4 text-primary" />
|
<Calendar className="w-4 h-4 text-primary" />
|
||||||
<span className="font-medium">{slot.day}</span>
|
<span className="font-medium">{formatDate(slot.day)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3 text-sm">
|
||||||
<Clock className="w-4 h-4 text-primary" />
|
<Clock className="w-4 h-4 text-primary" />
|
||||||
@@ -109,12 +148,20 @@ export const ScheduleModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Lead Time Warning */}
|
||||||
|
{tooSoon && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>Meetings must be scheduled at least 2 hours in advance</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<Button
|
<Button
|
||||||
variant="schedule"
|
variant="schedule"
|
||||||
className="w-full h-12"
|
className="w-full h-12"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || tooSoon}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="animate-pulse">Sending...</span>
|
<span className="animate-pulse">Sending...</span>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ const Index = () => {
|
|||||||
const created = await createParticipant({
|
const created = await createParticipant({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
ics_url: data.icsLink,
|
ics_url: data.icsLink || undefined,
|
||||||
});
|
});
|
||||||
setParticipants((prev) => [...prev, apiToParticipant(created)]);
|
setParticipants((prev) => [...prev, apiToParticipant(created)]);
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
Reference in New Issue
Block a user