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 ###
24 changes: 21 additions & 3 deletions backend/app/api/controllers/auth_controller.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any

from fastapi.responses import JSONResponse
from sqlmodel import SQLModel
from starlette import status

from app.core.exceptions import AppException
Expand All @@ -9,6 +10,7 @@
LoginSchema,
ResendEmailSchema,
ResetPasswordSchema,
SocialLoginSchema,
VerifySchema,
)
from app.services.auth_service import AuthService
Expand Down Expand Up @@ -40,14 +42,17 @@ def _success(
data_payload = {
k: v for k, v in data_payload.items() if k != "message"
}
elif isinstance(data, SQLModel):
# Convert SQLModel to dict with proper UUID serialization
data_payload = data.model_dump(mode="json")

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

return JSONResponse(status_code=status_code, content=payload)

Expand All @@ -68,7 +73,7 @@ def _error(
message=getattr(exc, "message", str(exc)),
errors=getattr(exc, "details", None),
data=None,
).model_dump(exclude_none=True)
).model_dump(mode="json", exclude_none=True)
return JSONResponse(status_code=int(code), content=payload)

code = code if code is not None else status.HTTP_400_BAD_REQUEST
Expand All @@ -79,7 +84,7 @@ def _error(
message=msg,
errors=errors,
data=None,
).model_dump(exclude_none=True)
).model_dump(mode="json", exclude_none=True)

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

Expand Down Expand Up @@ -161,3 +166,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)
218 changes: 218 additions & 0 deletions backend/app/api/controllers/integrations_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import uuid
from typing import Any

from fastapi.responses import JSONResponse, Response
from sqlmodel import SQLModel
from starlette import status

from app.core.exceptions import AppException
from app.schemas.external_account import GoogleDriveTokenResponse
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"
}
elif isinstance(data, SQLModel):
# Convert SQLModel to dict with proper UUID serialization
data_payload = data.model_dump(mode="json")

payload = self.response_class(
success=True,
message=msg,
data=data_payload,
errors=None,
meta=None,
).model_dump(mode="json", 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(mode="json", 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(mode="json", exclude_none=True)

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

async def connect_google_drive_with_tokens(
self,
token_response: GoogleDriveTokenResponse,
user_id: uuid.UUID,
) -> JSONResponse:
"""Connect Google Drive account using token response directly"""
try:
account = await self.service.connect_google_drive_with_tokens(
access_token=token_response.access_token,
refresh_token=token_response.refresh_token,
expires_in=token_response.expires_in,
scope=token_response.scope,
user_id=user_id,
)
return self._success(
data=account,
message="Google Drive account connected successfully with provided tokens",
)
except Exception as e:
return self._error(message=e)

async def upload_file_to_google_drive(
self,
user_id: uuid.UUID,
file_name: str,
file_content: bytes,
mime_type: str,
parent_folder_id: str | None = None,
) -> JSONResponse:
"""Upload a file to Google Drive"""
try:
result = await self.service.upload_file_to_google_drive(
user_id=user_id,
file_name=file_name,
file_content=file_content,
mime_type=mime_type,
parent_folder_id=parent_folder_id,
)
return self._success(
data=result,
message="File uploaded to Google Drive successfully",
)
except Exception as e:
return self._error(message=e)

async def list_google_drive_files(
self,
user_id: uuid.UUID,
page_size: int = 100,
page_token: str | None = None,
query: str | None = None,
) -> JSONResponse:
"""List all files in Google Drive"""
try:
result = await self.service.list_google_drive_files(
user_id=user_id,
page_size=page_size,
page_token=page_token,
query=query,
)
return self._success(
data=result,
message="Files retrieved successfully",
)
except Exception as e:
return self._error(message=e)

async def read_google_drive_file(
self,
user_id: uuid.UUID,
file_id: str,
) -> JSONResponse:
"""Read file content from Google Drive"""
try:
result = await self.service.read_google_drive_file(
user_id=user_id,
file_id=file_id,
)
return self._success(
data=result,
message="File read successfully",
)
except Exception as e:
return self._error(message=e)

async def update_google_drive_file(
self,
user_id: uuid.UUID,
file_id: str,
file_content: bytes | None = None,
file_name: str | None = None,
mime_type: str | None = None,
) -> JSONResponse:
"""Update file content and/or metadata in Google Drive"""
try:
result = await self.service.update_google_drive_file(
user_id=user_id,
file_id=file_id,
file_content=file_content,
file_name=file_name,
mime_type=mime_type,
)
return self._success(
data=result,
message="File updated successfully",
)
except Exception as e:
return self._error(message=e)

async def download_google_drive_file(
self,
user_id: uuid.UUID,
file_id: str,
) -> Response | JSONResponse:
"""Download file content from Google Drive as a streaming response"""
try:
(
content,
content_type,
metadata,
) = await self.service.download_google_drive_file(
user_id=user_id,
file_id=file_id,
)
filename = metadata.get("name", "file")
return Response(
content=content,
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
except Exception as e:
return self._error(message=e)
Loading
Loading