diff --git a/.env.example b/.env.example index 1cb310a..ebefb9f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,21 @@ S3_BUCKET=documents # Get your API key from: https://makersuite.google.com/app/apikey GEMINI_API_KEY=your_gemini_api_key +# Authentication Configuration +# IMPORTANT: Generate a secure JWT secret with: openssl rand -hex 64 +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-minimum-64-characters +JWT_EXPIRATION_HOURS=168 + +# Auth Service Configuration +AUTH_SERVICE_URL=http://localhost:8003 +# CORS allowed origins (comma-separated) +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://yourdomain.com + +# Redis Configuration (for rate limiting and session storage) +# Optional: If not set, will use in-memory fallback +REDIS_URL=redis://localhost:6379 +# REDIS_URL=redis://username:password@localhost:6379/0 + # Document Parsing Configuration # Select parser implementations used at build and runtime # Options: complete-pdf-parser-1 | fast-pdf-parser-1 diff --git a/ai-agent-service/main.py b/ai-agent-service/main.py index 7d24dae..e954dde 100644 --- a/ai-agent-service/main.py +++ b/ai-agent-service/main.py @@ -48,9 +48,13 @@ class WorkspaceInfo(BaseModel): # --- App and Clients Setup --- app = FastAPI(title="AI Customer Service Agent") +# Get CORS origins from environment variable, with fallback to localhost +cors_origins_env = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost,http://localhost:8080") +origins = [origin.strip() for origin in cors_origins_env.split(",") if origin.strip()] + app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost", "http://localhost:8080"], + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/auth-service/Dockerfile b/backend/auth-service/Dockerfile new file mode 100644 index 0000000..ab9ebab --- /dev/null +++ b/backend/auth-service/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/backend/auth-service/auth_endpoints.py b/backend/auth-service/auth_endpoints.py new file mode 100644 index 0000000..a1a4f90 --- /dev/null +++ b/backend/auth-service/auth_endpoints.py @@ -0,0 +1,326 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Header +from sqlalchemy.orm import Session +from typing import Optional, List +from datetime import datetime, timezone +from uuid import UUID +from backend.db.database import get_db,create_tables +from auth_service import ( + AuthService, + UserLogin, + UserRegister, + LoginResponse, + UserResponse, +) + +from auth_models import User, Workspace, File + + +# --------------------------------------- +# Routers +# --------------------------------------- +auth_router = APIRouter(prefix="/auth", tags=["authentication"]) +users_router = APIRouter(prefix="/users", tags=["users"]) +files_router = APIRouter(prefix="/files", tags=["files"]) + +# Optionally initialize DB tables (prefer migrations in production) TODO: check this after +create_tables + +# --------------------------------------- +# Helpers / Dependencies +# --------------------------------------- + +def get_current_user_dep( + authorization: Optional[str] = Header(None), + db: Session = Depends(get_db), +) -> User: + """Extract current user from Authorization: Bearer .""" + if not authorization: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization header required", + ) + try: + token = authorization.replace("Bearer ", "") + user = AuthService.get_current_user(db, token) + return user + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + +# --------------------------------------- +# Pydantic schemas for new endpoints +# --------------------------------------- +from pydantic import BaseModel, Field + +class UserUpdateRequest(BaseModel): + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + company_name: Optional[str] = None + team_size: Optional[str] = None + +class PasswordChangeRequest(BaseModel): + current_password: str = Field(..., min_length=1) + new_password: str = Field(..., min_length=8) + +class WorkspaceOut(BaseModel): + id: int + name: str + created_at: datetime + +class FileOut(BaseModel): + id: str + name: str + s3_key: Optional[str] = None + workspace_id: int + status: Optional[str] = None + created_at: datetime + workspace: Optional[WorkspaceOut] = None + +class FileStatusOut(BaseModel): + id: str + name: str + status: Optional[str] = None + created_at: datetime + +# --------------------------------------- +# AUTH ENDPOINTS +# --------------------------------------- + +@auth_router.post("/login", response_model=LoginResponse) +async def login( + login_data: UserLogin, + db: Session = Depends(get_db) +): + """Authenticate user and return JWT token""" + try: + return AuthService.login_user(db, login_data) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during login", + ) + +@auth_router.post("/register", response_model=LoginResponse) +async def register( + register_data: UserRegister, + db: Session = Depends(get_db) +): + """Register new user and return JWT token""" + try: + return AuthService.register_user(db, register_data) + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error during registration", + ) + +@auth_router.get("/me", response_model=UserResponse) +async def get_current_user( + current_user: User = Depends(get_current_user_dep), +): + """Get current authenticated user""" + # Convert ORM user to the existing pydantic response model + return UserResponse.model_validate(current_user) + +@auth_router.post("/logout") +async def logout(): + """Logout user (client-side token removal)""" + return {"message": "Logout successful"} + +@auth_router.post("/refresh") +async def refresh_token( + current_user: User = Depends(get_current_user_dep), +): + """Refresh JWT token""" + try: + token_data = { + "id": current_user.id, + "email": current_user.email, + "username": current_user.username, + } + new_token = AuthService.create_jwt_token(token_data) + return {"token": new_token, "message": "Token refreshed successfully"} + except HTTPException: + raise + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Internal server error", + ) + +@auth_router.get("/health") +async def auth_health_check(): + """Health check for authentication service""" + return {"status": "healthy", "service": "authentication", "message": "Auth service is running"} + +# --------------------------------------- +# USERS ENDPOINTS +# --------------------------------------- +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +@users_router.put("/{user_id}", response_model=UserResponse) +async def update_user_profile( + user_id: int, + user_update: UserUpdateRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """Update user profile information (self only).""" + if current_user.id != user_id: + raise HTTPException(status_code=403, detail="Forbidden") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Uniqueness checks + if user_update.email and user_update.email != user.email: + exists = db.query(User).filter(User.email == user_update.email).first() + if exists: + raise HTTPException(status_code=400, detail="Email already registered") + + if user_update.username and user_update.username != user.username: + exists = db.query(User).filter(User.username == user_update.username).first() + if exists: + raise HTTPException(status_code=400, detail="Username already taken") + + # Apply updates + update_data = user_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if value is not None: + setattr(user, field, value) + + user.updated_at = datetime.now(timezone.utc) + + try: + db.commit() + db.refresh(user) + return UserResponse.model_validate(user) + except Exception: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update user profile") + +@users_router.put("/{user_id}/change-password") +async def change_user_password( + user_id: int, + password_change: PasswordChangeRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """Change user password (self only).""" + if current_user.id != user_id: + raise HTTPException(status_code=403, detail="Forbidden") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Verify current password + if not pwd_context.verify(password_change.current_password, user.password_hash): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + # Validate and hash new password + new_hash = pwd_context.hash(password_change.new_password) + user.password_hash = new_hash + user.updated_at = datetime.now(timezone.utc) + + try: + db.commit() + return {"message": "Password changed successfully"} + except Exception: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to change password") + +# --------------------------------------- +# FILES ENDPOINTS +# --------------------------------------- + +@files_router.get("/", response_model=List[FileOut]) +async def get_user_files( + user_id: int, + workspace_id: Optional[int] = None, + limit: int = 50, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """Get files for a user (self only), optionally filtered by workspace.""" + if current_user.id != user_id: + raise HTTPException(status_code=403, detail="Forbidden") + + # Get user's workspaces + user_workspaces = db.query(Workspace).filter(Workspace.owner_id == user_id).all() + workspace_ids = [ws.id for ws in user_workspaces] + + if not workspace_ids: + return [] + + # Base query + query = db.query(File).filter(File.workspace_id.in_(workspace_ids)) + + # Optional filter + if workspace_id: + if workspace_id not in workspace_ids: + raise HTTPException(status_code=404, detail="Workspace not found") + query = query.filter(File.workspace_id == workspace_id) + + files = query.order_by(File.created_at.desc()).limit(limit).all() + + # Build response + ws_map = {ws.id: ws for ws in user_workspaces} + result: List[FileOut] = [] + for f in files: + ws = ws_map.get(f.workspace_id) + result.append( + FileOut( + id=str(f.id), + name=f.name, + s3_key=getattr(f, "s3_key", None), + workspace_id=f.workspace_id, + status=getattr(f, "status", None), + created_at=f.created_at, + workspace=( + WorkspaceOut(id=ws.id, name=ws.name, created_at=ws.created_at) if ws else None + ), + ) + ) + + return result + +@files_router.get("/{file_id}/status", response_model=FileStatusOut) +async def get_file_status( + file_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_dep), +): + """Get the processing status of a specific file owned by the current user.""" + # Parse UUID + try: + file_uuid = UUID(file_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid file ID format") + + # Load file + file = db.query(File).filter(File.id == file_uuid).first() + if not file: + raise HTTPException(status_code=404, detail="File not found") + + # Ownership check via workspace owner + ws = db.query(Workspace).filter(Workspace.id == file.workspace_id).first() + if not ws or ws.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Forbidden") + + return FileStatusOut( + id=str(file.id), name=file.name, status=getattr(file, "status", None), created_at=file.created_at + ) + diff --git a/ingestion-service/database.py b/backend/auth-service/auth_models.py similarity index 54% rename from ingestion-service/database.py rename to backend/auth-service/auth_models.py index 8195c70..89fd1f3 100644 --- a/ingestion-service/database.py +++ b/backend/auth-service/auth_models.py @@ -1,13 +1,18 @@ import os - +from datetime import datetime from sqlalchemy import ( Column, ForeignKey, Integer, SmallInteger, String, + DateTime, + Boolean, + Text, create_engine, + text, ) +from sqlalchemy.exc import OperationalError, DisconnectionError from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, sessionmaker from sqlalchemy.dialects.postgresql import UUID @@ -17,6 +22,7 @@ # Only create engine if DATABASE_URL is provided if DATABASE_URL: + # Use the improved pooling configuration from remote (already optimized) engine = create_engine( DATABASE_URL, pool_pre_ping=True, @@ -26,35 +32,74 @@ pool_recycle=int(os.getenv("DB_POOL_RECYCLE", "1800")), future=True, ) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + # Create session factory with retry logic (your enhancement) + SessionLocal = sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, + expire_on_commit=False # Prevent expired object access issues + ) else: engine = None SessionLocal = None Base = declarative_base() - - class User(Base): __tablename__ = "users" + id = Column(Integer, primary_key=True, index=True) username = Column(String, unique=True, index=True) + email = Column(String, unique=True, index=True) + password_hash = Column(String, nullable=False) + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + company_name = Column(String, nullable=True) + team_size = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships workspaces = relationship("Workspace", back_populates="owner") - class Workspace(Base): __tablename__ = "workspaces" + id = Column(Integer, primary_key=True, index=True) name = Column(String, index=True) owner_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships owner = relationship("User", back_populates="workspaces") files = relationship("File", back_populates="workspace") - class File(Base): __tablename__ = "files" + id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4) name = Column(String, index=True) s3_key = Column(String, unique=True) workspace_id = Column(Integer, ForeignKey("workspaces.id")) - workspace = relationship("Workspace", back_populates="files") status = Column(SmallInteger, nullable=False, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + workspace = relationship("Workspace", back_populates="files") + +# Database dependency +def get_db(): + if SessionLocal is None: + raise Exception("Database not configured") + db = SessionLocal() + try: + yield db + finally: + db.close() + +# Create tables +def create_tables(): + if engine: + Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/auth-service/auth_service.py b/backend/auth-service/auth_service.py new file mode 100644 index 0000000..d83f172 --- /dev/null +++ b/backend/auth-service/auth_service.py @@ -0,0 +1,296 @@ +import os +import jwt +import bcrypt +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +from sqlalchemy.orm import Session +from fastapi import HTTPException, status +from pydantic import BaseModel, EmailStr +from backend.db.database import User, get_db + +# Configuration +JWT_SECRET = os.getenv("JWT_SECRET") +if not JWT_SECRET: + raise ValueError("JWT_SECRET environment variable is required and not set") +if len(JWT_SECRET) < 64: + raise ValueError("JWT_SECRET must be at least 64 characters long for security") + +JWT_ALGORITHM = "HS256" +JWT_EXPIRATION_HOURS = int(os.getenv("JWT_EXPIRATION_HOURS", 168)) # 7 days default + +# Pydantic models for request/response +class UserLogin(BaseModel): + email: EmailStr + password: str + +class UserRegister(BaseModel): + email: EmailStr + password: str + first_name: str + last_name: str + username: Optional[str] = None + company_name: Optional[str] = None + team_size: Optional[str] = None + +class UserResponse(BaseModel): + id: int + email: str + username: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + company_name: Optional[str] + team_size: Optional[str] + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +class LoginResponse(BaseModel): + token: str + user: UserResponse + message: str + +class AuthService: + @staticmethod + def hash_password(password: str) -> str: + """Hash a password using bcrypt""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed.decode('utf-8') + + @staticmethod + def verify_password(password: str, hashed_password: str) -> bool: + """Verify a password against its hash""" + return bcrypt.checkpw( + password.encode('utf-8'), + hashed_password.encode('utf-8') + ) + + @staticmethod + def create_jwt_token(user_data: Dict[str, Any]) -> str: + """Create a JWT token for the user""" + payload = { + "user_id": user_data["id"], + "email": user_data["email"], + "username": user_data["username"], + "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS), + "iat": datetime.utcnow(), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + @staticmethod + def verify_jwt_token(token: str) -> Dict[str, Any]: + """Verify and decode a JWT token""" + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired" + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + + @staticmethod + def get_user_by_email(db: Session, email: str) -> Optional[User]: + """Get user by email""" + return db.query(User).filter(User.email == email).first() + + @staticmethod + def get_user_by_username(db: Session, username: str) -> Optional[User]: + """Get user by username""" + return db.query(User).filter(User.username == username).first() + + @staticmethod + def validate_team_size(team_size: str) -> bool: + """Validate team size options""" + valid_sizes = ["1-10", "11-50", "51-200", "201-1000", "+1000"] + return team_size in valid_sizes + + @staticmethod + def create_user(db: Session, user_data: UserRegister) -> User: + """Create a new user""" + # Check if user already exists + existing_user = AuthService.get_user_by_email(db, user_data.email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User with this email already exists" + ) + + # Validate team size if provided + if user_data.team_size and not AuthService.validate_team_size(user_data.team_size): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid team size option" + ) + + # Generate username if not provided + username = user_data.username + if not username: + username = user_data.email.split('@')[0] + # Ensure username is unique + counter = 1 + base_username = username + while AuthService.get_user_by_username(db, username): + username = f"{base_username}{counter}" + counter += 1 + else: + # Check if provided username is already taken + existing_username = AuthService.get_user_by_username(db, username) + if existing_username: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Username already exists" + ) + + # Create new user + hashed_password = AuthService.hash_password(user_data.password) + db_user = User( + email=user_data.email, + username=username, + password_hash=hashed_password, + first_name=user_data.first_name, + last_name=user_data.last_name, + company_name=user_data.company_name, + team_size=user_data.team_size, + is_active=True, + is_verified=False # In production, require email verification + ) + + try: + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + except Exception as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create user" + ) + + @staticmethod + def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: + """Authenticate user with email and password""" + user = AuthService.get_user_by_email(db, email) + if not user: + return None + + if not AuthService.verify_password(password, user.password_hash): + return None + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated" + ) + + return user + + @staticmethod + def login_user(db: Session, login_data: UserLogin) -> LoginResponse: + """Login user and return token""" + user = AuthService.authenticate_user(db, login_data.email, login_data.password) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password" + ) + + # Create JWT token + token_data = { + "id": user.id, + "email": user.email, + "username": user.username + } + token = AuthService.create_jwt_token(token_data) + + # Return response + user_response = UserResponse.model_validate(user) + return LoginResponse( + token=token, + user=user_response, + message="Login successful" + ) + + @staticmethod + def register_user(db: Session, register_data: UserRegister) -> LoginResponse: + """Register new user and return token""" + # Validate password strength + if len(register_data.password) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters long" + ) + + # Additional password validation + if not any(c.isupper() for c in register_data.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must contain at least one uppercase letter" + ) + + if not any(c.islower() for c in register_data.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must contain at least one lowercase letter" + ) + + if not any(c.isdigit() for c in register_data.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must contain at least one number" + ) + + # Create user + user = AuthService.create_user(db, register_data) + + # Create JWT token + token_data = { + "id": user.id, + "email": user.email, + "username": user.username + } + token = AuthService.create_jwt_token(token_data) + + # Return response + user_response = UserResponse.model_validate(user) + return LoginResponse( + token=token, + user=user_response, + message="Registration successful" + ) + + @staticmethod + def get_current_user(db: Session, token: str) -> User: + """Get current user from JWT token""" + payload = AuthService.verify_jwt_token(token) + user_id = payload.get("user_id") + + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload" + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated" + ) + + return user \ No newline at end of file diff --git a/backend/auth-service/main.py b/backend/auth-service/main.py new file mode 100644 index 0000000..8c40c23 --- /dev/null +++ b/backend/auth-service/main.py @@ -0,0 +1,64 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from auth_endpoints import auth_router, users_router, files_router +import sys +sys.path.insert(0, '/app/backend') +from backend.db.database import init_database +import os + +# Create FastAPI app +app = FastAPI( + title="QuickResolve Backend", + description="Backend API for QuickResolve authentication and file management", + version="1.0.0" +) + +# CORS Configuration +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:3001").split(",") + +# Add CORS middleware with secure configuration +app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], # Specific methods only + allow_headers=[ + "Authorization", + "Content-Type", + "X-CSRF-Token", + "X-Requested-With", + "Accept" + ], # Specific headers only + expose_headers=["X-CSRF-Token"], # Headers that frontend can access +) + +@app.on_event("startup") +async def startup_event(): + """Initialize database - this is the single source of truth for database creation""" + init_database() + +# Include all routes +app.include_router(auth_router) +app.include_router(users_router) +app.include_router(files_router) + +# Root endpoint +@app.get("/") +async def root(): + return { + "message": "QuickResolve Backend API", + "version": "1.0.0", + "status": "running" + } + +# Health check +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "message": "Backend service is running" + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/backend/auth-service/requirements.txt b/backend/auth-service/requirements.txt new file mode 100644 index 0000000..7a593b4 --- /dev/null +++ b/backend/auth-service/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +bcrypt==4.1.2 +pyjwt==2.8.0 +pydantic[email]==2.5.0 +python-multipart==0.0.18 +python-dotenv==1.0.0 +passlib[bcrypt] \ No newline at end of file diff --git a/backend/db/__init__.py b/backend/db/__init__.py new file mode 100644 index 0000000..8cc3bc6 --- /dev/null +++ b/backend/db/__init__.py @@ -0,0 +1 @@ +# Database package \ No newline at end of file diff --git a/backend/db/database.py b/backend/db/database.py new file mode 100644 index 0000000..0d8011d --- /dev/null +++ b/backend/db/database.py @@ -0,0 +1,165 @@ +""" +Database configuration and initialization - Single file with all models and functions +""" +import os +from datetime import datetime +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + SmallInteger, + String, + DateTime, + Boolean, + Text, + create_engine, +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, sessionmaker +from sqlalchemy.dialects.postgresql import UUID +from uuid import uuid4 + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db") + +# Create database engine and session factory +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + pool_size=int(os.getenv("DB_POOL_SIZE", "10")), + max_overflow=int(os.getenv("DB_POOL_MAX_OVERFLOW", "20")), + pool_timeout=int(os.getenv("DB_POOL_TIMEOUT", "60")), + pool_recycle=int(os.getenv("DB_POOL_RECYCLE", "1800")), + future=True, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# Database Models +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True) + email = Column(String, unique=True, index=True) + password_hash = Column(String, nullable=False) + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + company_name = Column(String, nullable=True) + team_size = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + workspaces = relationship("Workspace", back_populates="owner") + +class Workspace(Base): + __tablename__ = "workspaces" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + owner_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + owner = relationship("User", back_populates="workspaces") + files = relationship("File", back_populates="workspace") + +class File(Base): + __tablename__ = "files" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4) + name = Column(String, index=True) + s3_key = Column(String, unique=True) + workspace_id = Column(Integer, ForeignKey("workspaces.id")) + status = Column(SmallInteger, nullable=False, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + workspace = relationship("Workspace", back_populates="files") + +# Database dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def init_database(): + """ + Initialize the database with all required tables. + This function should only be called from the auth service. + """ + print("=== INITIALIZING DATABASE FROM AUTH SERVICE ===") + + # Retry logic for database connection + import time + max_retries = 30 + retry_interval = 2 + + for attempt in range(max_retries): + try: + print(f"Database connection attempt {attempt + 1}/{max_retries}") + + # Test the connection first + from sqlalchemy import text + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + print("Database connection successful") + + # Safe table creation - only create if they don't exist + Base.metadata.create_all(bind=engine, checkfirst=True) + print("Database tables created/verified successfully") + + # Verify the schema + from sqlalchemy import inspect + inspector = inspect(engine) + tables = inspector.get_table_names() + print(f"Available tables: {tables}") + + if 'users' in tables: + columns = [col['name'] for col in inspector.get_columns('users')] + print(f"Users table columns: {columns}") + + if 'workspaces' in tables: + columns = [col['name'] for col in inspector.get_columns('workspaces')] + print(f"Workspaces table columns: {columns}") + + if 'files' in tables: + columns = [col['name'] for col in inspector.get_columns('files')] + print(f"Files table columns: {columns}") + + print("Database initialization completed successfully from auth service") + break + + except Exception as e: + print(f"Database connection attempt {attempt + 1} failed: {e}") + + if attempt < max_retries - 1: + print(f"Retrying in {retry_interval} seconds...") + time.sleep(retry_interval) + else: + print("Max retries exceeded. Database initialization failed.") + raise RuntimeError(f"Failed to initialize database after {max_retries} attempts: {e}") + + # Add migration reminder + print("Note: For production deployments, use proper database migrations instead of auto-creation") + print("=== DATABASE INITIALIZATION COMPLETE ===") + +def create_tables(): + """ + Create database tables. For backward compatibility with services that expect this function. + """ + print("Creating database tables...") + try: + Base.metadata.create_all(bind=engine, checkfirst=True) + print("Database tables created/updated successfully") + except Exception as e: + print(f"Error creating tables: {e}") + print("Continuing with existing database schema...") + +# Make sure all imports are available for other services +__all__ = ['Base', 'User', 'Workspace', 'File', 'get_db', 'engine', 'SessionLocal', 'init_database', 'create_tables'] \ No newline at end of file diff --git a/backend/db/models.py b/backend/db/models.py new file mode 100644 index 0000000..8bc20cd --- /dev/null +++ b/backend/db/models.py @@ -0,0 +1,90 @@ +""" +Database models only - no dependencies on auth-specific packages. +This file can be safely imported by other services. +""" +import os +from datetime import datetime +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + SmallInteger, + String, + DateTime, + Boolean, + Text, + create_engine, +) +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship, sessionmaker +from sqlalchemy.dialects.postgresql import UUID +from uuid import uuid4 + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./test.db") + +# Create database engine and session factory +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + pool_size=int(os.getenv("DB_POOL_SIZE", "10")), + max_overflow=int(os.getenv("DB_POOL_MAX_OVERFLOW", "20")), + pool_timeout=int(os.getenv("DB_POOL_TIMEOUT", "60")), + pool_recycle=int(os.getenv("DB_POOL_RECYCLE", "1800")), + future=True, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# Database Models +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, unique=True, index=True) + email = Column(String, unique=True, index=True) + password_hash = Column(String, nullable=False) + first_name = Column(String, nullable=True) + last_name = Column(String, nullable=True) + company_name = Column(String, nullable=True) + team_size = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_verified = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + workspaces = relationship("Workspace", back_populates="owner") + +class Workspace(Base): + __tablename__ = "workspaces" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + owner_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + owner = relationship("User", back_populates="workspaces") + files = relationship("File", back_populates="workspace") + +class File(Base): + __tablename__ = "files" + + id = Column(UUID(as_uuid=True), primary_key=True, index=True, default=uuid4) + name = Column(String, index=True) + s3_key = Column(String, unique=True) + workspace_id = Column(Integer, ForeignKey("workspaces.id")) + status = Column(SmallInteger, nullable=False, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + workspace = relationship("Workspace", back_populates="files") + +# Database dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/chunking-service/main.py b/chunking-service/main.py index 07ddf04..45a9258 100644 --- a/chunking-service/main.py +++ b/chunking-service/main.py @@ -23,9 +23,13 @@ title="Chunking Service", description="Chunking with single strategy" ) +# Get CORS origins from environment variable, with fallback to localhost +cors_origins_env = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost,http://localhost:8080") +origins = [origin.strip() for origin in cors_origins_env.split(",") if origin.strip()] + app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost", "http://localhost:8080"], + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/docker-compose.yml b/docker-compose.yml index d6e87ad..f43b7b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,25 @@ services: volumes: - postgres_data:/var/lib/postgresql/data restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory-policy allkeys-lru + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 minio: image: minio/minio:latest @@ -58,24 +77,62 @@ services: command: server /data --console-address ":9001" restart: unless-stopped - frontend: - build: ./frontend + auth-service: + build: ./backend/auth-service ports: - - "8080:80" + - "8003:8000" + volumes: + - ./backend:/app/backend + environment: + DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}" + JWT_SECRET: "${JWT_SECRET}" + JWT_ALGORITHM: "HS256" + JWT_EXPIRATION_HOURS: "168" # 7 days + ALLOWED_ORIGINS: "${ALLOWED_ORIGINS}" + LOG_LEVEL: "INFO" + FORCE_SCHEMA_UPDATE: "false" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped - + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + landing-next: build: ./landing-next ports: - - "8090:3000" + - "3000:3000" # Changed from 8090 to 3000 for consistency environment: - NODE_ENV=production + - AUTH_SERVICE_URL=http://auth-service:8000 + - INGESTION_SERVICE_URL=http://ingestion-service:8000 + - AI_AGENT_SERVICE_URL=http://ai-agent-service:8002 + - EMBEDDING_SERVICE_URL=http://embedding-service:8001 + - DOCUMENT_PARSING_SERVICE_URL=http://document-parsing-service:8005 + - CHUNKING_SERVICE_URL=http://chunking-service:8006 + - JWT_SECRET=${JWT_SECRET} + - NEXTAUTH_URL=http://localhost:3000 + - NEXTAUTH_SECRET=${JWT_SECRET} + - REDIS_URL=redis://redis:6379 + - ALLOWED_ORIGINS=${ALLOWED_ORIGINS} + depends_on: + - auth-service + - ingestion-service + - ai-agent-service + - redis restart: unless-stopped ingestion-service: build: ./ingestion-service ports: - "8000:8000" + volumes: + - ./backend:/app/backend environment: DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}" S3_ENDPOINT: "http://minio:9000" @@ -83,8 +140,17 @@ services: S3_SECRET_KEY: "${MINIO_ROOT_PASSWORD}" S3_BUCKET: "${S3_BUCKET}" TASK_SERVICE_URL: "http://task-service:8010" + depends_on: + db: + condition: service_healthy + auth-service: + condition: service_healthy restart: unless-stopped - + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 embedding-service: build: ./embedding-service ports: @@ -108,8 +174,6 @@ services: GEMINI_API_KEY: ${GEMINI_API_KEY} restart: unless-stopped - - document-parsing-service: build: context: ./document-parsing-service @@ -128,8 +192,10 @@ services: DOCX_PARSER_VERSION: "${DOCX_PARSER_VERSION:-fast-docx-parser-1}" TASK_SERVICE_URL: "http://task-service:8010" depends_on: - - db - - minio + db: + condition: service_healthy + minio: + condition: service_started restart: unless-stopped chunking-service: @@ -192,4 +258,5 @@ services: volumes: postgres_data: + redis_data: qdrant_storage: \ No newline at end of file diff --git a/document-parsing-service/main.py b/document-parsing-service/main.py index 561cbea..beacc5b 100644 --- a/document-parsing-service/main.py +++ b/document-parsing-service/main.py @@ -22,10 +22,14 @@ uploads to S3, and forwards to the redaction service (then chunking).""", ) +# Get CORS origins from environment variable, with fallback to localhost +cors_origins_env = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost,http://localhost:8080") +origins = [origin.strip() for origin in cors_origins_env.split(",") if origin.strip()] + # CORS app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost", "http://localhost:8080"], + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/embedding-service/main.py b/embedding-service/main.py index 6ff4a51..aa434e5 100644 --- a/embedding-service/main.py +++ b/embedding-service/main.py @@ -37,9 +37,13 @@ class EmbedChunkRequest(BaseModel): # --- App and Clients Setup --- app = FastAPI() +# Get CORS origins from environment variable, with fallback to localhost +cors_origins_env = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost,http://localhost:8080") +origins = [origin.strip() for origin in cors_origins_env.split(",") if origin.strip()] + app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost", "http://localhost:8080"], + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index a957fbb..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM nginx:latest -COPY . /usr/share/nginx/html -COPY nginx.conf /etc/nginx/nginx.conf \ No newline at end of file diff --git a/frontend/chat-style.css b/frontend/chat-style.css deleted file mode 100644 index bc20a97..0000000 --- a/frontend/chat-style.css +++ /dev/null @@ -1,500 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - height: 100vh; - overflow: hidden; -} - -.container { - display: grid; - grid-template-columns: 1fr 300px; - grid-template-rows: auto 1fr; - height: 100vh; - max-width: 1400px; - margin: 0 auto; - background: white; - box-shadow: 0 0 50px rgba(0, 0, 0, 0.1); -} - -/* Header */ -.chat-header { - grid-column: 1 / -1; - background: white; - border-bottom: 1px solid #e1e5e9; - padding: 1rem 2rem; - display: flex; - justify-content: space-between; - align-items: center; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); -} - -.chat-header h1 { - color: #2d3748; - font-size: 1.5rem; - font-weight: 600; -} - -.workspace-selector { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.workspace-selector label { - font-weight: 500; - color: #4a5568; - font-size: 0.9rem; -} - -.workspace-selector select { - padding: 0.5rem 1rem; - border: 2px solid #e2e8f0; - border-radius: 8px; - background: white; - font-size: 0.9rem; - color: #2d3748; - cursor: pointer; - transition: all 0.2s ease; -} - -.workspace-selector select:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -/* Chat Container */ -.chat-container { - display: flex; - flex-direction: column; - height: 100%; - background: #f8fafc; - min-height: 0; /* allow inner scroll areas to work in flex/grid */ -} - -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; - flex-direction: column; - gap: 1rem; - scroll-behavior: smooth; - min-height: 0; /* critical for overflow scroll within flex/grid */ -} - -.message { - display: flex; - flex-direction: column; - max-width: 80%; - animation: fadeIn 0.3s ease-in; -} - -.message.user { - align-self: flex-end; -} - -.message.assistant { - align-self: flex-start; -} - -.message.system { - align-self: center; - max-width: 60%; -} - -.message-content { - padding: 1rem 1.5rem; - border-radius: 18px; - font-size: 0.95rem; - line-height: 1.5; - word-wrap: break-word; -} - -.message.user .message-content { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border-bottom-right-radius: 6px; -} - -.message.assistant .message-content { - background: white; - color: #2d3748; - border: 1px solid #e2e8f0; - border-bottom-left-radius: 6px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); -} - -.message.system .message-content { - background: #edf2f7; - color: #4a5568; - text-align: center; - font-style: italic; - border-radius: 12px; -} - -.message-time { - font-size: 0.75rem; - color: #a0aec0; - margin-top: 0.25rem; - align-self: flex-end; -} - -.message.user .message-time { - color: rgba(255, 255, 255, 0.7); -} - -/* Input Container */ -.chat-input-container { - padding: 1.5rem; - background: white; - border-top: 1px solid #e1e5e9; -} - -.input-wrapper { - display: flex; - gap: 0.75rem; - align-items: flex-end; - background: white; - border: 2px solid #e2e8f0; - border-radius: 24px; - padding: 0.5rem; - transition: all 0.2s ease; -} - -.input-wrapper:focus-within { - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -#messageInput { - flex: 1; - border: none; - outline: none; - resize: none; - font-size: 0.95rem; - line-height: 1.4; - max-height: 120px; - padding: 0.5rem 0; - font-family: inherit; -} - -#messageInput:disabled { - background: transparent; - color: #a0aec0; - cursor: not-allowed; -} - -#sendButton { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - border-radius: 50%; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; - flex-shrink: 0; -} - -#sendButton:hover:not(:disabled) { - transform: scale(1.05); - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); -} - -#sendButton:disabled { - background: #e2e8f0; - color: #a0aec0; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -/* Sidebar */ -.chat-sidebar { - background: white; - border-left: 1px solid #e1e5e9; - padding: 1.5rem; - overflow-y: auto; - min-height: 0; -} - -.sidebar-section { - margin-bottom: 2rem; -} - -.sidebar-section h3 { - color: #2d3748; - font-size: 1rem; - font-weight: 600; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #e2e8f0; -} - -.info-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - font-size: 0.9rem; -} - -.info-item strong { - color: #4a5568; -} - -.info-item span { - color: #2d3748; - font-weight: 500; -} - -.sources-list { - max-height: 200px; - overflow-y: auto; -} - -.sources-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #e2e8f0; -} - -.sources-count { - font-size: 0.85rem; - color: #4a5568; - font-weight: 500; -} - -.view-sources-btn { - padding: 0.25rem 0.75rem; - background: #667eea; - color: white; - border: none; - border-radius: 6px; - font-size: 0.8rem; - cursor: pointer; - transition: all 0.2s ease; -} - -.view-sources-btn:hover:not(:disabled) { - background: #5a67d8; - transform: translateY(-1px); -} - -.view-sources-btn:disabled { - background: #a0aec0; - cursor: not-allowed; - transform: none; -} - -.source-item { - background: #f7fafc; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 0.75rem; - margin-bottom: 0.5rem; - font-size: 0.85rem; - cursor: pointer; - transition: all 0.2s ease; -} - -.source-item:hover { - background: #edf2f7; - border-color: #cbd5e0; - transform: translateY(-1px); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.source-item .source-title { - font-weight: 600; - color: #2d3748; - margin-bottom: 0.25rem; - word-break: break-all; -} - -.source-item .source-score { - color: #667eea; - font-size: 0.8rem; - margin-bottom: 0.25rem; -} - -.source-item .source-preview { - color: #4a5568; - font-size: 0.8rem; - line-height: 1.4; - max-height: 3.2em; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.no-sources { - color: #a0aec0; - font-style: italic; - text-align: center; - font-size: 0.9rem; -} - -.clear-button { - width: 100%; - padding: 0.75rem; - background: #fed7d7; - color: #c53030; - border: 1px solid #feb2b2; - border-radius: 8px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; -} - -.clear-button:hover { - background: #feb2b2; - border-color: #fc8181; -} - -/* Loading Animation */ -.typing-indicator { - display: flex; - gap: 0.25rem; - padding: 1rem 1.5rem; - background: white; - border: 1px solid #e2e8f0; - border-radius: 18px; - border-bottom-left-radius: 6px; - align-self: flex-start; - max-width: 80px; -} - -.typing-dot { - width: 8px; - height: 8px; - background: #a0aec0; - border-radius: 50%; - animation: typing 1.4s infinite ease-in-out; -} - -.typing-dot:nth-child(1) { animation-delay: -0.32s; } -.typing-dot:nth-child(2) { animation-delay: -0.16s; } - -@keyframes typing { - 0%, 80%, 100% { - transform: scale(0.8); - opacity: 0.5; - } - 40% { - transform: scale(1); - opacity: 1; - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Responsive Design */ -@media (max-width: 768px) { - .container { - grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; - } - - .chat-sidebar { - grid-row: 3; - border-left: none; - border-top: 1px solid #e1e5e9; - max-height: 200px; - } - - .chat-header { - flex-direction: column; - gap: 1rem; - align-items: stretch; - } - - .workspace-selector { - justify-content: center; - } - - .message { - max-width: 90%; - } -} - -/* Document Viewer Modal Styles */ -.document-modal { - max-width: 800px; - max-height: 90vh; -} - -.document-info { - background: #f7fafc; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 1rem; - margin-bottom: 1.5rem; -} - -.info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - font-size: 0.9rem; -} - -.info-row strong { - color: #4a5568; - min-width: 80px; -} - -.info-row span { - color: #2d3748; - font-weight: 500; - text-align: right; -} - -.document-content h4 { - color: #2d3748; - font-size: 1rem; - font-weight: 600; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid #e2e8f0; -} - -.document-text { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 1rem; - max-height: 400px; - overflow-y: auto; - font-size: 0.9rem; - line-height: 1.6; - color: #2d3748; - white-space: pre-wrap; - word-wrap: break-word; -} \ No newline at end of file diff --git a/frontend/chat.html b/frontend/chat.html deleted file mode 100644 index ea02c95..0000000 --- a/frontend/chat.html +++ /dev/null @@ -1,115 +0,0 @@ - - - - - - AI Customer Service Chat - - - -
-
-

