Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ node_modules/

# macOS
.DS_Store

.env
44 changes: 44 additions & 0 deletions backend/app/alembic/versions/e98732087769_create_a_new_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""create a new table

Revision ID: e98732087769
Revises: c09c4a1bfec5
Create Date: 2025-12-22 16:42:32.197493

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes


# revision identifiers, used by Alembic.
revision = 'e98732087769'
down_revision = 'c09c4a1bfec5'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# Create enum types first (Postgres requires the type to exist before using it)
auth_provider = sa.Enum('google', 'apple', name='authprovider')
auth_provider.create(op.get_bind(), checkfirst=True)

op.create_index(op.f('ix_otp_token_status'), 'otp', ['token_status'], unique=False)
op.add_column('user', sa.Column('provider', auth_provider, nullable=True))
op.add_column('user', sa.Column('provider_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.create_index(op.f('ix_user_provider'), 'user', ['provider'], unique=False)
op.create_index(op.f('ix_user_provider_id'), 'user', ['provider_id'], unique=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_provider_id'), table_name='user')
op.drop_index(op.f('ix_user_provider'), table_name='user')
op.drop_column('user', 'provider_id')
op.drop_column('user', 'provider')
op.drop_index(op.f('ix_otp_token_status'), table_name='otp')
# Drop enum types if they exist
auth_provider = sa.Enum('google', 'apple', name='authprovider')
auth_provider.drop(op.get_bind(), checkfirst=True)
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""add external-account-table

Revision ID: eec7222e7348
Revises: e98732087769
Create Date: 2025-12-22 16:51:35.314511

"""
from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes


# revision identifiers, used by Alembic.
revision = 'eec7222e7348'
down_revision = 'e98732087769'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
14 changes: 14 additions & 0 deletions backend/app/api/controllers/auth_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
LoginSchema,
ResendEmailSchema,
ResetPasswordSchema,
SocialLoginSchema,
VerifySchema,
)
from app.services.auth_service import AuthService
Expand Down Expand Up @@ -161,3 +162,16 @@ async def reset_password(self, request: ResetPasswordSchema) -> JSONResponse:
)
except Exception as exc:
return self._error(exc)

async def social_login(self, request: SocialLoginSchema) -> JSONResponse:
try:
result = await self.service.social_login(
provider=request.provider, access_token=request.access_token
)
return self._success(
data=result,
message=MSG.AUTH["SUCCESS"]["USER_LOGGED_IN"],
status_code=status.HTTP_200_OK,
)
except Exception as exc:
return self._error(exc)
130 changes: 130 additions & 0 deletions backend/app/api/controllers/integrations_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import uuid
from typing import Any

from fastapi.responses import JSONResponse
from starlette import status

from app.core.exceptions import AppException
from app.schemas.external_account import ExternalAccountCreate
from app.schemas.response import ResponseSchema
from app.services.integrations_service import IntegrationService


class IntegrationsController:
def __init__(self) -> None:
self.service = IntegrationService()
self.response_class: type[ResponseSchema[Any]] = ResponseSchema
self.error_class = AppException

def _success(
self,
data: Any = None,
message: str = "OK",
status_code: int = status.HTTP_200_OK,
) -> JSONResponse:
msg = message
data_payload = data

if isinstance(data, dict):
msg = data.get("message") or message
if "user" in data:
data_payload = data.get("user")
elif "data" in data:
data_payload = data.get("data")
if isinstance(data_payload, dict) and "message" in data_payload:
data_payload = {
k: v for k, v in data_payload.items() if k != "message"
}

payload = self.response_class(
success=True,
message=msg,
data=data_payload,
errors=None,
meta=None,
).model_dump(exclude_none=True)

return JSONResponse(status_code=status_code, content=payload)

def _error(
self, message: Any = "Error", errors: Any = None, status_code: int | None = None
) -> JSONResponse:
code = status_code
if isinstance(message, self.error_class):
exc = message
fallback_status = getattr(exc, "status_code", status.HTTP_400_BAD_REQUEST)
if code is None:
if isinstance(fallback_status, int):
code = fallback_status
else:
code = status.HTTP_400_BAD_REQUEST
payload = self.response_class(
success=False,
message=getattr(exc, "message", str(exc)),
errors=getattr(exc, "details", None),
data=None,
).model_dump(exclude_none=True)
return JSONResponse(status_code=int(code), content=payload)

code = code if code is not None else status.HTTP_400_BAD_REQUEST
msg = str(message)

payload = self.response_class(
success=False,
message=msg,
errors=errors,
data=None,
).model_dump(exclude_none=True)

return JSONResponse(status_code=int(code), content=payload)

async def connect_account(
self,
request: ExternalAccountCreate,
user_id: uuid.UUID,
) -> JSONResponse:
try:
account = await self.service.connect_account(
user_id=user_id,
provider=request.provider,
provider_account_id=request.provider_account_id,
access_token=request.access_token,
refresh_token=request.refresh_token,
extra_data=request.extra_data,
)
return self._success(data=account, message="Account connected")
except Exception as e:
return self._error(message=e)

async def get_google_drive_auth_url(
self,
user_id: uuid.UUID,
) -> JSONResponse:
"""Get Google Drive OAuth2 authorization URL"""
try:
auth_data = self.service.get_google_drive_auth_url(user_id=user_id)
return self._success(
data=auth_data,
message="Google Drive authorization URL generated",
)
except Exception as e:
return self._error(message=e)

async def google_drive_callback(
self,
code: str,
user_id: uuid.UUID,
state: str | None = None,
) -> JSONResponse:
"""Handle Google Drive OAuth2 callback"""
try:
account = await self.service.exchange_google_drive_code(
code=code,
user_id=user_id,
)
return self._success(
data=account,
message="Google Drive account connected successfully",
)
except Exception as e:
return self._error(message=e)
25 changes: 24 additions & 1 deletion backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from collections.abc import Generator
from typing import Annotated

from fastapi import Depends
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session

from app.core import security
from app.core.config import settings
from app.core.db import get_engine

Expand All @@ -20,3 +22,24 @@ def get_db() -> Generator[Session, None, None]:

SessionDep = Annotated[Session, Depends(get_db)]
TokenDep = Annotated[str, Depends(reusable_oauth2)]


def get_current_user_id(token: str = Depends(reusable_oauth2)) -> str:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
sub = payload.get("sub")
if not sub:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload"
)
return str(sub)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
)
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
)
3 changes: 2 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from fastapi import APIRouter

from app.api.routes import auth, utils, ws
from app.api.routes import auth, integrations, utils, ws

api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(ws.router)
api_router.include_router(utils.router)
api_router.include_router(integrations.router)
6 changes: 6 additions & 0 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
LoginSchema,
ResendEmailSchema,
ResetPasswordSchema,
SocialLoginSchema,
VerifySchema,
)

Expand Down Expand Up @@ -44,6 +45,11 @@ async def reset_password(request: ResetPasswordSchema) -> JSONResponse:
return await controller.reset_password(request)


@router.post("/social-login")
async def social_login(request: SocialLoginSchema) -> JSONResponse:
return await controller.social_login(request)


@router.post("/logout")
async def logout() -> JSONResponse:
return await controller.logout()
42 changes: 42 additions & 0 deletions backend/app/api/routes/integrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import uuid

from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse

from app.api.controllers.integrations_controller import IntegrationsController
from app.api.deps import get_current_user_id
from app.schemas.external_account import ExternalAccountCreate, callback_request

router = APIRouter(prefix="/integrations", tags=["integrations"])
controller = IntegrationsController()


@router.post(
"/connect",
)
async def connect_account(
request: ExternalAccountCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
) -> JSONResponse:
return await controller.connect_account(request, user_id=user_id)


@router.get(
"/google-drive/auth-url",
)
async def get_google_drive_auth_url(
user_id: uuid.UUID = Depends(get_current_user_id),
) -> JSONResponse:
return await controller.get_google_drive_auth_url(user_id=user_id)


@router.get(
"/google-drive/callback",
)
async def google_drive_callback(
request: callback_request,
user_id: uuid.UUID = Depends(get_current_user_id),
) -> JSONResponse:
return await controller.google_drive_callback(
code=request.code, state=request.state, user_id=user_id
)
5 changes: 5 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,11 @@ def r2_boto3_config(self) -> dict[str, Any]:
WEBENGAGE_CAMPAIGN_REGISTER_ID: str | None = None
WEBENGAGE_CAMPAIGN_FORGOT_PASSWORD_ID: str | None = None

# Google OAuth2 settings for Google Drive integration
GOOGLE_CLIENT_ID: str | None = None
GOOGLE_CLIENT_SECRET: str | None = None
GOOGLE_REDIRECT_URI: str | None = None

def _check_default_secret(self, var_name: str, value: str | None) -> None:
if value == "changethis":
message = (
Expand Down
9 changes: 9 additions & 0 deletions backend/app/enums/external_account_enum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from enum import Enum


class EXTERNAL_ACCOUNT_PROVIDER(str, Enum):
GOOGLE_DRIVE = "google_drive"
CANVAS = "canvas"
CHATGPT = "chatgpt"
ONE_DRIVE = "one_drive"
NOTION = "notion"
5 changes: 5 additions & 0 deletions backend/app/enums/user_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ class UserStatus(str, Enum):
active = "active"
inactive = "inactive"
banned = "banned"


class AuthProvider(str, Enum):
google = "google"
apple = "apple"
Loading
Loading