Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,17 @@ SMTP_PASSWORD=
# External APIs (for future use)
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=

# Google Calendar OAuth (read-only) - register at https://console.cloud.google.com/
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Microsoft Outlook OAuth (read-only) - register at https://portal.azure.com/
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=

# OAuth callback URL (must match redirect URI registered with OAuth providers)
OAUTH_REDIRECT_URI=http://localhost:8000/api/v1/calendar/callback

# Calendar sync lookahead window in days
CALENDAR_SYNC_LOOKAHEAD_DAYS=30
9 changes: 9 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A scalable, production-ready FastAPI backend for the Stellarts platform - connec
- **FastAPI Framework**: Modern, fast web framework for building APIs
- **Modular Architecture**: Clean separation of concerns with organized directory structure
- **Database Integration**: PostgreSQL with SQLAlchemy ORM
- **Semantic Search**: pgvector-powered artisan matching with OpenAI embeddings
- **Authentication**: JWT-based authentication system
- **API Versioning**: Versioned API endpoints for smooth upgrades
- **Containerized**: Docker and docker-compose for easy deployment
Expand Down Expand Up @@ -196,6 +197,7 @@ docker-compose up -d api-prod db

- `GET /` - Root endpoint with API information
- `GET /api/v1/health` - Health check with database status
- `GET /api/v1/search/semantic` - Semantic artisan search by natural-language query
- `GET /docs` - Interactive API documentation