🤖 AI Customer Service

-
- - -
-
- -
-
-
-
- Welcome! I'm your AI customer service assistant. Please select a workspace to get started. -
-
-
-
- -
-
- - -
-
-
- -
- - - - - -
-
- - - - - - - \ No newline at end of file diff --git a/frontend/chat.js b/frontend/chat.js deleted file mode 100644 index 539f186..0000000 --- a/frontend/chat.js +++ /dev/null @@ -1,369 +0,0 @@ -class ChatInterface { - constructor() { - this.currentWorkspace = null; - this.conversation = []; - this.aiAgentUrl = '/api/ai-agent'; - - this.initializeElements(); - this.bindEvents(); - this.loadWorkspaces(); - this.updateCurrentTime(); - - // Update time every minute - setInterval(() => this.updateCurrentTime(), 60000); - } - - initializeElements() { - this.workspaceSelect = document.getElementById('workspaceSelect'); - this.chatMessages = document.getElementById('chatMessages'); - this.messageInput = document.getElementById('messageInput'); - this.sendButton = document.getElementById('sendButton'); - this.clearChatButton = document.getElementById('clearChat'); - this.currentWorkspaceSpan = document.getElementById('currentWorkspace'); - this.messageCountSpan = document.getElementById('messageCount'); - this.recentSourcesDiv = document.getElementById('recentSources'); - - // Additional workspace info elements - this.workspaceDescriptionSpan = document.getElementById('workspaceDescription'); - - // Document viewer elements - this.documentModal = document.getElementById('documentModal'); - this.closeDocumentModal = document.getElementById('closeDocumentModal'); - this.documentTitle = document.getElementById('documentTitle'); - this.documentSource = document.getElementById('documentSource'); - this.documentRelevance = document.getElementById('documentRelevance'); - this.documentWorkspace = document.getElementById('documentWorkspace'); - this.documentText = document.getElementById('documentText'); - this.viewAllSourcesBtn = document.getElementById('viewAllSources'); - } - - bindEvents() { - this.workspaceSelect.addEventListener('change', (e) => this.onWorkspaceChange(e)); - this.sendButton.addEventListener('click', () => this.sendMessage()); - this.messageInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - this.sendMessage(); - } - }); - this.messageInput.addEventListener('input', () => this.adjustTextareaHeight()); - this.clearChatButton.addEventListener('click', () => this.clearConversation()); - - // Document viewer events - this.closeDocumentModal.addEventListener('click', () => this.hideDocumentModal()); - this.viewAllSourcesBtn.addEventListener('click', () => this.showAllSources()); - - // Close document modal when clicking outside - this.documentModal.addEventListener('click', (e) => { - if (e.target === this.documentModal) { - this.hideDocumentModal(); - } - }); - } - - async loadWorkspaces() { - try { - const response = await fetch(`${this.aiAgentUrl}/workspaces`); - if (!response.ok) throw new Error('Failed to load workspaces'); - - const workspaces = await response.json(); - this.populateWorkspaceSelect(workspaces); - } catch (error) { - console.error('Error loading workspaces:', error); - this.showSystemMessage('Error loading workspaces. Please refresh the page.'); - } - } - - populateWorkspaceSelect(workspaces) { - this.workspaceSelect.innerHTML = ''; - - workspaces.forEach(workspace => { - const option = document.createElement('option'); - option.value = workspace.workspace_id; - option.textContent = workspace.name; - option.title = workspace.description || `Workspace: ${workspace.name}`; - this.workspaceSelect.appendChild(option); - }); - - // If there are workspaces, show a helpful message - if (workspaces.length > 0) { - this.showSystemMessage(`Loaded ${workspaces.length} workspace(s). Select one to start chatting!`); - } else { - this.showSystemMessage('No workspaces available. Please contact your administrator to create workspaces or upload documents to existing workspaces.'); - } - } - - onWorkspaceChange(event) { - const workspaceId = parseInt(event.target.value); - if (workspaceId) { - this.currentWorkspace = workspaceId; - this.enableChat(); - this.updateWorkspaceInfo(); - this.showSystemMessage(`Switched to workspace: ${event.target.options[event.target.selectedIndex].text}`); - } else { - this.currentWorkspace = null; - this.disableChat(); - this.updateWorkspaceInfo(); - } - } - - enableChat() { - this.messageInput.disabled = false; - this.sendButton.disabled = false; - this.messageInput.placeholder = 'Type your message here...'; - } - - disableChat() { - this.messageInput.disabled = true; - this.sendButton.disabled = true; - this.messageInput.placeholder = 'Please select a workspace first...'; - } - - updateWorkspaceInfo() { - if (this.currentWorkspace) { - const selectedOption = this.workspaceSelect.options[this.workspaceSelect.selectedIndex]; - const workspaceName = selectedOption.text; - const workspaceDescription = selectedOption.title; - - this.currentWorkspaceSpan.textContent = workspaceName; - this.workspaceDescriptionSpan.textContent = workspaceDescription; - } else { - this.currentWorkspaceSpan.textContent = 'None selected'; - this.workspaceDescriptionSpan.textContent = '-'; - } - } - - updateCurrentTime() { - const now = new Date(); - const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - document.getElementById('currentTime').textContent = timeString; - } - - adjustTextareaHeight() { - this.messageInput.style.height = 'auto'; - this.messageInput.style.height = Math.min(this.messageInput.scrollHeight, 120) + 'px'; - } - - async sendMessage() { - const message = this.messageInput.value.trim(); - if (!message || !this.currentWorkspace) return; - - // Add user message to chat - this.addMessage('user', message); - this.conversation.push({ role: 'user', content: message }); - - // Clear input - this.messageInput.value = ''; - this.adjustTextareaHeight(); - - // Show typing indicator - const typingIndicator = this.showTypingIndicator(); - - try { - // Send to AI agent - const response = await fetch(`${this.aiAgentUrl}/conversation`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - messages: this.conversation, - workspace_id: this.currentWorkspace - }) - }); - - if (!response.ok) throw new Error('Failed to get response from AI agent'); - - const data = await response.json(); - - // Remove typing indicator - typingIndicator.remove(); - - // Add AI response - this.addMessage('assistant', data.response); - this.conversation.push({ role: 'assistant', content: data.response }); - - // Update sources - if (data.relevant_docs && data.relevant_docs.length > 0) { - this.updateRecentSources(data.relevant_docs); - this.showSystemMessage(`Retrieved ${data.relevant_docs.length} relevant document(s) for your question.`); - } else { - this.updateRecentSources([]); - this.showSystemMessage('No specific documents were retrieved for this question.'); - } - - } catch (error) { - console.error('Error sending message:', error); - typingIndicator.remove(); - this.addMessage('assistant', 'Sorry, I encountered an error. Please try again.'); - } - - this.updateMessageCount(); - } - - addMessage(role, content) { - const messageDiv = document.createElement('div'); - messageDiv.className = `message ${role}`; - - const contentDiv = document.createElement('div'); - contentDiv.className = 'message-content'; - contentDiv.textContent = content; - - const timeDiv = document.createElement('div'); - timeDiv.className = 'message-time'; - timeDiv.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - - messageDiv.appendChild(contentDiv); - messageDiv.appendChild(timeDiv); - - this.chatMessages.appendChild(messageDiv); - // Use microtask to ensure layout is updated before scrolling - Promise.resolve().then(() => this.scrollToBottom()); - } - - showSystemMessage(content) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message system'; - - const contentDiv = document.createElement('div'); - contentDiv.className = 'message-content'; - contentDiv.textContent = content; - - messageDiv.appendChild(contentDiv); - this.chatMessages.appendChild(messageDiv); - this.scrollToBottom(); - } - - showTypingIndicator() { - const typingDiv = document.createElement('div'); - typingDiv.className = 'message assistant typing-indicator'; - typingDiv.innerHTML = ` -
-
-
- `; - - this.chatMessages.appendChild(typingDiv); - this.scrollToBottom(); - return typingDiv; - } - - updateRecentSources(sources) { - if (!sources || sources.length === 0) { - this.recentSourcesDiv.innerHTML = '

No documents retrieved

'; - this.viewAllSourcesBtn.disabled = true; - this.updateSourcesCount(0); - return; - } - - this.recentSourcesDiv.innerHTML = ''; - this.updateSourcesCount(sources.length); - this.viewAllSourcesBtn.disabled = false; - - sources.forEach((source, index) => { - const sourceDiv = document.createElement('div'); - sourceDiv.className = 'source-item'; - sourceDiv.dataset.sourceIndex = index; - - // Create a preview of the content (first 100 characters) - const preview = source.content ? - source.content.substring(0, 100) + (source.content.length > 100 ? '...' : '') : - 'No content preview available'; - - sourceDiv.innerHTML = ` -
${this.formatSourceName(source.s3_key || 'Unknown source')}
-
Relevance: ${(source.score * 100).toFixed(1)}%
-
${preview}
- `; - - // Make the source item clickable to view full content - sourceDiv.addEventListener('click', () => this.showDocumentContent(source, index)); - - this.recentSourcesDiv.appendChild(sourceDiv); - }); - } - - updateSourcesCount(count) { - const sourcesCountElement = document.querySelector('.sources-count'); - if (sourcesCountElement) { - sourcesCountElement.textContent = `${count} document${count !== 1 ? 's' : ''}`; - } - } - - updateMessageCount() { - const userMessages = this.conversation.filter(msg => msg.role === 'user').length; - this.messageCountSpan.textContent = userMessages; - } - - formatSourceName(s3Key) { - const parts = s3Key.split('/'); - return parts[parts.length - 1]; - } - - showDocumentContent(source, index) { - this.documentTitle.textContent = this.formatSourceName(source.s3_key); - this.documentSource.textContent = `Source: ${this.formatSourceName(source.s3_key)}`; - this.documentRelevance.textContent = `Relevance: ${(source.score * 100).toFixed(1)}%`; - this.documentWorkspace.textContent = `Workspace: ${source.workspace_name}`; - this.documentText.textContent = source.content || 'No content available for this document.'; - this.documentModal.style.display = 'block'; - this.scrollToBottom(); - } - - hideDocumentModal() { - this.documentModal.style.display = 'none'; - } - - showAllSources() { - // Show a summary of all retrieved documents - const sources = this.recentSourcesDiv.querySelectorAll('.source-item'); - if (sources.length === 0) { - this.showSystemMessage('No documents have been retrieved yet.'); - return; - } - - let summary = `Retrieved ${sources.length} document(s):\n\n`; - sources.forEach((source, index) => { - const title = source.querySelector('.source-title').textContent; - const score = source.querySelector('.source-score').textContent; - summary += `${index + 1}. ${title} (${score})\n`; - }); - - // Show the summary in the modal - this.documentTitle.textContent = 'Retrieved Documents Summary'; - this.documentSource.textContent = `Total: ${sources.length} document(s)`; - this.documentRelevance.textContent = `Current conversation`; - this.documentWorkspace.textContent = `Workspace: ${this.currentWorkspace ? this.workspaceSelect.options[this.workspaceSelect.selectedIndex].text : 'Unknown'}`; - this.documentText.textContent = summary; - this.documentModal.style.display = 'block'; - } - - clearConversation() { - if (confirm('Are you sure you want to clear the conversation?')) { - this.conversation = []; - this.chatMessages.innerHTML = ''; - this.recentSourcesDiv.innerHTML = '

No documents retrieved yet

'; - this.updateSourcesCount(0); - this.viewAllSourcesBtn.disabled = true; - this.updateMessageCount(); - - if (this.currentWorkspace) { - this.showSystemMessage('Conversation cleared. You can start a new conversation.'); - } - } - } - - scrollToBottom() { - const last = this.chatMessages.lastElementChild; - if (last && last.scrollIntoView) { - last.scrollIntoView({ behavior: 'smooth', block: 'end' }); - } else { - this.chatMessages.scrollTop = this.chatMessages.scrollHeight; - } - } -} - -// Initialize the chat interface when the page loads -document.addEventListener('DOMContentLoaded', () => { - new ChatInterface(); -}); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index fc1a84d..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - File Upload & Search - - - -
-
-

QuickResolve - File Management & AI Chat

- -
- -
-

Upload Files

-
- - -
-
- - -
-
- - -
- -

-

-
- -
- -
-

Search Files

-
- - -
-
- - -
- -

- - -
- - - - \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 9ebccae..2da2ebc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -42,20 +42,37 @@ http { proxy_set_header X-Forwarded-Proto $scheme; # Handle service unavailability gracefully - proxy_connect_timeout 15s; - proxy_send_timeout 30s; - proxy_read_timeout 30s; + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; - # Return 503 Service Unavailable if backend is down - proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; - proxy_intercept_errors on; - error_page 502 503 504 = @fallback; + # Don't intercept errors for now to debug the issue + proxy_intercept_errors off; } - # Fallback location for when AI agent service is unavailable - location @fallback { - return 503 '{"error": "AI Agent Service is temporarily unavailable", "status": "service_unavailable"}'; - add_header Content-Type application/json; + # API proxy for ingestion service + location /api/ingestion/ { + proxy_pass http://ingestion-service:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Handle service unavailability gracefully + proxy_connect_timeout 30s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Don't intercept errors for now to debug the issue + proxy_intercept_errors off; + + # Add debug headers + add_header X-Debug-Proxy "true"; + add_header X-Debug-Target "ingestion-service:8000"; + + # Fix response handling + proxy_buffering off; + proxy_request_buffering off; } error_page 500 502 503 504 /50x.html; diff --git a/frontend/script.js b/frontend/script.js index 64ef0b7..64e5988 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -109,7 +109,7 @@ async function getOrCreateUser(username) { // First, try to find the user const existingUser = await handleRequest( - `http://localhost:8000/users/?username=${encodeURIComponent(username)}`, + `/api/ingestion/users/?username=${encodeURIComponent(username)}`, { method: 'GET' }, 'Failed to check for existing user' ); @@ -122,7 +122,7 @@ async function getOrCreateUser(username) { // If user doesn't exist, create them uploadMessage.textContent = `Creating new user '${username}'...`; const newUser = await handleRequest( - 'http://localhost:8000/users/', + '/api/ingestion/users/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -145,7 +145,7 @@ async function getOrCreateWorkspace(workspaceName, userId) { // First, try to find the workspace for this user const existingWorkspace = await handleRequest( - `http://localhost:8000/workspaces/?owner_id=${userId}&name=${encodeURIComponent(workspaceName)}`, + `/api/ingestion/workspaces/?owner_id=${userId}&name=${encodeURIComponent(workspaceName)}`, { method: 'GET' }, 'Failed to check for existing workspace' ); @@ -158,7 +158,7 @@ async function getOrCreateWorkspace(workspaceName, userId) { // If workspace doesn't exist, create it uploadMessage.textContent = `Creating new workspace '${workspaceName}'...`; const newWorkspace = await handleRequest( - 'http://localhost:8000/workspaces/', + '/api/ingestion/workspaces/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -182,7 +182,7 @@ async function findUser(username) { workspaceMessage.className = ''; const existingUser = await handleRequest( - `http://localhost:8000/users/?username=${encodeURIComponent(username)}`, + `/api/ingestion/users/?username=${encodeURIComponent(username)}`, { method: 'GET' }, 'Failed to find user' ); @@ -201,7 +201,7 @@ async function findWorkspace(workspaceName, userId) { workspaceMessage.textContent = `Looking for workspace '${workspaceName}'...`; const existingWorkspace = await handleRequest( - `http://localhost:8000/workspaces/?owner_id=${userId}&name=${encodeURIComponent(workspaceName)}`, + `/api/ingestion/workspaces/?owner_id=${userId}&name=${encodeURIComponent(workspaceName)}`, { method: 'GET' }, 'Failed to find workspace' ); @@ -282,7 +282,7 @@ async function toggleContent(event) { // Otherwise, fetch the content button.textContent = 'Loading...'; const data = await handleRequest( - `http://localhost:8000/file-content/?s3_key=${encodeURIComponent(s3_key)}`, + `/api/ingestion/file-content/?s3_key=${encodeURIComponent(s3_key)}`, { method: 'GET' }, 'Failed to fetch file content' ); diff --git a/frontend/style.css b/frontend/style.css deleted file mode 100644 index f39961b..0000000 --- a/frontend/style.css +++ /dev/null @@ -1,185 +0,0 @@ -body { - font-family: sans-serif; - margin: 0; - padding: 0; - background-color: #f0f0f0; - min-height: 100vh; -} - -.main-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 1rem 2rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.main-header h1 { - margin: 0 0 1rem 0; - font-size: 1.8rem; - font-weight: 600; -} - -.main-nav { - display: flex; - gap: 1rem; -} - -.nav-link { - color: rgba(255, 255, 255, 0.8); - text-decoration: none; - padding: 0.5rem 1rem; - border-radius: 6px; - transition: all 0.2s ease; -} - -.nav-link:hover { - color: white; - background: rgba(255, 255, 255, 0.1); -} - -.nav-link.active { - color: white; - background: rgba(255, 255, 255, 0.2); -} - -.upload-section { - padding: 2rem; -} - -.container { - background-color: white; - padding: 2rem; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - margin-bottom: 2rem; - max-width: 600px; - margin-left: auto; - margin-right: auto; -} - -.form-group { - margin-bottom: 1.5rem; -} - -label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - color: #333; -} - -input[type="text"], input[type="file"] { - width: 100%; - padding: 0.75rem; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - box-sizing: border-box; -} - -input[type="text"]:focus, input[type="file"]:focus { - outline: none; - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); -} - -button { - padding: 0.75rem 1.5rem; - border: none; - border-radius: 4px; - background-color: #007bff; - color: white; - cursor: pointer; - font-size: 14px; - font-weight: 600; - transition: background-color 0.2s; -} - -button:hover { - background-color: #0056b3; -} - -button:disabled { - background-color: #6c757d; - cursor: not-allowed; -} - -.search-container { - margin-top: 2rem; -} - -#searchSection { - margin-top: 1.5rem; - padding-top: 1.5rem; - border-top: 1px solid #eee; -} - -#results { - margin-top: 1rem; - padding: 1rem; - border: 1px solid #eee; - border-radius: 4px; - min-height: 50px; - background-color: #fafafa; -} - -.toggle-content { - margin-left: 1rem; - padding: 0.25rem 0.5rem; - font-size: 12px; - background-color: #28a745; -} - -.toggle-content:hover { - background-color: #218838; -} - -.content { - margin-top: 0.5rem; - padding: 1rem; - background-color: #f8f9fa; - border-radius: 4px; - border-left: 4px solid #007bff; -} - -#uploadMessage, #workspaceMessage, #overallStatus { - margin-top: 1rem; - padding: 0.75rem; - border-radius: 4px; - font-weight: 500; -} - -#uploadMessage { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; -} - -#workspaceMessage { - background-color: #d1ecf1; - color: #0c5460; - border: 1px solid #bee5eb; -} - -#overallStatus { - background-color: #d4edda; - color: #155724; - border: 1px solid #c3e6cb; -} - -.error { - background-color: #f8d7da; - color: #721c24; - border: 1px solid #f5c6cb; -} - -h1 { - color: #333; - margin-bottom: 1.5rem; - text-align: center; -} - -h2 { - color: #555; - margin-bottom: 1rem; -} \ No newline at end of file diff --git a/ingestion-service/Dockerfile b/ingestion-service/Dockerfile index a2a7506..22bd730 100644 --- a/ingestion-service/Dockerfile +++ b/ingestion-service/Dockerfile @@ -2,6 +2,11 @@ FROM python:3.9-slim WORKDIR /app +# Install system dependencies including curl for health checks +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/ingestion-service/main.py b/ingestion-service/main.py index 2f5cac6..693a999 100644 --- a/ingestion-service/main.py +++ b/ingestion-service/main.py @@ -9,49 +9,46 @@ import httpx from database import Base from database import File as DBFile -from database import SessionLocal, User, Workspace, engine +from database import SessionLocal, User, Workspace, engine, get_db_with_retry, get_db_session from fastapi import ( Depends, FastAPI, HTTPException, + Query, Response, UploadFile, ) from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, ConfigDict -from sqlalchemy import text # Import text function +from sqlalchemy import text from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session -# --- Improved Pydantic Models --- - - +# --- Pydantic Models --- class UserCreate(BaseModel): username: str - class UserResponse(UserCreate): id: int model_config = ConfigDict(from_attributes=True) - class WorkspaceCreate(BaseModel): name: str owner_id: int - class WorkspaceResponse(WorkspaceCreate): id: int model_config = ConfigDict(from_attributes=True) +class FileStatusResponse(BaseModel): + file_id: UUID + status: int app = FastAPI() -origins = [ - "http://localhost", - "http://localhost:8080", - "http://localhost:8090", -] +# Get CORS origins from environment variable, with fallback to localhost +cors_origins_env = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost,http://localhost:8080,http://localhost:8090") +origins = [origin.strip() for origin in cors_origins_env.split(",") if origin.strip()] app.add_middleware( CORSMiddleware, @@ -62,19 +59,49 @@ class WorkspaceResponse(WorkspaceCreate): expose_headers=["Location", "Content-Location", "location"], ) - +@app.middleware("http") +async def db_connection_middleware(request, call_next): + """ + Middleware to handle database connection issues gracefully. + """ + try: + response = await call_next(request) + return response + except Exception as e: + if "connection" in str(e).lower() or "database" in str(e).lower(): + print(f"Database connection error in middleware: {e}") + return {"detail": "Database connection failed. Please try again.", "status_code": 503} + raise e @app.get("/health") async def health(): return {"status": "healthy", "service": "ingestion-service"} +@app.get("/health/db") +async def health_db(): + """ + Check database connection health. + """ + try: + with get_db_session() as db: + db.execute(text("SELECT 1")) + return {"status": "healthy", "database": "connected"} + except Exception as e: + return {"status": "unhealthy", "database": "disconnected", "error": str(e)} + def get_db(): - db = SessionLocal() + """ + Database dependency with retry logic and proper error handling. + """ try: + db = get_db_with_retry() yield db + except Exception as e: + print(f"Database connection error: {e}") + raise HTTPException(status_code=503, detail="Database connection failed") finally: - db.close() - + if 'db' in locals(): + db.close() # Global variables for S3 S3_ENDPOINT = None @@ -83,7 +110,6 @@ def get_db(): S3_BUCKET = None s3 = None - @app.on_event("startup") def on_startup(): global s3, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET @@ -107,27 +133,29 @@ def on_startup(): aws_secret_access_key=S3_SECRET_KEY, ) - # Wait for the database to be ready - retries = 5 + # Wait for the database to be ready with improved retry logic + retries = 10 while retries > 0: try: - # Try to connect to the database - db = SessionLocal() - db.execute(text("SELECT 1")) # Use text() function - db.close() + # Try to connect to the database using retry logic + with get_db_session() as db: + db.execute(text("SELECT 1")) print("Database is ready.") break - except OperationalError: - print("Database not ready, waiting...") + except Exception as e: + print(f"Database not ready, waiting... (attempt {11-retries}/10): {e}") time.sleep(5) retries -= 1 if retries == 0: - print("Could not connect to the database. Exiting.") + print("Could not connect to the database after 10 attempts. Exiting.") exit(1) - # Create tables - Base.metadata.create_all(bind=engine) + # Create tables using shared function + print("Creating database tables from ingestion service...") + from database import create_tables + create_tables() + print("Database tables created successfully from ingestion service") # Ensure S3 bucket exists try: @@ -138,7 +166,6 @@ def on_startup(): s3.create_bucket(Bucket=S3_BUCKET) print(f"S3 bucket '{S3_BUCKET}' created successfully.") - async def _trigger_downstream( md: bool, s3_key: str, db_file_id: str, workspace_id: int, filename: str ): @@ -178,7 +205,6 @@ async def _trigger_downstream( # best-effort fire-and-forget pass - async def _bg_upload_and_trigger( tmp_path: str, s3_bucket: str, s3_key: str, db_file_id: UUID ) -> None: @@ -217,7 +243,6 @@ async def _bg_upload_and_trigger( @app.post("/uploadfile", status_code=202) -@app.post("/uploadfile/", status_code=202) async def create_upload_file( file: UploadFile, workspace_id: int, @@ -321,13 +346,11 @@ async def create_upload_file( status_url = f"http://localhost:8010/task/{task_id}/status" return Response(status_code=202, headers={"Location": status_url}) - def _copy_stream_to_path(src_fileobj, dst_path: str) -> None: src_fileobj.seek(0) with open(dst_path, "wb") as out_f: shutil.copyfileobj(src_fileobj, out_f, length=1024 * 1024) - @app.get("/file-content/") async def get_file_content(s3_key: str): """ @@ -342,29 +365,39 @@ async def get_file_content(s3_key: str): status_code=500, detail=f"Failed to retrieve file from S3: {e}" ) - # --- User and Workspace Endpoints --- - - @app.post("/users/", response_model=UserResponse, status_code=201) async def create_user(user: UserCreate, db: Session = Depends(get_db)): - existing_user = ( - db.query(User).filter(User.username == user.username).first() - ) - if existing_user: - raise HTTPException( - status_code=409, # Conflict - detail=( - "Username already registered. Please choose a different one." - ), + try: + existing_user = ( + db.query(User).filter(User.username == user.username).first() ) + if existing_user: + raise HTTPException( + status_code=409, # Conflict + detail=( + "Username already registered. Please choose a different one." + ), + ) - db_user = User(**user.model_dump()) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user - + # Create user with minimal required fields for ingestion service compatibility + db_user = User( + username=user.username, + email=f"{user.username}@example.com", # Temporary email + password_hash="temp", # Temporary password hash + is_active=True + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + except Exception as e: + db.rollback() + print(f"Error creating user: {e}") + raise HTTPException( + status_code=500, + detail="Failed to create user. Please try again." + ) @app.get("/users/", response_model=list[UserResponse]) async def get_user_by_name(username: str, db: Session = Depends(get_db)): @@ -372,9 +405,15 @@ async def get_user_by_name(username: str, db: Session = Depends(get_db)): Looks up a user by their exact username. Returns a list containing the user if found, otherwise an empty list. """ - user = db.query(User).filter(User.username == username).first() - return [user] if user else [] - + try: + user = db.query(User).filter(User.username == username).first() + return [user] if user else [] + except Exception as e: + print(f"Error looking up user: {e}") + raise HTTPException( + status_code=500, + detail="Failed to look up user. Please try again." + ) @app.post("/workspaces/", response_model=WorkspaceResponse, status_code=201) async def create_workspace( @@ -393,7 +432,6 @@ async def create_workspace( db.refresh(db_workspace) return db_workspace - @app.get("/workspaces/", response_model=list[WorkspaceResponse]) async def get_workspace_by_name( name: str, owner_id: int, db: Session = Depends(get_db) @@ -409,6 +447,16 @@ async def get_workspace_by_name( ) return [workspace] if workspace else [] +@app.get("/workspaces/by-owner/{owner_id}", response_model=list[WorkspaceResponse]) +async def get_workspaces_by_owner( + owner_id: int, db: Session = Depends(get_db) +): + """ + Get all workspaces for a specific owner. + Returns a list of workspaces belonging to the specified owner. + """ + workspaces = db.query(Workspace).filter(Workspace.owner_id == owner_id).all() + return workspaces @app.get("/workspaces/all", response_model=list[WorkspaceResponse]) async def get_all_workspaces(db: Session = Depends(get_db)): @@ -418,3 +466,83 @@ async def get_all_workspaces(db: Session = Depends(get_db)): """ workspaces = db.query(Workspace).all() return workspaces + +# --- File Management Endpoints --- +@app.get("/files/", response_model=list) +async def get_files( + user_id: int = None, + workspace_id: int = None, + limit: int = Query(50, ge=1, le=1000), + db: Session = Depends(get_db) +): + """ + Get files by user_id or workspace_id. + Returns a list of files matching the criteria. + """ + query = db.query(DBFile) + + if workspace_id: + query = query.filter(DBFile.workspace_id == workspace_id) + elif user_id: + # Get all workspaces for this user first + user_workspaces = db.query(Workspace).filter(Workspace.owner_id == user_id).all() + workspace_ids = [w.id for w in user_workspaces] + if workspace_ids: + query = query.filter(DBFile.workspace_id.in_(workspace_ids)) + else: + return [] # User has no workspaces, so no files + + files = query.order_by(DBFile.created_at.desc()).limit(limit).all() + + # Convert to dict format for response + return [ + { + "id": str(file.id), + "name": file.name, + "s3_key": file.s3_key, + "workspace_id": file.workspace_id, + "status": file.status, + "created_at": file.created_at.isoformat() if file.created_at else None, + } + for file in files + ] + +@app.delete("/files/{file_id}") +async def delete_file(file_id: UUID, db: Session = Depends(get_db)): + """ + Delete a file by its ID. + """ + file = db.query(DBFile).filter(DBFile.id == file_id).first() + if not file: + raise HTTPException(status_code=404, detail="File not found") + + # TODO: Also delete from S3 if needed + # if file.s3_key: + # # Delete from S3 + # pass + + db.delete(file) + db.commit() + return {"message": "File deleted successfully"} + +# --- File Status Endpoints --- +@app.get("/files/{file_id}/status", response_model=FileStatusResponse) +async def get_file_status(file_id: UUID, db: Session = Depends(get_db)): + file = db.query(DBFile).filter(DBFile.id == file_id).first() + if not file: + raise HTTPException(status_code=404, detail="File not found") + return FileStatusResponse(file_id=file.id, status=file.status) + +@app.put("/files/{file_id}/status", response_model=FileStatusResponse) +async def update_file_status( + file_id: UUID, + status: int = Query(..., ge=1, le=3), + db: Session = Depends(get_db), +): + file = db.query(DBFile).filter(DBFile.id == file_id).first() + if not file: + raise HTTPException(status_code=404, detail="File not found") + file.status = status + db.commit() + db.refresh(file) + return FileStatusResponse(file_id=file.id, status=file.status) diff --git a/landing-next/Dockerfile b/landing-next/Dockerfile index e851fb6..1e0b772 100644 --- a/landing-next/Dockerfile +++ b/landing-next/Dockerfile @@ -16,11 +16,16 @@ FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production +# Install bash and netcat for wait-for-it script +RUN apk add --no-cache bash netcat-openbsd + COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +COPY wait-for-it.sh ./wait-for-it.sh +RUN chmod +x ./wait-for-it.sh EXPOSE 3000 -CMD ["npm", "run", "start"] +CMD ["./wait-for-it.sh", "auth-service:8000", "--timeout=60", "--", "npm", "run", "start"] diff --git a/landing-next/app/api/ai-agent/conversation/route.ts b/landing-next/app/api/ai-agent/conversation/route.ts new file mode 100644 index 0000000..5608e6b --- /dev/null +++ b/landing-next/app/api/ai-agent/conversation/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { messages, workspace_id } = body + + const response = await fetch(`${process.env.AI_AGENT_SERVICE_URL || 'http://localhost:8001'}/conversation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages, + workspace_id + }) + }) + + if (!response.ok) { + const error = await response.json() + return NextResponse.json({ detail: error.detail || 'AI conversation failed' }, { status: response.status }) + } + + const result = await response.json() + return NextResponse.json(result) + + } catch (error) { + console.error('AI conversation error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/ai-agent/workspaces/route.ts b/landing-next/app/api/ai-agent/workspaces/route.ts new file mode 100644 index 0000000..685d36c --- /dev/null +++ b/landing-next/app/api/ai-agent/workspaces/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + try { + const response = await fetch(`${process.env.AI_AGENT_SERVICE_URL || 'http://localhost:8001'}/workspaces`) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to fetch workspaces' }, { status: response.status }) + } + + const workspaces = await response.json() + return NextResponse.json(workspaces) + + } catch (error) { + console.error('AI workspaces fetch error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} diff --git a/landing-next/app/api/auth/change-password/route.ts b/landing-next/app/api/auth/change-password/route.ts new file mode 100644 index 0000000..b0d945c --- /dev/null +++ b/landing-next/app/api/auth/change-password/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, getAuthToken } from '../../../../lib/auth' +import { createSecureResponse, createValidationErrorResponse, logSecurityEvent } from '../../../../lib/security' + +const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:8003' + +export async function PUT(request: NextRequest) { + try { + // Use secure session-based authentication + const userOrResponse = await requireAuth(request) + + if (userOrResponse instanceof Response) { + return userOrResponse // Return the error response + } + + const { current_password, new_password } = await request.json() + + // Validate input + if (!current_password || !new_password) { + return createValidationErrorResponse('current_password and new_password are required') + } + + // Validate new password strength + try { + const passwordValidation = validatePassword(new_password) + if (!passwordValidation.isValid) { + return createValidationErrorResponse(passwordValidation.message) + } + } catch (error) { + return createValidationErrorResponse('Invalid password format') + } + + // Get auth token for backend API call + const authToken = await getAuthToken() + if (!authToken) { + return createValidationErrorResponse('Authentication token not found') + } + + // Call backend to change password + const response = await fetch( + `${AUTH_SERVICE_URL}/users/${userOrResponse.id}/change-password`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ current_password, new_password }), + } + ) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + if (response.status === 400 && data.detail?.includes('incorrect')) { + logSecurityEvent('FAILED_PASSWORD_CHANGE', { + userId: userOrResponse.id, + reason: 'incorrect_current_password' + }, request) + } + + return createSecureResponse( + { detail: data.detail || 'Failed to change password' }, + { status: response.status } + ) + } + + logSecurityEvent('PASSWORD_CHANGED', { + userId: userOrResponse.id + }, request) + + return createSecureResponse({ message: 'Password changed successfully' }) + + } catch (error) { + console.error('Password change error:', error) + return createSecureResponse({ detail: 'Internal server error' }, { status: 500 }) + } +} + +function validatePassword(password: string): { isValid: boolean, message: string } { + if (password.length < 8) { + return { isValid: false, message: 'Password must be at least 8 characters long' } + } + + if (!/[A-Z]/.test(password)) { + return { isValid: false, message: 'Password must contain at least one uppercase letter' } + } + + if (!/[a-z]/.test(password)) { + return { isValid: false, message: 'Password must contain at least one lowercase letter' } + } + + if (!/\d/.test(password)) { + return { isValid: false, message: 'Password must contain at least one number' } + } + + return { isValid: true, message: 'Password is valid' } +} diff --git a/landing-next/app/api/auth/login/route.ts b/landing-next/app/api/auth/login/route.ts new file mode 100644 index 0000000..f21d7ab --- /dev/null +++ b/landing-next/app/api/auth/login/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from 'next/server' +import { setSessionCookies } from '../../../../lib/auth' +import { loginLimiter, getClientIP } from '../../../../lib/rateLimiter' +import { sanitizeEmail, detectSuspiciousPatterns } from '../../../../lib/sanitization' +import { createSecureResponse, createRateLimitResponse, createValidationErrorResponse, createServerErrorResponse, logSecurityEvent } from '../../../../lib/security' + +// Auth service URL +const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:8003' + +export async function POST(request: NextRequest) { + try { + // Enhanced rate limiting + const clientIP = getClientIP(request) + const rateLimitResult = await loginLimiter.checkLimit(clientIP) + + if (!rateLimitResult.success) { + logSecurityEvent('RATE_LIMIT_EXCEEDED', { + endpoint: '/auth/login', + clientIP, + limit: rateLimitResult.limit + }, request) + + const retryAfterMs = rateLimitResult.reset.getTime() - Date.now() + return createRateLimitResponse(retryAfterMs) + } + + const body = await request.json() + const { password } = body + + // Validate input + if (!body.email || !password) { + return createValidationErrorResponse('Email and password are required') + } + + // Sanitize email and check for suspicious patterns + let sanitizedEmail + try { + sanitizedEmail = sanitizeEmail(body.email) + + const suspiciousPatterns = detectSuspiciousPatterns(body.email + ' ' + password) + if (suspiciousPatterns.length > 0) { + logSecurityEvent('SUSPICIOUS_LOGIN_ATTEMPT', { + patterns: suspiciousPatterns, + email: body.email.substring(0, 3) + '***' // Log partial email only + }, request) + return createValidationErrorResponse('Invalid input detected') + } + } catch (error) { + return createValidationErrorResponse('Invalid email format') + } + + // Additional password validation + if (password.length < 8) { + return createValidationErrorResponse('Invalid password format') + } + + // Forward request to Python backend + const response = await fetch(`${AUTH_SERVICE_URL}/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: sanitizedEmail, password }), + }) + + const data = await response.json() + + if (response.ok) { + // Validate user data from backend + if (!data.user || !data.user.id || !data.user.email) { + logSecurityEvent('INVALID_USER_DATA', { + receivedData: data.user ? 'partial' : 'none' + }, request) + return createServerErrorResponse() + } + + // Set secure HTTP-only cookies + await setSessionCookies(data.user, data.token) + + // Return success without sensitive data + return createSecureResponse({ + message: 'Login successful', + user: { + id: data.user.id, + email: data.user.email, + first_name: data.user.first_name, + last_name: data.user.last_name, + username: data.user.username, + company_name: data.user.company_name, + team_size: data.user.team_size, + is_active: data.user.is_active + } + }) + } else { + // Log failed login attempts + if (response.status === 401) { + logSecurityEvent('FAILED_LOGIN_ATTEMPT', { + email: sanitizedEmail.substring(0, 3) + '***', + status: response.status + }, request) + } + + // Return error from backend + return createSecureResponse( + { error: data.error || data.detail || 'Login failed' }, + { status: response.status } + ) + } + + } catch (error) { + logSecurityEvent('LOGIN_API_ERROR', { + error: error instanceof Error ? error.message : 'Unknown error' + }, request) + + // Check if it's a network error + if (error instanceof TypeError && error.message.includes('fetch')) { + return createSecureResponse( + { error: 'Auth service is temporarily unavailable. Please try again in a moment.' }, + { status: 503 } + ) + } + + return createServerErrorResponse() + } +} + +export async function GET() { + return createSecureResponse( + { error: 'Method not allowed' }, + { status: 405 } + ) +} \ No newline at end of file diff --git a/landing-next/app/api/auth/logout/route.ts b/landing-next/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7a80212 --- /dev/null +++ b/landing-next/app/api/auth/logout/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server' +import { clearSession } from '../../../../lib/auth' +import { createSecureResponse } from '../../../../lib/security' + +export async function POST(request: NextRequest) { + try { + // Clear the session cookies + clearSession() + + return createSecureResponse({ + message: 'Logged out successfully' + }) + + } catch (error) { + console.error('Logout error:', error) + return createSecureResponse( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +export async function GET() { + return createSecureResponse( + { error: 'Method not allowed' }, + { status: 405 } + ) +} \ No newline at end of file diff --git a/landing-next/app/api/auth/me/route.ts b/landing-next/app/api/auth/me/route.ts new file mode 100644 index 0000000..833359f --- /dev/null +++ b/landing-next/app/api/auth/me/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth } from '../../../../lib/auth' +import { createSecureResponse } from '../../../../lib/security' + +export async function GET(request: NextRequest) { + try { + // Use secure session-based authentication + const userOrResponse = await requireAuth(request) + + if (userOrResponse instanceof Response) { + return userOrResponse + } + + // Return user data + return createSecureResponse({ + id: userOrResponse.id, + email: userOrResponse.email, + first_name: userOrResponse.first_name, + last_name: userOrResponse.last_name, + username: userOrResponse.username, + is_active: userOrResponse.is_active, + created_at: userOrResponse.created_at + }) + + } catch (error) { + console.error('Auth verification error:', error) + return createSecureResponse( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +export async function POST() { + return createSecureResponse( + { error: 'Method not allowed' }, + { status: 405 } + ) +} \ No newline at end of file diff --git a/landing-next/app/api/auth/signup/route.ts b/landing-next/app/api/auth/signup/route.ts new file mode 100644 index 0000000..dc59351 --- /dev/null +++ b/landing-next/app/api/auth/signup/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from 'next/server' +import { setSessionCookies } from '../../../../lib/auth' +import { signupLimiter, getClientIP } from '../../../../lib/rateLimiter' +import { sanitizeUserRegistrationData, detectSuspiciousPatterns } from '../../../../lib/sanitization' + +// Auth service URL +const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:8003' + +// Password validation function +interface PasswordValidation { + isValid: boolean + message: string +} + +function validatePassword(password: string): PasswordValidation { + if (password.length < 8) { + return { isValid: false, message: 'Password must be at least 8 characters long' } + } + + if (!/[A-Z]/.test(password)) { + return { isValid: false, message: 'Password must contain at least one uppercase letter' } + } + + if (!/[a-z]/.test(password)) { + return { isValid: false, message: 'Password must contain at least one lowercase letter' } + } + + if (!/\d/.test(password)) { + return { isValid: false, message: 'Password must contain at least one number' } + } + + return { isValid: true, message: 'Password is valid' } +} + +export async function POST(request: NextRequest) { + try { + // Enhanced rate limiting + const clientIP = getClientIP(request) + const rateLimitResult = await signupLimiter.checkLimit(clientIP) + + if (!rateLimitResult.success) { + return NextResponse.json( + { + error: 'Too many signup attempts. Please try again later.', + retryAfter: rateLimitResult.reset + }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': rateLimitResult.limit.toString(), + 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), + 'X-RateLimit-Reset': rateLimitResult.reset.getTime().toString(), + 'Retry-After': Math.ceil((rateLimitResult.reset.getTime() - Date.now()) / 1000).toString() + } + } + ) + } + + const body = await request.json() + const { password } = body + + // Basic input validation + if (!body.email || !password || !body.firstName || !body.lastName) { + return NextResponse.json( + { error: 'Email, password, first name, and last name are required' }, + { status: 400 } + ) + } + + // Sanitize and validate input data + let sanitizedData + try { + sanitizedData = sanitizeUserRegistrationData(body) + + // Check for suspicious patterns in all text fields + const allTextInputs = [ + body.email, + body.firstName, + body.lastName, + body.username, + body.companyName + ].filter(Boolean).join(' ') + + const suspiciousPatterns = detectSuspiciousPatterns(allTextInputs) + if (suspiciousPatterns.length > 0) { + console.warn(`Suspicious signup attempt from ${getClientIP(request)}:`, suspiciousPatterns) + return NextResponse.json( + { error: 'Invalid input detected' }, + { status: 400 } + ) + } + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid input' }, + { status: 400 } + ) + } + + // Comprehensive password validation (matching backend requirements) + const passwordValidation = validatePassword(password) + if (!passwordValidation.isValid) { + return NextResponse.json( + { error: passwordValidation.message }, + { status: 400 } + ) + } + + // Forward sanitized data to Python backend + const response = await fetch(`${AUTH_SERVICE_URL}/auth/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: sanitizedData.email, + password, + first_name: sanitizedData.firstName, + last_name: sanitizedData.lastName, + username: sanitizedData.username || undefined, + company_name: sanitizedData.companyName || undefined, + team_size: sanitizedData.teamSize || undefined + }), + }) + + const data = await response.json() + + if (response.ok) { + // Validate user data from backend + if (!data.user || !data.user.id || !data.user.email) { + return NextResponse.json( + { error: 'Invalid user data received from server' }, + { status: 500 } + ) + } + + // Set secure HTTP-only cookies + await setSessionCookies(data.user, data.token) + + // Return success without sensitive data + return NextResponse.json({ + message: 'Registration successful', + user: { + id: data.user.id, + email: data.user.email, + first_name: data.user.first_name, + last_name: data.user.last_name, + username: data.user.username, + company_name: data.user.company_name, + team_size: data.user.team_size, + is_active: data.user.is_active + } + }) + } else { + // Return error from backend + return NextResponse.json( + { error: data.detail || data.error || 'Registration failed' }, + { status: response.status } + ) + } + + } catch (error) { + console.error('Registration API error:', error) + + if (error instanceof TypeError && error.message.includes('fetch')) { + return NextResponse.json( + { error: 'Auth service is temporarily unavailable. Please try again in a moment.' }, + { status: 503 } + ) + } + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed' }, + { status: 405 } + ) +} \ No newline at end of file diff --git a/landing-next/app/api/auth/update-profile/route.ts b/landing-next/app/api/auth/update-profile/route.ts new file mode 100644 index 0000000..b1edb3d --- /dev/null +++ b/landing-next/app/api/auth/update-profile/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAuth, getAuthToken } from '../../../../lib/auth' +import { createSecureResponse, createValidationErrorResponse, logSecurityEvent } from '../../../../lib/security' +import { sanitizeUserRegistrationData, detectSuspiciousPatterns } from '../../../../lib/sanitization' + +const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL || 'http://localhost:8003' + +export async function PUT(request: NextRequest) { + try { + // Use secure session-based authentication + const userOrResponse = await requireAuth(request) + + if (userOrResponse instanceof Response) { + return userOrResponse // Return the error response + } + + // Get and validate request body + const body = await request.json() + const { first_name, last_name, email, company_name, team_size } = body + + // Validate required fields + if (!first_name || !last_name || !email) { + return createValidationErrorResponse('first_name, last_name, and email are required') + } + + // Sanitize and validate input data + let sanitizedData + try { + sanitizedData = sanitizeUserRegistrationData({ + firstName: first_name, + lastName: last_name, + email, + companyName: company_name, + teamSize: team_size + }) + + // Check for suspicious patterns + const allTextInputs = [ + first_name, + last_name, + email, + company_name + ].filter(Boolean).join(' ') + + const suspiciousPatterns = detectSuspiciousPatterns(allTextInputs) + if (suspiciousPatterns.length > 0) { + logSecurityEvent('SUSPICIOUS_PROFILE_UPDATE', { + userId: userOrResponse.id, + patterns: suspiciousPatterns + }, request) + return createValidationErrorResponse('Invalid input detected') + } + } catch (error) { + return createValidationErrorResponse(error instanceof Error ? error.message : 'Invalid input') + } + + // Get auth token for backend API call + const authToken = await getAuthToken() + if (!authToken) { + return createValidationErrorResponse('Authentication token not found') + } + + // Update user profile via auth service + const response = await fetch(`${AUTH_SERVICE_URL}/users/${userOrResponse.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ + first_name: sanitizedData.firstName, + last_name: sanitizedData.lastName, + email: sanitizedData.email, + company_name: sanitizedData.companyName, + team_size: sanitizedData.teamSize + }) + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + return createSecureResponse( + { detail: data.detail || 'Failed to update profile' }, + { status: response.status } + ) + } + + logSecurityEvent('PROFILE_UPDATED', { + userId: userOrResponse.id, + updatedFields: Object.keys(sanitizedData).filter(key => sanitizedData[key as keyof typeof sanitizedData] !== undefined) + }, request) + + return createSecureResponse(data) + + } catch (error) { + console.error('Profile update error:', error) + return createSecureResponse({ detail: 'Internal server error' }, { status: 500 }) + } +} diff --git a/landing-next/app/api/dashboard/stats/route.ts b/landing-next/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..732819e --- /dev/null +++ b/landing-next/app/api/dashboard/stats/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('user_id') + + if (!userId) { + return NextResponse.json({ detail: 'user_id parameter required' }, { status: 400 }) + } + + const workspacesResponse = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/workspaces/by-owner/${userId}`) + const workspaces = workspacesResponse.ok ? await workspacesResponse.json() : [] + + // Fetch files stats + const filesResponse = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/files/?user_id=${userId}&limit=1000`) + const files = filesResponse.ok ? await filesResponse.json() : [] + + // Calculate stats + const stats = { + total_files: files.length, + processed_files: files.filter((f: any) => f.status === 2).length, + processing_files: files.filter((f: any) => f.status === 1).length, + error_files: files.filter((f: any) => f.status === 3).length, + total_workspaces: workspaces.length + } + + return NextResponse.json(stats) + + } catch (error) { + console.error('Dashboard stats error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/file-content/route.ts b/landing-next/app/api/file-content/route.ts new file mode 100644 index 0000000..409e85a --- /dev/null +++ b/landing-next/app/api/file-content/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const s3Key = searchParams.get('s3_key') + + if (!s3Key) { + return NextResponse.json({ detail: 's3_key parameter required' }, { status: 400 }) + } + + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/file-content/?s3_key=${encodeURIComponent(s3Key)}`) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to fetch file content' }, { status: response.status }) + } + + const content = await response.json() + return NextResponse.json(content) + + } catch (error) { + console.error('File content fetch error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/files/[id]/route.ts b/landing-next/app/api/files/[id]/route.ts new file mode 100644 index 0000000..6de040b --- /dev/null +++ b/landing-next/app/api/files/[id]/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/files/${params.id}`, { + method: 'DELETE' + }) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to delete file' }, { status: response.status }) + } + + const result = await response.json() + return NextResponse.json(result) + + } catch (error) { + console.error('File deletion error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/files/[id]/status/route.ts b/landing-next/app/api/files/[id]/status/route.ts new file mode 100644 index 0000000..0765fcb --- /dev/null +++ b/landing-next/app/api/files/[id]/status/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/files/${params.id}/status`) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to fetch file status' }, { status: response.status }) + } + + const status = await response.json() + return NextResponse.json(status) + + } catch (error) { + console.error('File status fetch error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} diff --git a/landing-next/app/api/files/route.ts b/landing-next/app/api/files/route.ts new file mode 100644 index 0000000..4110d00 --- /dev/null +++ b/landing-next/app/api/files/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const userId = searchParams.get('user_id') + const workspaceId = searchParams.get('workspace_id') + const limit = searchParams.get('limit') || '50' + + if (!userId) { + return NextResponse.json({ detail: 'user_id parameter required' }, { status: 400 }) + } + + let url = `${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/files/?user_id=${userId}&limit=${limit}` + if (workspaceId) { + url += `&workspace_id=${workspaceId}` + } + + const response = await fetch(url) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to fetch files' }, { status: response.status }) + } + + const files = await response.json() + return NextResponse.json(files) + + } catch (error) { + console.error('Files fetch error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/search/route.ts b/landing-next/app/api/search/route.ts new file mode 100644 index 0000000..612c33b --- /dev/null +++ b/landing-next/app/api/search/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const query = searchParams.get('query') + const workspaceId = searchParams.get('workspace_id') + + if (!query) { + return NextResponse.json({ detail: 'query parameter required' }, { status: 400 }) + } + + if (!workspaceId) { + return NextResponse.json({ detail: 'workspace_id parameter required' }, { status: 400 }) + } + + const response = await fetch(`${process.env.AI_AGENT_SERVICE_URL || 'http://localhost:8001'}/search/?query=${encodeURIComponent(query)}&workspace_id=${workspaceId}`) + + if (!response.ok) { + return NextResponse.json({ detail: 'Search failed' }, { status: response.status }) + } + + const results = await response.json() + return NextResponse.json(results) + + } catch (error) { + console.error('Search error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/upload/route.ts b/landing-next/app/api/upload/route.ts new file mode 100644 index 0000000..f67aa8b --- /dev/null +++ b/landing-next/app/api/upload/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const workspaceId = searchParams.get('workspace_id') + + if (!workspaceId) { + return NextResponse.json({ detail: 'workspace_id parameter required' }, { status: 400 }) + } + + const formData = await request.formData() + + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/uploadfile/?workspace_id=${workspaceId}`, { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.json() + return NextResponse.json({ detail: error.detail || 'Upload failed' }, { status: response.status }) + } + + const result = await response.json() + + // Forward the Location header for status polling + const location = response.headers.get('Location') + const nextResponse = NextResponse.json(result, { status: response.status }) + if (location) { + nextResponse.headers.set('Location', location) + } + + return nextResponse + + } catch (error) { + console.error('Upload error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} diff --git a/landing-next/app/api/users/route.ts b/landing-next/app/api/users/route.ts new file mode 100644 index 0000000..83f656f --- /dev/null +++ b/landing-next/app/api/users/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const username = searchParams.get('username') + + if (!username) { + return NextResponse.json({ detail: 'username parameter required' }, { status: 400 }) + } + + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/users/?username=${encodeURIComponent(username)}`) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to fetch users' }, { status: response.status }) + } + + const users = await response.json() + return NextResponse.json(users) + + } catch (error) { + console.error('Users fetch error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { username } = body + + if (!username) { + return NextResponse.json({ detail: 'username is required' }, { status: 400 }) + } + + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/users/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username }) + }) + + if (!response.ok) { + const error = await response.json() + return NextResponse.json({ detail: error.detail || 'Failed to create user' }, { status: response.status }) + } + + const user = await response.json() + return NextResponse.json(user) + + } catch (error) { + console.error('User creation error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/api/workspaces/route.ts b/landing-next/app/api/workspaces/route.ts new file mode 100644 index 0000000..57a7430 --- /dev/null +++ b/landing-next/app/api/workspaces/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const ownerId = searchParams.get('owner_id') + + if (!ownerId) { + return NextResponse.json({ detail: 'owner_id parameter required' }, { status: 400 }) + } + + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/workspaces/by-owner/${ownerId}`) + + if (!response.ok) { + return NextResponse.json({ detail: 'Failed to fetch workspaces' }, { status: response.status }) + } + + const workspaces = await response.json() + return NextResponse.json(workspaces) + + } catch (error) { + console.error('Workspaces fetch error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { name, owner_id, description } = body + + const response = await fetch(`${process.env.INGESTION_SERVICE_URL || 'http://localhost:8000'}/workspaces/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + owner_id, + description + }) + }) + + if (!response.ok) { + const error = await response.json() + return NextResponse.json({ detail: error.detail || 'Failed to create workspace' }, { status: response.status }) + } + + const workspace = await response.json() + return NextResponse.json(workspace) + + } catch (error) { + console.error('Workspace creation error:', error) + return NextResponse.json({ detail: 'Internal server error' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/landing-next/app/components/AIChatPage.tsx b/landing-next/app/components/AIChatPage.tsx new file mode 100644 index 0000000..8f86e26 --- /dev/null +++ b/landing-next/app/components/AIChatPage.tsx @@ -0,0 +1,608 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { useRouter } from 'next/navigation' +import { motion } from 'framer-motion' +import { + Send, + Bot, + User, + Folder, + FileText, + MessageCircle, + Trash2, + Eye, + MoreVertical +} from 'lucide-react' +import { GlassCard } from '../components/GlassCard' +import { GradientOrbs } from '../components/GradientOrbs' + +interface User { + id: number + email: string + username?: string + first_name?: string + last_name?: string +} + +interface Workspace { + id: number + name: string + description?: string + owner_id: number + created_at: string +} + +interface Message { + id: string + role: 'user' | 'assistant' | 'system' + content: string + timestamp: Date + sources?: RetrievedDocument[] +} + +interface RetrievedDocument { + s3_key: string + score: number + content: string + workspace_name: string +} + +export default function AIChatPage() { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [workspaces, setWorkspaces] = useState([]) + const [selectedWorkspace, setSelectedWorkspace] = useState(null) + const [messages, setMessages] = useState([]) + const [inputMessage, setInputMessage] = useState('') + const [isTyping, setIsTyping] = useState(false) + const [retrievedSources, setRetrievedSources] = useState([]) + const [showSources, setShowSources] = useState(false) + const [selectedDocument, setSelectedDocument] = useState(null) + const [showClearConfirm, setShowClearConfirm] = useState(false) + + const messagesEndRef = useRef(null) + const router = useRouter() + + useEffect(() => { + checkAuth() + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages]) + + const checkAuth = async () => { + try { + // Check authentication using the same logic as FileUploadPage + const authResponse = await fetch('/api/auth/me') + if (authResponse.ok) { + const userData = await authResponse.json() + setUser(userData) + + // Get the ingestion service user and load workspaces + const username = userData.username || `${userData.first_name}_${userData.last_name}`.toLowerCase() + console.log(`[DEBUG] AI Chat - Loading workspaces for username: ${username}`) + const ingestionUser = await getOrCreateUser(username) + if (ingestionUser) { + await loadWorkspaces(ingestionUser.id) + } + } else { + router.push('/login') + } + } catch (error) { + console.error('Failed to initialize chat:', error) + addSystemMessage('Failed to initialize chat. Please refresh the page.') + router.push('/login') + } finally { + setIsLoading(false) + } + } + + const getOrCreateUser = async (username: string) => { + try { + console.log(`[DEBUG] AI Chat - Getting/creating user: ${username}`) + + // First, try to find the user + const existingResponse = await fetch(`/api/users?username=${encodeURIComponent(username)}`) + if (existingResponse.ok) { + const existingUsers = await existingResponse.json() + if (existingUsers && existingUsers.length > 0) { + console.log(`[DEBUG] AI Chat - Found existing user:`, existingUsers[0]) + return existingUsers[0] + } + } + + // If user doesn't exist, create them + const createResponse = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }) + }) + + if (createResponse.ok) { + const newUser = await createResponse.json() + console.log(`[DEBUG] AI Chat - Created new user:`, newUser) + return newUser + } else { + console.error(`[DEBUG] AI Chat - Failed to create user, status: ${createResponse.status}`) + return null + } + } catch (error) { + console.error('Failed to get/create user:', error) + return null + } + } + + const loadWorkspaces = async (userId: number) => { + try { + console.log(`[DEBUG] AI Chat - Loading workspaces for userId: ${userId}`) + const response = await fetch(`/api/workspaces?owner_id=${userId}`) + if (response.ok) { + const workspaceData = await response.json() + console.log(`[DEBUG] AI Chat - Loaded ${workspaceData.length} workspaces:`, workspaceData) + setWorkspaces(workspaceData) + if (workspaceData.length > 0) { + setSelectedWorkspace(workspaceData[0].id) + addSystemMessage(`Connected to workspace: ${workspaceData[0].name}`) + } else { + addSystemMessage('No workspaces available. Please create a workspace and upload documents first.') + } + } else { + console.error(`[DEBUG] AI Chat - Failed to load workspaces, status: ${response.status}`) + addSystemMessage('Failed to load workspaces. Please refresh the page.') + } + } catch (error) { + console.error('Failed to load workspaces:', error) + addSystemMessage('Failed to load workspaces. Please refresh the page.') + } + } + + const addSystemMessage = (content: string) => { + const systemMessage: Message = { + id: Date.now().toString(), + role: 'system', + content, + timestamp: new Date() + } + setMessages(prev => [...prev, systemMessage]) + } + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + const handleWorkspaceChange = (workspaceId: number) => { + setSelectedWorkspace(workspaceId) + const workspace = workspaces.find(w => w.id === workspaceId) + addSystemMessage(`Switched to workspace: ${workspace?.name || 'Unknown'}`) + setRetrievedSources([]) + } + + const sendMessage = async () => { + if (!inputMessage.trim() || !selectedWorkspace || isTyping) return + + const userMessage: Message = { + id: Date.now().toString(), + role: 'user', + content: inputMessage.trim(), + timestamp: new Date() + } + + setMessages(prev => [...prev, userMessage]) + setInputMessage('') + setIsTyping(true) + + try { + // Create conversation history for context + const conversation = messages + .filter(msg => msg.role !== 'system') + .map(msg => ({ + role: msg.role, + content: msg.content + })) + + conversation.push({ role: 'user', content: userMessage.content }) + + const response = await fetch('/api/ai-agent/conversation', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: conversation, + workspace_id: selectedWorkspace + }) + }) + + if (response.ok) { + const data = await response.json() + + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: data.response, + timestamp: new Date(), + sources: data.relevant_docs || [] + } + + setMessages(prev => [...prev, assistantMessage]) + setRetrievedSources(data.relevant_docs || []) + } else { + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: 'I apologize, but I encountered an error while processing your request. Please try again.', + timestamp: new Date() + } + setMessages(prev => [...prev, assistantMessage]) + } + } catch (error) { + console.error('Error sending message:', error) + const assistantMessage: Message = { + id: (Date.now() + 1).toString(), + role: 'assistant', + content: 'I apologize, but I encountered a network error. Please check your connection and try again.', + timestamp: new Date() + } + setMessages(prev => [...prev, assistantMessage]) + } finally { + setIsTyping(false) + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage() + } + } + + const clearConversation = () => { + setShowClearConfirm(true) + } + + const confirmClearConversation = () => { + setMessages([]) + setRetrievedSources([]) + if (selectedWorkspace) { + const workspace = workspaces.find(w => w.id === selectedWorkspace) + addSystemMessage(`Conversation cleared. Connected to workspace: ${workspace?.name || 'Unknown'}`) + } + setShowClearConfirm(false) + } + + const formatSourceName = (s3Key: string) => { + const parts = s3Key.split('/') + return parts[parts.length - 1] + } + + if (isLoading) { + return ( +
+
+
+ Loading... +
+
+ ) + } + + return ( +
+ + +
+ {/* Header */} +
+
+ +
+

AI Assistant

+

Chat with your documents

+
+
+ +
+ {/* Workspace Selector */} +
+ + +
+ + {/* Clear Chat */} + +
+
+ + {/* Main Content */} +
+ {/* Chat Area */} +
+ + {/* Messages */} +
+ {messages.map((message) => ( + + ))} + + {isTyping && ( +
+
+ +
+
+
+
+
+
+
+
+
+ )} + +
+
+ + {/* Input */} +
+
+
+