Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
7 changes: 7 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from .v1.auth import router as auth_router
from .v1.health import router as health_router
from .v1.integrations import router as integrations_router

api_router = APIRouter()

Expand All @@ -16,4 +17,10 @@
tags=["Health"]
)

api_router.include_router(
integrations_router,
prefix="/v1/integrations",
tags=["Integrations"]
)

__all__ = ["api_router"]
105 changes: 105 additions & 0 deletions backend/app/api/v1/integrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from fastapi import APIRouter, HTTPException, Depends, status
from uuid import UUID
from app.models.integration import (
IntegrationCreateRequest,
IntegrationUpdateRequest,
IntegrationResponse,
IntegrationListResponse,
IntegrationStatusResponse
)
from app.services.integration_service import integration_service, NotFoundError
from app.core.dependencies import get_current_user

router = APIRouter()


@router.post("/", response_model=IntegrationResponse, status_code=status.HTTP_201_CREATED)
async def create_integration(
request: IntegrationCreateRequest,
user_id: UUID = Depends(get_current_user)
):
"""Create a new organization integration."""
try:
return await integration_service.create_integration(user_id, request)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e


@router.get("/", response_model=IntegrationListResponse)
async def list_integrations(user_id: UUID = Depends(get_current_user)):
"""List all integrations for the current user."""
try:
integrations = await integration_service.get_integrations(user_id)
return IntegrationListResponse(integrations=integrations, total=len(integrations))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e


@router.get("/status/{platform}", response_model=IntegrationStatusResponse)
async def get_integration_status(
platform: str,
user_id: UUID = Depends(get_current_user)
):
"""Get the status of a specific platform integration."""
try:
return await integration_service.get_integration_status(user_id, platform)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e

@router.get("/{integration_id}", response_model=IntegrationResponse)
async def get_integration(
integration_id: UUID,
user_id: UUID = Depends(get_current_user)
):
"""Get a specific integration."""
try:
integration = await integration_service.get_integration(user_id, integration_id)

if not integration:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Integration not found"
)

return integration
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) from e

@router.put("/{integration_id}", response_model=IntegrationResponse)
async def update_integration(
integration_id: UUID,
request: IntegrationUpdateRequest,
user_id: UUID = Depends(get_current_user)
):
"""Update an existing integration."""
try:
return await integration_service.update_integration(user_id, integration_id, request)
except NotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update integration: {str(e)}"
) from e

@router.delete("/{integration_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_integration(
integration_id: UUID,
user_id: UUID = Depends(get_current_user)
):
"""Delete an integration."""
try:
await integration_service.delete_integration(user_id, integration_id)
except NotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete integration: {str(e)}"
) from e
67 changes: 62 additions & 5 deletions backend/app/core/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,69 @@
from fastapi import Request
from fastapi import Header, HTTPException, status, Request
from uuid import UUID
from app.database.supabase.client import get_supabase_client
import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from main import DevRAIApplication

async def get_app_instance(request: Request) -> "DevRAIApplication":
logger = logging.getLogger(__name__)


def get_app_instance(request: Request) -> "DevRAIApplication":
"""Get the application instance from FastAPI app state."""
return request.app.state.app_instance


async def get_current_user(authorization: str = Header(None)) -> UUID:
"""
Dependency to get the application instance from FastAPI's state.
This avoids circular imports by using dependency injection.
Get the current authenticated user from the Supabase JWT token.

Args:
authorization: The Authorization header containing the Bearer token

Returns:
UUID: The user's ID

Raises:
HTTPException: If authentication fails
"""
return request.app.state.app_instance
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization header",
headers={"WWW-Authenticate": "Bearer"},
)

if not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header format. Expected 'Bearer <token>'",
headers={"WWW-Authenticate": "Bearer"},
)

token = authorization.replace("Bearer ", "")

try:
supabase = get_supabase_client()
# Verify the token and get user
user_response = supabase.auth.get_user(token)

if not user_response or not user_response.user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)

return UUID(user_response.user.id)

except HTTPException:
raise
except Exception as e:
logger.exception("Authentication error")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication failed",
headers={"WWW-Authenticate": "Bearer"},
) from e
24 changes: 24 additions & 0 deletions backend/app/models/database/supabase.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,27 @@ class ConversationContext(BaseModel):
session_end_time: Optional[datetime] = None

created_at: datetime = Field(default_factory=datetime.now)


class OrganizationIntegration(BaseModel):
"""
Represents a registered organization (just metadata, no credentials).

Attributes:
id (UUID): Unique identifier for the integration.
user_id (UUID): User/Owner who registered this organization.
platform (str): Platform name (github, discord, slack, discourse).
organization_name (str): Name of the organization.
is_active (bool): Whether the integration is active.
created_at (datetime): Timestamp when registered.
updated_at (datetime): Timestamp when last updated.
config (dict): Platform-specific data (org link, guild_id, etc.).
"""
id: UUID
user_id: UUID
platform: str
organization_name: str
is_active: bool = True
created_at: datetime
updated_at: datetime
config: Optional[dict] = None
49 changes: 49 additions & 0 deletions backend/app/models/integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from pydantic import BaseModel, Field
from typing import Optional, Literal
from datetime import datetime
from uuid import UUID


# Request Models
class IntegrationCreateRequest(BaseModel):
"""Request model for registering an organization."""
platform: Literal["github", "discord", "slack", "discourse"]
organization_name: str
organization_link: Optional[str] = None # GitHub org URL, Discord server ID, etc.
config: Optional[dict] = None # Platform-specific data (discord_guild_id, etc.)


class IntegrationUpdateRequest(BaseModel):
"""Request model for updating an integration."""
organization_name: Optional[str] = None
organization_link: Optional[str] = None
is_active: Optional[bool] = None
config: Optional[dict] = None


# Response Models
class IntegrationResponse(BaseModel):
"""Response model for integration data."""
id: UUID
user_id: UUID
platform: str
organization_name: str
is_active: bool
created_at: datetime
updated_at: datetime
config: Optional[dict] = None
# Note: We never return the actual token in responses


class IntegrationListResponse(BaseModel):
"""Response model for listing integrations."""
integrations: list[IntegrationResponse]
total: int


class IntegrationStatusResponse(BaseModel):
"""Response model for checking integration status."""
platform: str
is_connected: bool
organization_name: Optional[str] = None
last_updated: Optional[datetime] = None
Loading