## Security
Expand All @@ -217,8 +219,15 @@ DATABASE_URL=postgresql://user:pass@host:port/db
SECRET_KEY=secure-random-key
DEBUG=False
BACKEND_CORS_ORIGINS=["https://yourdomain.com"]
OPENAI_API_KEY=your-openai-api-key
SEMANTIC_CACHE_TTL=300
```

### Semantic Search Notes

- The local Docker database uses a pgvector-enabled image (`pgvector/pgvector:pg15`) so vector indexes and similarity operators are available.
- Use `GET /api/v1/search/semantic?q=historic%20restoration` to test natural-language ranking.

### Production Checklist

- [ ] Set strong `SECRET_KEY`
Expand Down
2 changes: 1 addition & 1 deletion backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from app.models.booking import Booking
from app.models.payment import Payment
from app.models.review import Review
from app.models.portfolio import PortfolioItem
from app.models.portfolio import Portfolio

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
Expand Down
91 changes: 91 additions & 0 deletions backend/alembic/versions/002_add_calendar_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Add artisan_calendar_tokens and calendar_events tables

Revision ID: 002_add_calendar_tables
Revises: 001_pgvector_embedding
Create Date: 2026-03-28
"""
from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "002_add_calendar_tables"
down_revision = "001_pgvector_embedding"
branch_labels = None
depends_on = None


def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
existing_tables = inspector.get_table_names()

# ── artisan_calendar_tokens ───────────────────────────────────────────────
if "artisan_calendar_tokens" not in existing_tables:
op.create_table(
"artisan_calendar_tokens",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"artisan_id",
sa.Integer(),
sa.ForeignKey("artisans.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("provider", sa.String(20), nullable=False),
sa.Column("access_token", sa.Text(), nullable=False),
sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("token_expiry", sa.DateTime(timezone=True), nullable=True),
sa.Column("scope", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
onupdate=sa.func.now(),
),
)

# ── calendar_events ───────────────────────────────────────────────────────
if "calendar_events" not in existing_tables:
op.create_table(
"calendar_events",
sa.Column("id", sa.Uuid(), primary_key=True, index=True),
sa.Column(
"artisan_id",
sa.Integer(),
sa.ForeignKey("artisans.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("provider", sa.String(20), nullable=False),
sa.Column("external_id", sa.String(500), nullable=False),
sa.Column("title", sa.String(500), nullable=True),
sa.Column("start_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("end_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("location", sa.Text(), nullable=True),
sa.Column("latitude", sa.String(20), nullable=True),
sa.Column("longitude", sa.String(20), nullable=True),
sa.Column("is_busy", sa.Boolean(), default=True),
sa.Column(
"synced_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)


def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
existing_tables = inspector.get_table_names()

if "calendar_events" in existing_tables:
op.drop_table("calendar_events")

if "artisan_calendar_tokens" in existing_tables:
op.drop_table("artisan_calendar_tokens")
7 changes: 5 additions & 2 deletions backend/app/api/v1/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import APIRouter

from app.api.v1.endpoints import admin, artisan, auth, booking, health, payments, user
from app.api.v1.endpoints import admin, artisan, auth, booking, calendar, health, payments, scheduling, search, stats, user

api_router = APIRouter()

Expand All @@ -12,4 +12,7 @@
api_router.include_router(artisan.router, tags=["artisans"])
api_router.include_router(admin.router, tags=["admin"])
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(stats.router, tags=["stats"])
api_router.include_router(stats.router, tags=["stats"])
api_router.include_router(search.router, tags=["search"])
api_router.include_router(calendar.router, tags=["calendar"])
api_router.include_router(scheduling.router, tags=["scheduling"])
192 changes: 192 additions & 0 deletions backend/app/api/v1/endpoints/calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""
Calendar OAuth API endpoints.

Artisan-only routes for connecting/disconnecting Google Calendar and
Microsoft Outlook (read-only) and listing synced events.
"""
from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session

from app.core.auth import require_artisan, get_current_active_user
from app.core.config import settings
from app.db.session import get_db
from app.models.artisan import Artisan
from app.models.calendar import CalendarEvent
from app.models.user import User
from app.schemas.calendar import (
CalendarConnectRequest,
CalendarConnectResponse,
CalendarEventResponse,
CalendarStatusResponse,
)
from app.services.calendar_oauth import calendar_oauth_service

router = APIRouter(prefix="/calendar")

SUPPORTED_PROVIDERS = {"google", "microsoft"}


def _get_artisan_or_404(db: Session, current_user: User) -> Artisan:
artisan = db.query(Artisan).filter(Artisan.user_id == current_user.id).first()
if not artisan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Artisan profile not found for current user",
)
return artisan


@router.post("/connect", response_model=CalendarConnectResponse)
def connect_calendar(
body: CalendarConnectRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_artisan),
):
"""
Initiate read-only OAuth flow for Google Calendar or Microsoft Outlook.

Returns the provider authorization URL. The client must redirect the user
to this URL to grant calendar read access.
"""
if body.provider not in SUPPORTED_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported provider '{body.provider}'. Choose: {sorted(SUPPORTED_PROVIDERS)}",
)

artisan = _get_artisan_or_404(db, current_user)

try:
if body.provider == "google":
auth_url = calendar_oauth_service.get_google_auth_url(artisan.id)
else:
auth_url = calendar_oauth_service.get_microsoft_auth_url(artisan.id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc

return CalendarConnectResponse(auth_url=auth_url, provider=body.provider)


@router.get("/callback")
def calendar_callback(
code: str = Query(...),
state: str = Query(...),
provider: str = Query(default="google"),
error: str | None = Query(default=None),
db: Session = Depends(get_db),
):
"""
OAuth callback endpoint.

The OAuth provider redirects here after the user grants access.
The ``state`` parameter encodes ``{provider}:{artisan_id}``.
On success, stores the token and redirects to the frontend dashboard.
"""
if error:
redirect_url = f"{settings.FRONTEND_URL}/dashboard?calendar_error={error}"
return RedirectResponse(url=redirect_url)

# state = "provider:artisan_id" or just "artisan_id" (Google flow)
# We accept both formats for robustness.
resolved_provider = provider
artisan_id_str = state
if ":" in state:
parts = state.split(":", 1)
resolved_provider = parts[0]
artisan_id_str = parts[1]

try:
artisan_id = int(artisan_id_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid state parameter") from None

if resolved_provider not in SUPPORTED_PROVIDERS:
raise HTTPException(status_code=400, detail=f"Unknown provider: {resolved_provider}")

try:
if resolved_provider == "google":
calendar_oauth_service.exchange_google_code(code, artisan_id, db)
else:
calendar_oauth_service.exchange_microsoft_code(code, artisan_id, db)
except Exception as exc:
logger_msg = str(exc)
redirect_url = (
f"{settings.FRONTEND_URL}/dashboard"
f"?calendar_error=token_exchange_failed&detail={logger_msg[:80]}"
)
return RedirectResponse(url=redirect_url)

redirect_url = f"{settings.FRONTEND_URL}/dashboard?calendar_connected={resolved_provider}"
return RedirectResponse(url=redirect_url)


@router.delete("/disconnect")
def disconnect_calendar(
provider: str = Query(..., description="Provider to disconnect: 'google' or 'microsoft'"),
db: Session = Depends(get_db),
current_user: User = Depends(require_artisan),
):
"""Revoke and delete the stored calendar token for a provider."""
if provider not in SUPPORTED_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported provider '{provider}'",
)

artisan = _get_artisan_or_404(db, current_user)
deleted = calendar_oauth_service.revoke_token(artisan.id, provider, db)

if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No {provider} calendar connection found",
)

return {"message": f"{provider} calendar disconnected successfully"}


@router.get("/events", response_model=list[CalendarEventResponse])
def list_calendar_events(
db: Session = Depends(get_db),
current_user: User = Depends(require_artisan),
skip: int = 0,
limit: int = 50,
):
"""List synced calendar events for the current artisan (cached locally)."""
artisan = _get_artisan_or_404(db, current_user)

events = (
db.query(CalendarEvent)
.filter(CalendarEvent.artisan_id == artisan.id)
.order_by(CalendarEvent.start_time.asc())
.offset(skip)
.limit(limit)
.all()
)
return events


@router.get("/status", response_model=CalendarStatusResponse)
def calendar_status(
db: Session = Depends(get_db),
current_user: User = Depends(require_artisan),
):
"""Check which calendar providers are connected and local event count."""
artisan = _get_artisan_or_404(db, current_user)
providers = calendar_oauth_service.get_connected_providers(artisan.id, db)
event_count = (
db.query(CalendarEvent)
.filter(CalendarEvent.artisan_id == artisan.id)
.count()
)
return CalendarStatusResponse(
connected=len(providers) > 0,
providers=providers,
event_count=event_count,
)
Loading
Loading