diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..659f11a --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,146 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, field_validator +from sqlalchemy import Boolean, Column, DateTime, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import declarative_base, relationship + +Base = declarative_base() + + +class UserTable(Base): + """SQLAlchemy User table model for PostgreSQL""" + + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=False) + avatar_url = Column(Text, nullable=True) + google_id = Column(String(255), unique=True, nullable=True, index=True) + is_active = Column(Boolean, default=True, nullable=False) + is_verified = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column( + DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + last_sign_in_at = Column(DateTime, nullable=True) + + # Relationships + projects = relationship( + "ProjectTable", back_populates="user", cascade="all, delete" + ) + chat_messages = relationship( + "ChatMessageTable", back_populates="user", cascade="all, delete" + ) + + def __repr__(self): + return f"" + + +class UserCreate(BaseModel): + """Pydantic model for creating a user""" + + email: EmailStr = Field(..., description="User email address") + name: str = Field(..., min_length=1, max_length=255, description="User full name") + avatar_url: Optional[str] = Field(None, description="User avatar URL") + google_id: Optional[str] = Field(None, description="Google OAuth ID") + + @field_validator("name") + @classmethod + def validate_name(cls, v): + if not v or not v.strip(): + raise ValueError("Name cannot be empty or just whitespace") + return v.strip() + + @field_validator("avatar_url") + @classmethod + def validate_avatar_url(cls, v): + if v and not v.startswith(("http://", "https://")): + raise ValueError("Avatar URL must be a valid HTTP/HTTPS URL") + return v + + +class UserUpdate(BaseModel): + """Pydantic model for updating a user""" + + name: Optional[str] = Field(None, min_length=1, max_length=255) + avatar_url: Optional[str] = Field(None) + is_active: Optional[bool] = Field(None) + is_verified: Optional[bool] = Field(None) + last_sign_in_at: Optional[datetime] = Field(None) + + @field_validator("name") + @classmethod + def validate_name(cls, v): + if v is not None and (not v or not v.strip()): + raise ValueError("Name cannot be empty or just whitespace") + return v.strip() if v else v + + @field_validator("avatar_url") + @classmethod + def validate_avatar_url(cls, v): + if v and not v.startswith(("http://", "https://")): + raise ValueError("Avatar URL must be a valid HTTP/HTTPS URL") + return v + + +class UserInDB(BaseModel): + """Pydantic model for user data from database""" + + id: uuid.UUID + email: str + name: str + avatar_url: Optional[str] = None + google_id: Optional[str] = None + is_active: bool + is_verified: bool + created_at: datetime + updated_at: datetime + last_sign_in_at: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +class UserPublic(BaseModel): + """Pydantic model for public user data (API responses)""" + + id: str + email: str + name: str + avatar_url: Optional[str] = None + created_at: str + last_sign_in_at: Optional[str] = None + + @classmethod + def from_db_user(cls, user: UserInDB) -> "UserPublic": + """Convert database user to public user model""" + return cls( + id=str(user.id), + email=user.email, + name=user.name, + avatar_url=user.avatar_url, + created_at=user.created_at.isoformat() + "Z", + last_sign_in_at=( + user.last_sign_in_at.isoformat() + "Z" if user.last_sign_in_at else None + ), + ) + + +class GoogleOAuthData(BaseModel): + """Pydantic model for Google OAuth data""" + + google_id: str + email: EmailStr + name: str + avatar_url: Optional[str] = None + email_verified: bool = False + + @field_validator("google_id") + @classmethod + def validate_google_id(cls, v): + if not v or not v.strip(): + raise ValueError("Google ID cannot be empty") + return v.strip() diff --git a/backend/requirements.txt b/backend/requirements.txt index 6dc387c..5fea5c7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,6 +28,9 @@ python-multipart==0.0.18 # JWT authentication PyJWT==2.8.0 +# Email validation +email-validator==2.1.0 + # Future dependencies (commented for now, will be added in later tasks) # langchain==0.1.0 # openai==1.3.0 diff --git a/backend/services/database_service.py b/backend/services/database_service.py index 974870b..757de4b 100644 --- a/backend/services/database_service.py +++ b/backend/services/database_service.py @@ -79,6 +79,50 @@ def get_session(self): self.connect() return self.SessionLocal() + def create_tables(self): + """Create database tables using SQLAlchemy models""" + try: + from models.user import Base + + if not self.engine: + self.connect() + + # Create all tables + Base.metadata.create_all(bind=self.engine) + logger.info("Database tables created successfully") + return True + + except Exception as e: + logger.error(f"Failed to create tables: {str(e)}") + return False + + def run_migration(self, migration_file: str) -> bool: + """Run a SQL migration file""" + try: + if not self.engine: + self.connect() + + migration_path = f"database/migrations/{migration_file}" + + if not os.path.exists(migration_path): + logger.error(f"Migration file not found: {migration_path}") + return False + + with open(migration_path, "r") as f: + migration_sql = f.read() + + with self.engine.connect() as conn: + # Execute migration + conn.execute(text(migration_sql)) + conn.commit() + + logger.info(f"Migration {migration_file} executed successfully") + return True + + except Exception as e: + logger.error(f"Failed to run migration {migration_file}: {str(e)}") + return False + # Global database service instance db_service = DatabaseService() diff --git a/backend/services/user_service.py b/backend/services/user_service.py new file mode 100644 index 0000000..98081d3 --- /dev/null +++ b/backend/services/user_service.py @@ -0,0 +1,251 @@ +import uuid +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import and_, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from models.user import ( + GoogleOAuthData, + UserCreate, + UserInDB, + UserPublic, + UserTable, + UserUpdate, +) +from services.database_service import db_service + + +class UserService: + """Service for user database operations""" + + def __init__(self): + self.db_service = db_service + + def create_user(self, user_data: UserCreate) -> UserInDB: + """Create a new user in the database""" + with self.db_service.get_session() as session: + try: + # Check if user already exists + existing_user = self.get_user_by_email(user_data.email) + if existing_user: + raise ValueError( + f"User with email {user_data.email} already exists" + ) + + # Create new user + db_user = UserTable( + email=user_data.email, + name=user_data.name, + avatar_url=user_data.avatar_url, + google_id=user_data.google_id, + is_verified=True if user_data.google_id else False, + ) + + session.add(db_user) + session.commit() + session.refresh(db_user) + + return UserInDB.model_validate(db_user) + + except IntegrityError as e: + session.rollback() + if "users_email_key" in str(e): + raise ValueError( + f"User with email {user_data.email} already exists" + ) + elif "users_google_id_key" in str(e): + raise ValueError(f"User with Google ID already exists") + else: + raise ValueError(f"Database error: {str(e)}") + + def get_user_by_id(self, user_id: uuid.UUID) -> Optional[UserInDB]: + """Get user by ID""" + with self.db_service.get_session() as session: + user = session.query(UserTable).filter(UserTable.id == user_id).first() + return UserInDB.model_validate(user) if user else None + + def get_user_by_email(self, email: str) -> Optional[UserInDB]: + """Get user by email address""" + with self.db_service.get_session() as session: + user = session.query(UserTable).filter(UserTable.email == email).first() + return UserInDB.model_validate(user) if user else None + + def get_user_by_google_id(self, google_id: str) -> Optional[UserInDB]: + """Get user by Google OAuth ID""" + with self.db_service.get_session() as session: + user = ( + session.query(UserTable) + .filter(UserTable.google_id == google_id) + .first() + ) + return UserInDB.model_validate(user) if user else None + + def update_user(self, user_id: uuid.UUID, user_update: UserUpdate) -> UserInDB: + """Update user information""" + with self.db_service.get_session() as session: + user = session.query(UserTable).filter(UserTable.id == user_id).first() + + if not user: + raise ValueError(f"User with ID {user_id} not found") + + # Update only provided fields + update_data = user_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(user, field, value) + + try: + session.commit() + session.refresh(user) + return UserInDB.model_validate(user) + + except IntegrityError as e: + session.rollback() + raise ValueError(f"Update failed: {str(e)}") + + def update_last_sign_in(self, user_id: uuid.UUID) -> UserInDB: + """Update user's last sign-in timestamp""" + return self.update_user(user_id, UserUpdate(last_sign_in_at=datetime.utcnow())) + + def deactivate_user(self, user_id: uuid.UUID) -> UserInDB: + """Deactivate a user account""" + return self.update_user(user_id, UserUpdate(is_active=False)) + + def activate_user(self, user_id: uuid.UUID) -> UserInDB: + """Activate a user account""" + return self.update_user(user_id, UserUpdate(is_active=True)) + + def verify_user_email(self, user_id: uuid.UUID) -> UserInDB: + """Mark user email as verified""" + return self.update_user(user_id, UserUpdate(is_verified=True)) + + def get_users( + self, + skip: int = 0, + limit: int = 100, + active_only: bool = True, + search: Optional[str] = None, + ) -> List[UserInDB]: + """Get list of users with optional filtering""" + with self.db_service.get_session() as session: + query = session.query(UserTable) + + # Filter by active status + if active_only: + query = query.filter(UserTable.is_active == True) + + # Search filter + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + UserTable.name.ilike(search_term), + UserTable.email.ilike(search_term), + ) + ) + + # Pagination + users = query.offset(skip).limit(limit).all() + return [UserInDB.model_validate(user) for user in users] + + def count_users(self, active_only: bool = True) -> int: + """Count total number of users""" + with self.db_service.get_session() as session: + query = session.query(UserTable) + if active_only: + query = query.filter(UserTable.is_active == True) + return query.count() + + def delete_user(self, user_id: uuid.UUID) -> bool: + """Delete a user (hard delete)""" + with self.db_service.get_session() as session: + user = session.query(UserTable).filter(UserTable.id == user_id).first() + + if not user: + return False + + session.delete(user) + session.commit() + return True + + def create_or_update_from_google_oauth( + self, google_data: GoogleOAuthData + ) -> tuple[UserInDB, bool]: + """Create or update user from Google OAuth data + + Returns: + tuple: (UserInDB, is_new_user) + """ + # Try to find existing user by Google ID first + existing_user = self.get_user_by_google_id(google_data.google_id) + + if existing_user: + # Update existing user with latest Google data + updated_user = self.update_user( + existing_user.id, + UserUpdate( + name=google_data.name, + avatar_url=google_data.avatar_url, + is_verified=google_data.email_verified, + last_sign_in_at=datetime.utcnow(), + ), + ) + return updated_user, False + + # Try to find by email (in case user exists but no Google ID) + existing_user = self.get_user_by_email(google_data.email) + + if existing_user: + # Link Google account to existing user + updated_user = self.update_user( + existing_user.id, + UserUpdate( + google_id=google_data.google_id, + name=google_data.name, + avatar_url=google_data.avatar_url, + is_verified=google_data.email_verified, + last_sign_in_at=datetime.utcnow(), + ), + ) + return updated_user, False + + # Create new user from Google data + new_user_data = UserCreate( + email=google_data.email, + name=google_data.name, + avatar_url=google_data.avatar_url, + google_id=google_data.google_id, + ) + + new_user = self.create_user(new_user_data) + # Update sign-in time + updated_user = self.update_last_sign_in(new_user.id) + return updated_user, True + + def get_user_public(self, user_id: uuid.UUID) -> Optional[UserPublic]: + """Get public user data for API responses""" + user = self.get_user_by_id(user_id) + return UserPublic.from_db_user(user) if user else None + + def health_check(self) -> dict: + """Check if user service and database connection is healthy""" + try: + with self.db_service.get_session() as session: + # Try to count users + user_count = session.query(UserTable).count() + return { + "status": "healthy", + "message": f"User service operational. Total users: {user_count}", + "user_count": user_count, + } + except Exception as e: + return { + "status": "unhealthy", + "message": f"User service error: {str(e)}", + "user_count": 0, + } + + +# Global instance +user_service = UserService() diff --git a/backend/tests/test_user_models.py b/backend/tests/test_user_models.py new file mode 100644 index 0000000..f00971e --- /dev/null +++ b/backend/tests/test_user_models.py @@ -0,0 +1,206 @@ +import uuid +from datetime import datetime + +import pytest +from pydantic import ValidationError + +from models.user import ( + GoogleOAuthData, + UserCreate, + UserInDB, + UserPublic, + UserUpdate, +) + + +class TestUserModels: + """Test suite for User Pydantic models""" + + def test_user_create_valid(self): + """Test creating valid UserCreate model""" + user_data = UserCreate( + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + google_id="google_123", + ) + + assert user_data.email == "test@example.com" + assert user_data.name == "Test User" + assert user_data.avatar_url == "https://example.com/avatar.jpg" + assert user_data.google_id == "google_123" + + def test_user_create_minimal(self): + """Test creating UserCreate with minimal data""" + user_data = UserCreate( + email="minimal@example.com", + name="Minimal User", + ) + + assert user_data.email == "minimal@example.com" + assert user_data.name == "Minimal User" + assert user_data.avatar_url is None + assert user_data.google_id is None + + def test_user_create_invalid_email(self): + """Test UserCreate with invalid email""" + with pytest.raises(ValidationError): + UserCreate( + email="invalid-email", + name="Test User", + ) + + def test_user_create_empty_name(self): + """Test UserCreate with empty name""" + with pytest.raises(ValidationError): + UserCreate( + email="test@example.com", + name="", + ) + + def test_user_create_invalid_avatar_url(self): + """Test UserCreate with invalid avatar URL""" + with pytest.raises(ValidationError): + UserCreate( + email="test@example.com", + name="Test User", + avatar_url="not-a-url", + ) + + def test_user_create_name_whitespace(self): + """Test UserCreate trims whitespace from name""" + user_data = UserCreate( + email="test@example.com", + name=" Test User ", + ) + assert user_data.name == "Test User" + + def test_user_update_valid(self): + """Test creating valid UserUpdate model""" + update_data = UserUpdate( + name="Updated Name", + avatar_url="https://example.com/new-avatar.jpg", + is_active=False, + is_verified=True, + last_sign_in_at=datetime.now(), + ) + + assert update_data.name == "Updated Name" + assert update_data.avatar_url == "https://example.com/new-avatar.jpg" + assert update_data.is_active is False + assert update_data.is_verified is True + assert isinstance(update_data.last_sign_in_at, datetime) + + def test_user_update_partial(self): + """Test UserUpdate with partial data""" + update_data = UserUpdate(name="Partial Update") + + assert update_data.name == "Partial Update" + assert update_data.avatar_url is None + assert update_data.is_active is None + + def test_user_in_db_model(self): + """Test UserInDB model creation""" + user_id = uuid.uuid4() + created_at = datetime.now() + updated_at = datetime.now() + + user_db = UserInDB( + id=user_id, + email="db@example.com", + name="DB User", + avatar_url="https://example.com/avatar.jpg", + google_id="google_db_123", + is_active=True, + is_verified=True, + created_at=created_at, + updated_at=updated_at, + last_sign_in_at=None, + ) + + assert user_db.id == user_id + assert user_db.email == "db@example.com" + assert user_db.name == "DB User" + assert user_db.is_active is True + assert user_db.is_verified is True + assert user_db.created_at == created_at + assert user_db.updated_at == updated_at + + def test_user_public_from_db_user(self): + """Test converting UserInDB to UserPublic""" + user_id = uuid.uuid4() + created_at = datetime.now() + + user_db = UserInDB( + id=user_id, + email="public@example.com", + name="Public User", + avatar_url="https://example.com/avatar.jpg", + google_id="google_public_123", + is_active=True, + is_verified=True, + created_at=created_at, + updated_at=created_at, + last_sign_in_at=created_at, + ) + + public_user = UserPublic.from_db_user(user_db) + + assert public_user.id == str(user_id) + assert public_user.email == "public@example.com" + assert public_user.name == "Public User" + assert public_user.avatar_url == "https://example.com/avatar.jpg" + assert public_user.created_at == created_at.isoformat() + "Z" + assert public_user.last_sign_in_at == created_at.isoformat() + "Z" + # Should not expose sensitive fields + assert not hasattr(public_user, "google_id") + assert not hasattr(public_user, "is_active") + assert not hasattr(public_user, "is_verified") + + def test_google_oauth_data_valid(self): + """Test valid GoogleOAuthData model""" + google_data = GoogleOAuthData( + google_id="google_oauth_123", + email="oauth@example.com", + name="OAuth User", + avatar_url="https://example.com/oauth-avatar.jpg", + email_verified=True, + ) + + assert google_data.google_id == "google_oauth_123" + assert google_data.email == "oauth@example.com" + assert google_data.name == "OAuth User" + assert google_data.avatar_url == "https://example.com/oauth-avatar.jpg" + assert google_data.email_verified is True + + def test_google_oauth_data_empty_google_id(self): + """Test GoogleOAuthData with empty Google ID""" + with pytest.raises(ValidationError): + GoogleOAuthData( + google_id="", + email="oauth@example.com", + name="OAuth User", + ) + + def test_google_oauth_data_minimal(self): + """Test GoogleOAuthData with minimal data""" + google_data = GoogleOAuthData( + google_id="minimal_google_123", + email="minimal@example.com", + name="Minimal OAuth User", + ) + + assert google_data.google_id == "minimal_google_123" + assert google_data.email == "minimal@example.com" + assert google_data.name == "Minimal OAuth User" + assert google_data.avatar_url is None + assert google_data.email_verified is False # Default value + + def test_google_oauth_data_whitespace_google_id(self): + """Test GoogleOAuthData trims whitespace from Google ID""" + google_data = GoogleOAuthData( + google_id=" google_trimmed_123 ", + email="trim@example.com", + name="Trim User", + ) + assert google_data.google_id == "google_trimmed_123" diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py new file mode 100644 index 0000000..e91fad7 --- /dev/null +++ b/backend/tests/test_user_service.py @@ -0,0 +1,209 @@ +import uuid +from datetime import datetime + +import pytest + +from models.user import ( + GoogleOAuthData, + UserCreate, + UserInDB, + UserPublic, + UserUpdate, +) + + +class TestUserServiceModels: + """Test suite for User models and basic validation""" + + @pytest.fixture + def sample_user_data(self): + """Sample user data for testing""" + return UserCreate( + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + google_id="google_123", + ) + + @pytest.fixture + def sample_google_data(self): + """Sample Google OAuth data""" + return GoogleOAuthData( + google_id="google_123", + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + email_verified=True, + ) + + @pytest.fixture + def sample_user_in_db(self): + """Sample UserInDB instance""" + return UserInDB( + id=uuid.uuid4(), + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + google_id="google_123", + is_active=True, + is_verified=True, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + def test_user_create_validation(self): + """Test UserCreate model validation""" + # Valid user creation + user = UserCreate( + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + google_id="google_123", + ) + assert user.email == "test@example.com" + assert user.name == "Test User" + + # Test with minimal data + minimal_user = UserCreate( + email="minimal@example.com", + name="Minimal User", + ) + assert minimal_user.avatar_url is None + assert minimal_user.google_id is None + + def test_user_create_email_validation(self): + """Test email validation in UserCreate""" + with pytest.raises(ValueError): + UserCreate( + email="invalid-email", + name="Test User", + ) + + def test_user_create_name_validation(self): + """Test name validation in UserCreate""" + with pytest.raises(ValueError): + UserCreate( + email="test@example.com", + name="", + ) + + with pytest.raises(ValueError): + UserCreate( + email="test@example.com", + name=" ", + ) + + # Test name trimming + user = UserCreate( + email="test@example.com", + name=" Test User ", + ) + assert user.name == "Test User" + + def test_user_create_avatar_url_validation(self): + """Test avatar URL validation""" + with pytest.raises(ValueError): + UserCreate( + email="test@example.com", + name="Test User", + avatar_url="invalid-url", + ) + + # Valid URLs should work + user = UserCreate( + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + ) + assert user.avatar_url == "https://example.com/avatar.jpg" + + def test_user_update_model(self): + """Test UserUpdate model""" + # Test partial update + update = UserUpdate(name="Updated Name") + assert update.name == "Updated Name" + assert update.avatar_url is None + + # Test full update + full_update = UserUpdate( + name="Updated Name", + avatar_url="https://example.com/new-avatar.jpg", + is_active=False, + is_verified=True, + last_sign_in_at=datetime.utcnow(), + ) + assert full_update.name == "Updated Name" + assert full_update.is_active is False + + def test_user_in_db_model(self, sample_user_in_db): + """Test UserInDB model""" + assert isinstance(sample_user_in_db.id, uuid.UUID) + assert sample_user_in_db.email == "test@example.com" + assert sample_user_in_db.is_active is True + assert isinstance(sample_user_in_db.created_at, datetime) + + def test_user_public_conversion(self, sample_user_in_db): + """Test UserPublic conversion from UserInDB""" + public_user = UserPublic.from_db_user(sample_user_in_db) + + assert isinstance(public_user.id, str) + assert public_user.email == sample_user_in_db.email + assert public_user.name == sample_user_in_db.name + assert public_user.created_at.endswith("Z") + + def test_google_oauth_data_validation(self): + """Test GoogleOAuthData validation""" + # Valid Google OAuth data + oauth_data = GoogleOAuthData( + google_id="google_123", + email="test@example.com", + name="Test User", + avatar_url="https://example.com/avatar.jpg", + email_verified=True, + ) + assert oauth_data.google_id == "google_123" + assert oauth_data.email_verified is True + + def test_google_oauth_empty_google_id(self): + """Test GoogleOAuthData with empty Google ID""" + with pytest.raises(ValueError): + GoogleOAuthData( + google_id="", + email="test@example.com", + name="Test User", + ) + + with pytest.raises(ValueError): + GoogleOAuthData( + google_id=" ", + email="test@example.com", + name="Test User", + ) + + def test_google_oauth_whitespace_trimming(self): + """Test GoogleOAuthData trims whitespace from Google ID""" + oauth_data = GoogleOAuthData( + google_id=" google_123 ", + email="test@example.com", + name="Test User", + ) + assert oauth_data.google_id == "google_123" + + +class TestUserServiceLogic: + """Test UserService business logic (without database)""" + + def test_user_service_import(self): + """Test that UserService can be imported and instantiated""" + from services.user_service import UserService + + service = UserService() + assert service is not None + + def test_health_check_method_exists(self): + """Test that health_check method exists""" + from services.user_service import UserService + + service = UserService() + assert hasattr(service, "health_check") + assert callable(getattr(service, "health_check")) diff --git a/database/migrations/001_create_users_table.sql b/database/migrations/001_create_users_table.sql new file mode 100644 index 0000000..c1d4a2f --- /dev/null +++ b/database/migrations/001_create_users_table.sql @@ -0,0 +1,61 @@ +-- Migration: 001_create_users_table.sql +-- Description: Create users table with proper indexes and constraints +-- Date: July 8, 2025 + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Create users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + avatar_url TEXT, + google_id VARCHAR(255) UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + is_verified BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_sign_in_at TIMESTAMP WITH TIME ZONE +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_google_id ON users(google_id) WHERE google_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); +CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active) WHERE is_active = true; + +-- Create trigger to automatically update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Apply trigger to users table +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Add constraints +ALTER TABLE users ADD CONSTRAINT users_email_check + CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'); + +ALTER TABLE users ADD CONSTRAINT users_name_check + CHECK (LENGTH(TRIM(name)) > 0); + +-- Add comments for documentation +COMMENT ON TABLE users IS 'User accounts with Google OAuth integration'; +COMMENT ON COLUMN users.id IS 'Primary key, UUID'; +COMMENT ON COLUMN users.email IS 'User email address, unique'; +COMMENT ON COLUMN users.name IS 'User full name'; +COMMENT ON COLUMN users.avatar_url IS 'User profile picture URL'; +COMMENT ON COLUMN users.google_id IS 'Google OAuth user ID, unique'; +COMMENT ON COLUMN users.is_active IS 'Whether user account is active'; +COMMENT ON COLUMN users.is_verified IS 'Whether user email is verified'; +COMMENT ON COLUMN users.created_at IS 'Account creation timestamp'; +COMMENT ON COLUMN users.updated_at IS 'Last update timestamp'; +COMMENT ON COLUMN users.last_sign_in_at IS 'Last sign in timestamp'; \ No newline at end of file