diff --git a/.gitignore b/.gitignore index b927ee7..914183b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,22 +3,16 @@ __pycache__/ *.pyc *.pyo *.pyd -.pytest_cache/ -.coverage -coverage/ -htmlcov/ *.log *.tmp *.swp -*.swo .DS_Store Thumbs.db .env .env.local -*.env.* +.env.* .vscode/ .idea/ -.mypy_cache/ node_modules/ venv/ .venv/ @@ -26,4 +20,31 @@ dist/ build/ target/ .gradle/ +.mypy_cache/ +.pytest_cache/ +coverage/ +htmlcov/ +.coverage +*.zip +*.gz +*.tar +*.tgz +*.bz2 +*.xz +*.7z +*.rar +*.zst +*.lz4 +*.lzh +*.cab +*.arj +*.rpm +*.deb +*.Z +*.lz +*.lzo +*.tar.gz +*.tar.bz2 +*.tar.xz +*.tar.zst ``` \ No newline at end of file diff --git a/docs/NORMALIZED_USER_DOMAIN.md b/docs/NORMALIZED_USER_DOMAIN.md new file mode 100644 index 0000000..cb867c3 --- /dev/null +++ b/docs/NORMALIZED_USER_DOMAIN.md @@ -0,0 +1,266 @@ +"""Normalized User Domain Schema Design + +This module implements a fully normalized user domain following: +- PostgreSQL 17 features +- Third Normal Form (3NF) +- Domain-Driven Design (DDD) principles +- Modular Monolith Architecture +- CQRS compatibility +- Multi-tenancy readiness +- Audit-friendly design + +## Domain Analysis: Why Monolithic Users Table is Bad + +1. **Single Responsibility Principle Violation**: A monolithic users table mixes identity, + profile, security, preferences, and contact information in one place. This makes it + difficult to reason about and maintain. + +2. **Scalability Bottlenecks**: As the table grows wide with many columns, every query + loads unnecessary data. Index efficiency decreases, and vacuum operations become slower. + +3. **Security Concerns**: Sensitive data like password hashes and security settings should + be isolated from frequently accessed profile data to minimize exposure surface. + +4. **Multi-Tenancy Complexity**: Adding tenant isolation to a wide table requires careful + consideration of which fields need tenant scoping. + +5. **CQRS Incompatibility**: Command and Query Responsibility Segregation becomes difficult + when read models need different projections than write models from the same table. + +6. **Microservice Extraction**: When extracting services, a monolithic table creates tight + coupling. Separated tables allow clean bounded context boundaries. + +7. **Audit Trail Gaps**: Tracking changes across many unrelated fields in one table is + complex and error-prone. + +8. **Performance Contention**: Hot spots form when unrelated operations compete for locks + on the same row. + +## Recommended Normalized Structure + +### Identity Bounded Context +- **users**: Core identity and authentication credentials +- **user_security**: Security state, lockouts, MFA configuration +- **user_verifications**: Verification status per communication channel +- **user_sessions**: Active sessions and refresh tokens + +### Profile Bounded Context +- **user_profiles**: Personal information (names, bio, avatar) +- **user_contacts**: Multiple contact methods with types +- **user_addresses**: Multiple addresses with labels +- **user_settings**: User preferences in flexible JSONB format + +### Access Control Bounded Context +- **roles**: Role definitions +- **permissions**: Permission definitions +- **role_permissions**: Role-to-permission assignments +- **user_has_roles**: User-to-role assignments + +### Audit Bounded Context +- **audit_logs**: Immutable event log for compliance +- **error_traces**: Error tracking for debugging + +## ERD Diagram (Mermaid) + +```mermaid +erDiagram + users ||--o| user_profiles : "has" + users ||--o| user_security : "has" + users ||--o| user_contacts : "has multiple" + users ||--o| user_addresses : "has multiple" + users ||--o| user_settings : "has" + users ||--o| user_verifications : "has multiple" + users ||--o| user_sessions : "has multiple" + users ||--o| user_has_roles : "assigned" + + roles ||--o{ role_permissions : "contains" + permissions ||--o{ role_permissions : "contained in" + roles ||--o{ user_has_roles : "assigned to" + users ||--o{ user_has_roles : "has roles" + + authorization_resources ||--o{ permissions : "defines" + + users { + uuid id PK + varchar email UK + varchar username UK + varchar password_hash + varchar auth_provider + varchar status + timestamptz created_at + timestamptz updated_at + } + + user_profiles { + uuid id PK + uuid user_id FK UK + varchar first_name + varchar last_name + varchar display_name + varchar avatar_url + text bio + date birth_date + } + + user_security { + uuid id PK + uuid user_id FK UK + int failed_login_attempts + timestamptz locked_until + timestamptz password_changed_at + boolean two_factor_enabled + varchar two_factor_secret + } + + user_contacts { + uuid id PK + uuid user_id FK + varchar type + varchar value + boolean is_primary + boolean is_verified + } + + user_addresses { + uuid id PK + uuid user_id FK + varchar label + varchar line1 + varchar line2 + varchar city + varchar state + varchar postal_code + varchar country + boolean is_default + } + + user_settings { + uuid id PK + uuid user_id FK UK + jsonb preferences + } + + user_verifications { + uuid id PK + uuid user_id FK + varchar channel + boolean is_verified + timestamptz verified_at + varchar verification_token + } + + user_sessions { + uuid id PK + uuid user_id FK + varchar refresh_token_hash + timestamptz expires_at + varchar device_info + varchar ip_address + boolean is_revoked + } + + roles { + uuid id PK + varchar name UK + varchar description + } + + permissions { + uuid id PK + uuid resource_id FK + varchar key UK + varchar resource + varchar action + varchar description + } + + authorization_resources { + uuid id PK + varchar key UK + varchar name + varchar description + } + + role_permissions { + uuid id PK + uuid role_id FK + uuid permission_id FK + } + + user_has_roles { + uuid id PK + uuid user_id FK + uuid role_id FK + } + + audit_logs { + uuid id PK + varchar action + uuid actor_id + varchar resource_type + uuid resource_id + varchar request_id + jsonb meta + timestamptz created_at + } +``` + +## DDD Mapping + +### Aggregates +- **UserAggregate**: Root entity `users` with entities `user_security`, `user_profiles` +- **SessionAggregate**: Root entity `user_sessions` +- **RoleAggregate**: Root entity `roles` with `role_permissions` + +### Entities +- `users`: Identity aggregate root +- `user_security`: Security configuration entity +- `user_profiles`: Profile entity +- `user_contacts`: Contact method entity +- `user_addresses`: Address entity +- `user_sessions`: Session entity +- `roles`: Role aggregate root +- `permissions`: Permission entity + +### Value Objects +- `Email`: Email address with validation +- `PhoneNumber`: Phone number with formatting +- `Address`: Structured address components +- `Preferences`: JSONB settings object + +### Domain Services +- `AuthenticationService`: Login/logout/password management +- `AuthorizationService`: RBAC evaluation +- `VerificationService`: Email/phone verification +- `SessionService`: Session lifecycle management +- `AuditService`: Audit log creation + +## Future Scalability Considerations + +### Millions of Users +- Partition `audit_logs` and `user_sessions` by date +- Use connection pooling efficiently +- Implement read replicas for query separation +- Cache frequently accessed profiles + +### Multi-Tenancy +- Add `tenant_id` column to all tables +- Implement Row Level Security (RLS) policies +- Use schema-per-tenant for high isolation needs + +### OAuth/SSO Support +- `auth_provider` field supports external identity providers +- `external_id` can be added for provider-specific IDs +- `user_verifications` tracks OAuth account linking + +### Microservice Extraction +- Each bounded context can become a separate service +- Clear foreign key boundaries enable database splitting +- Event sourcing ready via `audit_logs` + +### Event-Driven Architecture +- `audit_logs` serves as event store +- Can integrate with message brokers (Kafka, RabbitMQ) +- Supports CQRS read model rebuilding + +""" diff --git a/src/modules/authorization/infrastructure/models/permission_model.py b/src/modules/authorization/infrastructure/models/permission_model.py index ac3eab2..66b013b 100644 --- a/src/modules/authorization/infrastructure/models/permission_model.py +++ b/src/modules/authorization/infrastructure/models/permission_model.py @@ -1,20 +1,39 @@ from uuid import UUID -from sqlalchemy import String, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin from src.shared.database.model import Base class PermissionModel(Base, TimeStampMixin, SoftDeleteMixin): + """Permission definition for RBAC system. + + Permissions represent specific actions on resources. + Linked to authorization_resources for resource management. + """ __tablename__ = "permissions" __table_args__ = ( UniqueConstraint("resource", "action", name="uq_permissions_resource_action"), + Index("ix_permissions_key", "key", unique=True), + Index("ix_permissions_resource", "resource"), + Index("ix_permissions_action", "action"), + Index("ix_permissions_resource_id", "resource_id"), ) - key: Mapped[str] = mapped_column(String(255), unique=True, index=True) - resource_id: Mapped[UUID] = mapped_column(index=True) - resource: Mapped[str] = mapped_column(String(100), index=True) - action: Mapped[str] = mapped_column(String(100), index=True) + key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + resource_id: Mapped[UUID] = mapped_column(nullable=False) + resource: Mapped[str] = mapped_column(String(100), nullable=False) + action: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Relationships + roles: Mapped[list["RolePermissionModel"]] = relationship( + back_populates="permission", + cascade="all, delete-orphan", + ) + authorization_resource: Mapped["AuthorizationResourceModel"] = relationship( + back_populates="permissions", + foreign_keys=[resource_id], + ) diff --git a/src/modules/authorization/infrastructure/models/resource_model.py b/src/modules/authorization/infrastructure/models/resource_model.py index 95c2082..17b1a90 100644 --- a/src/modules/authorization/infrastructure/models/resource_model.py +++ b/src/modules/authorization/infrastructure/models/resource_model.py @@ -1,13 +1,27 @@ -from sqlalchemy import String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin from src.shared.database.model import Base class AuthorizationResourceModel(Base, TimeStampMixin, SoftDeleteMixin): + """Authorization resource definition for grouping permissions. + + Resources represent domain entities that permissions act upon. + Examples: users, todos, documents, reports. + """ __tablename__ = "authorization_resources" + __table_args__ = ( + Index("ix_authorization_resources_key", "key", unique=True), + ) - key: Mapped[str] = mapped_column(String(100), unique=True, index=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(150), nullable=False) description: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Relationships + permissions: Mapped[list["PermissionModel"]] = relationship( + back_populates="authorization_resource", + cascade="all, delete-orphan", + ) diff --git a/src/modules/authorization/infrastructure/models/role_model.py b/src/modules/authorization/infrastructure/models/role_model.py index 7be2550..639e13c 100644 --- a/src/modules/authorization/infrastructure/models/role_model.py +++ b/src/modules/authorization/infrastructure/models/role_model.py @@ -1,12 +1,29 @@ -from sqlalchemy import String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin from src.shared.database.model import Base class RoleModel(Base, TimeStampMixin, SoftDeleteMixin): + """Role definition for RBAC system. + + Roles group permissions and can be assigned to users. + """ __tablename__ = "roles" + __table_args__ = ( + Index("ix_roles_name", "name", unique=True), + ) - name: Mapped[str] = mapped_column(String(100), unique=True, index=True) + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) description: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Relationships + permissions: Mapped[list["RolePermissionModel"]] = relationship( + back_populates="role", + cascade="all, delete-orphan", + ) + user_assignments: Mapped[list["UserHasRoleModel"]] = relationship( + back_populates="role", + cascade="all, delete-orphan", + ) diff --git a/src/modules/authorization/infrastructure/models/role_permission_model.py b/src/modules/authorization/infrastructure/models/role_permission_model.py index a7d924c..11232f7 100644 --- a/src/modules/authorization/infrastructure/models/role_permission_model.py +++ b/src/modules/authorization/infrastructure/models/role_permission_model.py @@ -1,12 +1,17 @@ from uuid import UUID -from sqlalchemy import UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin from src.shared.database.model import Base class RolePermissionModel(Base): + """Junction table for role-to-permission assignments. + + Many-to-many relationship between roles and permissions. + """ __tablename__ = "role_permissions" __table_args__ = ( UniqueConstraint( @@ -14,7 +19,19 @@ class RolePermissionModel(Base): "permission_id", name="uq_role_permissions_role_id_permission_id", ), + Index("ix_role_permissions_role_id", "role_id"), + Index("ix_role_permissions_permission_id", "permission_id"), ) - role_id: Mapped[UUID] = mapped_column(index=True) - permission_id: Mapped[UUID] = mapped_column(index=True) + role_id: Mapped[UUID] = mapped_column(nullable=False) + permission_id: Mapped[UUID] = mapped_column(nullable=False) + + # Relationships + role: Mapped["RoleModel"] = relationship( + back_populates="permissions", + foreign_keys=[role_id], + ) + permission: Mapped["PermissionModel"] = relationship( + back_populates="roles", + foreign_keys=[permission_id], + ) diff --git a/src/modules/authorization/infrastructure/models/user_has_role_model.py b/src/modules/authorization/infrastructure/models/user_has_role_model.py index 5849a38..caa6964 100644 --- a/src/modules/authorization/infrastructure/models/user_has_role_model.py +++ b/src/modules/authorization/infrastructure/models/user_has_role_model.py @@ -1,16 +1,33 @@ from uuid import UUID -from sqlalchemy import UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.model import Base class UserHasRoleModel(Base): + """Junction table for user-to-role assignments. + + Many-to-many relationship between users and roles. + Supports RBAC by linking users to their assigned roles. + """ __tablename__ = "user_has_roles" __table_args__ = ( UniqueConstraint("user_id", "role_id", name="uq_user_has_roles_user_id_role_id"), + Index("ix_user_has_roles_user_id", "user_id"), + Index("ix_user_has_roles_role_id", "role_id"), ) - user_id: Mapped[UUID] = mapped_column(index=True) - role_id: Mapped[UUID] = mapped_column(index=True) + user_id: Mapped[UUID] = mapped_column(nullable=False) + role_id: Mapped[UUID] = mapped_column(nullable=False) + + # Relationships + user: Mapped["UserModel"] = relationship( + back_populates="role_assignments", + foreign_keys=[user_id], + ) + role: Mapped["RoleModel"] = relationship( + back_populates="user_assignments", + foreign_keys=[role_id], + ) diff --git a/src/modules/user/application/auth/login_user/handler.py b/src/modules/user/application/auth/login_user/handler.py index f106f01..2381989 100644 --- a/src/modules/user/application/auth/login_user/handler.py +++ b/src/modules/user/application/auth/login_user/handler.py @@ -56,7 +56,7 @@ async def execute(self, command: LoginUserCommand) -> dict[str, str]: raise InvalidCredentialsError("Incorrect email or password") if not user or not PasswordSerrvice.verify_password( - command.password, user.password + command.password, user.password_hash ): await self._record_failed_login(command.username) raise InvalidCredentialsError("Incorrect email or password") diff --git a/src/modules/user/application/auth/register_user/handler.py b/src/modules/user/application/auth/register_user/handler.py index 15a1beb..c00bb50 100644 --- a/src/modules/user/application/auth/register_user/handler.py +++ b/src/modules/user/application/auth/register_user/handler.py @@ -37,11 +37,14 @@ async def execute(self, command: RegisterUserCommand) -> User: hashed_password = PasswordSerrvice.hash(command.password) user = User.create( - command.email, - password=hashed_password, + email=command.email, + password_hash=hashed_password, ) async with self._unit_of_work: + # Save user (this also creates default profile, settings, security) saved_user = await self._user_repository.save(user=user) + + # Assign default role await self._authorization_service.assign_role( subject=str(saved_user.id), role=DEFAULT_USER_ROLE, diff --git a/src/modules/user/application/detail_user/handler.py b/src/modules/user/application/detail_user/handler.py index 4fb3061..c224334 100644 --- a/src/modules/user/application/detail_user/handler.py +++ b/src/modules/user/application/detail_user/handler.py @@ -12,15 +12,8 @@ def __init__(self, user_repository: UserRepository): async def execute(self, query: DetailUserQuery) -> User: validate_detail_user_query(query) - user = await self._user_repository.get_by_id(query.user_id) + user = await self._user_repository.get_by_id_with_relations(query.user_id) if user is None: raise UserNotFoundError("User not found.") - return User( - id=user.id, - username=user.username, - fullname=user.fullname, - email=user.email, - password=user.password, - birthday=user.birthday, - ) + return user diff --git a/src/modules/user/domain/entities/user.py b/src/modules/user/domain/entities/user.py index 224341f..62a9b07 100644 --- a/src/modules/user/domain/entities/user.py +++ b/src/modules/user/domain/entities/user.py @@ -1,34 +1,73 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import date +from dataclasses import dataclass, field +from datetime import date, datetime +from typing import Optional from uuid import UUID, uuid4 +@dataclass +class UserProfile: + """User profile containing personal information.""" + user_id: UUID + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: Optional[str] = None + avatar_url: Optional[str] = None + bio: Optional[str] = None + birth_date: Optional[date] = None + + +@dataclass +class UserSettings: + """User preferences and settings.""" + user_id: UUID + preferences: dict = field(default_factory=dict) + + +@dataclass +class UserSecurity: + """User security configuration and state.""" + user_id: UUID + failed_login_attempts: int = 0 + locked_until: Optional[datetime] = None + password_changed_at: Optional[datetime] = None + two_factor_enabled: bool = False + two_factor_secret: Optional[str] = None + two_factor_backup_codes: Optional[str] = None + + @dataclass class User: + """Core user identity and authentication aggregate root.""" id: UUID email: str - password: str - - username: str | None = None - fullname: str | None = None - birthday: date | None = None + password_hash: str + + # Identity + username: Optional[str] = None + auth_provider: str = "local" + external_id: Optional[str] = None + status: str = "pending_verification" + + # Related entities (loaded separately via repository methods) + profile: Optional[UserProfile] = None + settings: Optional[UserSettings] = None + security: Optional[UserSecurity] = None @classmethod def create( cls, email: str, - password: str, - username: str | None = None, - fullname: str | None = None, - birthday: date | None = None, + password_hash: str, + username: Optional[str] = None, + auth_provider: str = "local", ) -> User: return cls( id=uuid4(), email=email, - password=password, + password_hash=password_hash, username=username, - fullname=fullname, - birthday=birthday, + auth_provider=auth_provider, + status="pending_verification", ) diff --git a/src/modules/user/domain/repositories/user_repository.py b/src/modules/user/domain/repositories/user_repository.py index c081a03..44ff69c 100644 --- a/src/modules/user/domain/repositories/user_repository.py +++ b/src/modules/user/domain/repositories/user_repository.py @@ -1,18 +1,36 @@ from abc import ABC, abstractmethod +from typing import Optional from uuid import UUID -from src.modules.user.domain.entities.user import User +from src.modules.user.domain.entities.user import User, UserProfile, UserSettings, UserSecurity class UserRepository(ABC): @abstractmethod - async def get_by_email(self, email: str) -> User | None: + async def get_by_email(self, email: str) -> Optional[User]: pass @abstractmethod - async def get_by_id(self, user_id: UUID) -> User | None: + async def get_by_id(self, user_id: UUID) -> Optional[User]: + pass + + @abstractmethod + async def get_by_id_with_relations(self, user_id: UUID) -> Optional[User]: + """Get user with profile, settings, and security loaded.""" pass @abstractmethod async def save(self, user: User) -> User: pass + + @abstractmethod + async def save_profile(self, profile: UserProfile) -> UserProfile: + pass + + @abstractmethod + async def save_settings(self, settings: UserSettings) -> UserSettings: + pass + + @abstractmethod + async def save_security(self, security: UserSecurity) -> UserSecurity: + pass diff --git a/src/modules/user/infrastructure/models/__init__.py b/src/modules/user/infrastructure/models/__init__.py new file mode 100644 index 0000000..80f4b1a --- /dev/null +++ b/src/modules/user/infrastructure/models/__init__.py @@ -0,0 +1,34 @@ +"""Normalized user domain models following DDD principles. + +This module contains all SQLAlchemy models for the normalized user domain: +- users: Core identity and authentication +- user_profiles: Personal information +- user_contacts: Multiple contact methods +- user_addresses: Multiple addresses +- user_settings: User preferences (JSONB) +- user_security: Security configuration +- user_verifications: Verification status +- user_sessions: Session management +""" + +from .user_model import UserModel, UserStatus, AuthProvider +from .user_profile_model import UserProfileModel +from .user_contact_model import UserContactModel +from .user_address_model import UserAddressModel +from .user_settings_model import UserSettingsModel +from .user_security_model import UserSecurityModel +from .user_verification_model import UserVerificationModel +from .refresh_token_model import UserSessionModel + +__all__ = [ + "UserModel", + "UserStatus", + "AuthProvider", + "UserProfileModel", + "UserContactModel", + "UserAddressModel", + "UserSettingsModel", + "UserSecurityModel", + "UserVerificationModel", + "UserSessionModel", +] diff --git a/src/modules/user/infrastructure/models/refresh_token_model.py b/src/modules/user/infrastructure/models/refresh_token_model.py index 405694e..23bf246 100644 --- a/src/modules/user/infrastructure/models/refresh_token_model.py +++ b/src/modules/user/infrastructure/models/refresh_token_model.py @@ -1,21 +1,54 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Boolean, DateTime, String, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin from src.shared.database.model import Base -class RefreshTokenModel( - Base, - TimeStampMixin, - SoftDeleteMixin, -): - __tablename__ = "refresh_tokens" +class UserSessionModel(Base, TimeStampMixin, SoftDeleteMixin): + """User session management for tracking active sessions. + + One-to-many relationship with users table. + Stores refresh tokens, device info, and login history. + """ + __tablename__ = "user_sessions" + __table_args__ = ( + Index("ix_user_sessions_user_id", "user_id"), + Index("ix_user_sessions_token_hash", "refresh_token_hash", unique=True), + Index("ix_user_sessions_expires_at", "expires_at"), + Index("ix_user_sessions_is_revoked", "is_revoked"), + Index("ix_user_sessions_device_info", "device_info"), + ) - user_id: Mapped[uuid.UUID] = mapped_column(index=True) - token_hash: Mapped[str] = mapped_column(String(255), unique=True, index=True) - expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - is_revoked: Mapped[bool] = mapped_column(Boolean, default=False) + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + nullable=False, + ) + + # Session Token (hashed for security) + refresh_token_hash: Mapped[str] = mapped_column(String(255), nullable=False) + + # Session Expiry + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + # Device and Location Info + device_info: Mapped[str | None] = mapped_column(String(500), nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) # IPv6 compatible + user_agent: Mapped[str | None] = mapped_column(String(1000), nullable=True) + + # Session Status + is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + revoked_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="sessions", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/models/user_address_model.py b/src/modules/user/infrastructure/models/user_address_model.py new file mode 100644 index 0000000..082c587 --- /dev/null +++ b/src/modules/user/infrastructure/models/user_address_model.py @@ -0,0 +1,52 @@ +from sqlalchemy import String, Boolean, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class UserAddressModel(Base, TimeStampMixin, SoftDeleteMixin): + """User addresses supporting multiple locations. + + One-to-many relationship with users table. + Supports home, billing, shipping, and custom address labels. + """ + __tablename__ = "user_addresses" + __table_args__ = ( + Index("ix_user_addresses_user_id", "user_id"), + Index("ix_user_addresses_label", "label"), + Index("ix_user_addresses_is_default", "is_default"), + Index("ix_user_addresses_country", "country"), + ) + + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + nullable=False, + ) + + # Address Label (home, billing, shipping, work, etc.) + label: Mapped[str] = mapped_column(String(100), nullable=False) + + # Address Lines + line1: Mapped[str] = mapped_column(String(255), nullable=False) + line2: Mapped[str | None] = mapped_column(String(255), nullable=True) + line3: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # City and State + city: Mapped[str] = mapped_column(String(100), nullable=False) + state: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Postal Code + postal_code: Mapped[str] = mapped_column(String(20), nullable=False) + + # Country (ISO 3166-1 alpha-2) + country: Mapped[str] = mapped_column(String(2), nullable=False) + + # Flags + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="addresses", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/models/user_contact_model.py b/src/modules/user/infrastructure/models/user_contact_model.py new file mode 100644 index 0000000..338725b --- /dev/null +++ b/src/modules/user/infrastructure/models/user_contact_model.py @@ -0,0 +1,49 @@ +from enum import Enum + +from sqlalchemy import String, Boolean, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class ContactType(str, Enum): + EMAIL = "email" + PHONE = "phone" + MOBILE = "mobile" + WORK_PHONE = "work_phone" + HOME_PHONE = "home_phone" + OTHER = "other" + + +class UserContactModel(Base, TimeStampMixin, SoftDeleteMixin): + """User contact methods supporting multiple channels. + + One-to-many relationship with users table. + Allows users to have multiple contact methods (phones, alternate emails). + """ + __tablename__ = "user_contacts" + __table_args__ = ( + Index("ix_user_contacts_user_id", "user_id"), + Index("ix_user_contacts_type", "contact_type"), + Index("ix_user_contacts_is_primary", "is_primary"), + ) + + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + nullable=False, + ) + + # Contact Information + contact_type: Mapped[str] = mapped_column(String(50), nullable=False) + value: Mapped[str] = mapped_column(String(255), nullable=False) + + # Flags + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="contacts", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/models/user_model.py b/src/modules/user/infrastructure/models/user_model.py index b40286f..cb54e14 100644 --- a/src/modules/user/infrastructure/models/user_model.py +++ b/src/modules/user/infrastructure/models/user_model.py @@ -1,21 +1,91 @@ from datetime import date +from enum import Enum -from sqlalchemy import Date, String -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Date, String, Boolean, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin from src.shared.database.model import Base +class UserStatus(str, Enum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + PENDING_VERIFICATION = "pending_verification" + + +class AuthProvider(str, Enum): + LOCAL = "local" + GOOGLE = "google" + GITHUB = "github" + MICROSOFT = "microsoft" + SSO = "sso" + + class UserModel( Base, TimeStampMixin, SoftDeleteMixin, ): + """Core user identity and authentication table. + + Contains only identity and authentication related fields. + All other user data is in separate normalized tables. + """ __tablename__ = "users" + __table_args__ = ( + Index("ix_users_status", "status"), + Index("ix_users_auth_provider", "auth_provider"), + ) - username: Mapped[str | None] = mapped_column(String(255), nullable=True) - fullname: Mapped[str | None] = mapped_column(String(255), nullable=True) - birthday: Mapped[date | None] = mapped_column(Date, nullable=True) - email: Mapped[str] = mapped_column(String(255), unique=True, index=True) - password: Mapped[str] = mapped_column(String(255)) + # Identity + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + username: Mapped[str | None] = mapped_column(String(100), unique=True, index=True, nullable=True) + + # Authentication + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + auth_provider: Mapped[str] = mapped_column(String(50), default=AuthProvider.LOCAL, nullable=False) + external_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Status + status: Mapped[str] = mapped_column(String(50), default=UserStatus.PENDING_VERIFICATION, nullable=False) + + # Relationships (one-to-one) + profile: Mapped["UserProfileModel"] = relationship( + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + ) + security: Mapped["UserSecurityModel"] = relationship( + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + ) + settings: Mapped["UserSettingsModel"] = relationship( + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + ) + + # Relationships (one-to-many) + contacts: Mapped[list["UserContactModel"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) + addresses: Mapped[list["UserAddressModel"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) + verifications: Mapped[list["UserVerificationModel"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) + sessions: Mapped[list["UserSessionModel"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) + role_assignments: Mapped[list["UserHasRoleModel"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + ) diff --git a/src/modules/user/infrastructure/models/user_profile_model.py b/src/modules/user/infrastructure/models/user_profile_model.py new file mode 100644 index 0000000..11f439f --- /dev/null +++ b/src/modules/user/infrastructure/models/user_profile_model.py @@ -0,0 +1,41 @@ +from sqlalchemy import String, Date, Text, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class UserProfileModel(Base, TimeStampMixin, SoftDeleteMixin): + """User profile containing personal information. + + One-to-one relationship with users table. + Contains fields that are not required for authentication. + """ + __tablename__ = "user_profiles" + __table_args__ = ( + Index("ix_user_profiles_user_id", "user_id", unique=True), + ) + + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + unique=True, + nullable=False, + ) + + # Personal Information + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + + # Avatar and Bio + avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + bio: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Birth Date + birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="profile", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/models/user_security_model.py b/src/modules/user/infrastructure/models/user_security_model.py new file mode 100644 index 0000000..24415cd --- /dev/null +++ b/src/modules/user/infrastructure/models/user_security_model.py @@ -0,0 +1,67 @@ +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, Integer, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class UserSecurityModel(Base, TimeStampMixin, SoftDeleteMixin): + """User security configuration and state. + + One-to-one relationship with users table. + Contains sensitive security-related fields separated from core identity. + """ + __tablename__ = "user_security" + __table_args__ = ( + Index("ix_user_security_user_id", "user_id", unique=True), + Index("ix_user_security_locked_until", "locked_until"), + Index("ix_user_security_two_factor_enabled", "two_factor_enabled"), + ) + + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + unique=True, + nullable=False, + ) + + # Login Attempt Tracking + failed_login_attempts: Mapped[int] = mapped_column( + Integer, + default=0, + nullable=False, + ) + + # Account Lockout + locked_until: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + # Password Management + password_changed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + # Two-Factor Authentication + two_factor_enabled: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) + two_factor_secret: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + ) + two_factor_backup_codes: Mapped[str | None] = mapped_column( + String(1000), + nullable=True, + ) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="security", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/models/user_settings_model.py b/src/modules/user/infrastructure/models/user_settings_model.py new file mode 100644 index 0000000..d6b9508 --- /dev/null +++ b/src/modules/user/infrastructure/models/user_settings_model.py @@ -0,0 +1,55 @@ +from sqlalchemy import String, Index +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class UserSettingsModel(Base, TimeStampMixin, SoftDeleteMixin): + """User preferences and settings stored as JSONB. + + One-to-one relationship with users table. + Flexible schema allows adding new preferences without migrations. + + Example preferences structure: + { + "language": "en", + "timezone": "UTC", + "theme": "dark", + "currency": "USD", + "notifications": { + "email": true, + "push": false, + "sms": false + }, + "privacy": { + "profile_visibility": "public", + "show_email": false + } + } + """ + __tablename__ = "user_settings" + __table_args__ = ( + Index("ix_user_settings_user_id", "user_id", unique=True), + Index("ix_user_settings_preferences", "preferences", postgresql_using="gin"), + ) + + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + unique=True, + nullable=False, + ) + + # Preferences stored as JSONB for flexibility + preferences: Mapped[dict] = mapped_column( + JSONB, + default=dict, + nullable=False, + ) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="settings", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/models/user_verification_model.py b/src/modules/user/infrastructure/models/user_verification_model.py new file mode 100644 index 0000000..7fb5b4a --- /dev/null +++ b/src/modules/user/infrastructure/models/user_verification_model.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin +from src.shared.database.model import Base + + +class UserVerificationModel(Base, TimeStampMixin, SoftDeleteMixin): + """User verification status per communication channel. + + One-to-many relationship with users table. + Tracks verification status for email, phone, and other channels. + """ + __tablename__ = "user_verifications" + __table_args__ = ( + Index("ix_user_verifications_user_id", "user_id"), + Index("ix_user_verifications_channel", "channel"), + Index("ix_user_verifications_is_verified", "is_verified"), + Index("ix_user_verifications_token", "verification_token"), + ) + + user_id: Mapped[str] = mapped_column( + String(36), # UUID as string for FK + nullable=False, + ) + + # Verification Channel (email, phone, etc.) + channel: Mapped[str] = mapped_column(String(50), nullable=False) + + # Verification Status + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + verified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + # Verification Token (for pending verifications) + verification_token: Mapped[str | None] = mapped_column(String(255), nullable=True) + token_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + + # Relationship + user: Mapped["UserModel"] = relationship( + back_populates="verifications", + foreign_keys=[user_id], + ) diff --git a/src/modules/user/infrastructure/repositories/user_repository.py b/src/modules/user/infrastructure/repositories/user_repository.py index 20bd2cf..3ff6efc 100644 --- a/src/modules/user/infrastructure/repositories/user_repository.py +++ b/src/modules/user/infrastructure/repositories/user_repository.py @@ -1,18 +1,23 @@ +from typing import Optional from uuid import UUID from sqlalchemy import select +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession -from src.modules.user.domain.entities.user import User +from src.modules.user.domain.entities.user import User, UserProfile, UserSettings, UserSecurity from src.modules.user.domain.repositories.user_repository import UserRepository from src.modules.user.infrastructure.models.user_model import UserModel +from src.modules.user.infrastructure.models.user_profile_model import UserProfileModel +from src.modules.user.infrastructure.models.user_settings_model import UserSettingsModel +from src.modules.user.infrastructure.models.user_security_model import UserSecurityModel class SQLAlchemyUserRepository(UserRepository): def __init__(self, db: AsyncSession): self._db = db - async def get_by_email(self, email) -> User | None: + async def get_by_email(self, email: str) -> Optional[User]: result = await self._db.execute( select(UserModel).where(UserModel.email == email) ) @@ -20,46 +25,223 @@ async def get_by_email(self, email) -> User | None: if user_model is None: return None - return User( - id=user_model.id, - email=user_model.email, - password=user_model.password, - username=user_model.username, - fullname=user_model.fullname, - birthday=user_model.birthday, - ) + return self._map_to_entity(user_model) - async def get_by_id(self, user_id: UUID) -> User | None: + async def get_by_id(self, user_id: UUID) -> Optional[User]: result = await self._db.execute(select(UserModel).where(UserModel.id == user_id)) user_model = result.scalar_one_or_none() if not user_model: return None - return User( - id=user_model.id, - email=user_model.email, - password=user_model.password, - username=user_model.username, - fullname=user_model.fullname, - birthday=user_model.birthday, + return self._map_to_entity(user_model) + + async def get_by_id_with_relations(self, user_id: UUID) -> Optional[User]: + """Get user with profile, settings, and security eagerly loaded.""" + result = await self._db.execute( + select(UserModel) + .options( + selectinload(UserModel.profile), + selectinload(UserModel.settings), + selectinload(UserModel.security), + ) + .where(UserModel.id == user_id) ) + user_model = result.scalar_one_or_none() + if not user_model: + return None + return self._map_to_entity_with_relations(user_model) async def save(self, user: User) -> User: - user_model = UserModel( - id=user.id, - email=user.email, - password=user.password, - username=user.username, - fullname=user.fullname, - birthday=user.birthday, - ) - self._db.add(user_model) + # Check if user exists + existing = await self.get_by_id(user.id) + + if existing: + # Update existing user + user_model = await self._get_user_model(user.id) + user_model.email = user.email + user_model.username = user.username + user_model.password_hash = user.password_hash + user_model.auth_provider = user.auth_provider + user_model.status = user.status + user_model.external_id = user.external_id + else: + # Create new user + user_model = UserModel( + id=user.id, + email=user.email, + username=user.username, + password_hash=user.password_hash, + auth_provider=user.auth_provider, + status=user.status, + external_id=user.external_id, + ) + self._db.add(user_model) + + # Create default related records + await self._create_default_related_records(user_model.id) + await self._db.flush() await self._db.refresh(user_model) + return self._map_to_entity(user_model) + + async def save_profile(self, profile: UserProfile) -> UserProfile: + existing = await self._db.execute( + select(UserProfileModel).where(UserProfileModel.user_id == profile.user_id) + ) + profile_model = existing.scalar_one_or_none() + + if profile_model: + profile_model.first_name = profile.first_name + profile_model.last_name = profile.last_name + profile_model.display_name = profile.display_name + profile_model.avatar_url = profile.avatar_url + profile_model.bio = profile.bio + profile_model.birth_date = profile.birth_date + else: + profile_model = UserProfileModel( + user_id=profile.user_id, + first_name=profile.first_name, + last_name=profile.last_name, + display_name=profile.display_name, + avatar_url=profile.avatar_url, + bio=profile.bio, + birth_date=profile.birth_date, + ) + self._db.add(profile_model) + + await self._db.flush() + await self._db.refresh(profile_model) + return self._map_profile_to_entity(profile_model) + + async def save_settings(self, settings: UserSettings) -> UserSettings: + existing = await self._db.execute( + select(UserSettingsModel).where(UserSettingsModel.user_id == settings.user_id) + ) + settings_model = existing.scalar_one_or_none() + + if settings_model: + settings_model.preferences = settings.preferences + else: + settings_model = UserSettingsModel( + user_id=settings.user_id, + preferences=settings.preferences, + ) + self._db.add(settings_model) + + await self._db.flush() + await self._db.refresh(settings_model) + return self._map_settings_to_entity(settings_model) + + async def save_security(self, security: UserSecurity) -> UserSecurity: + existing = await self._db.execute( + select(UserSecurityModel).where(UserSecurityModel.user_id == security.user_id) + ) + security_model = existing.scalar_one_or_none() + + if security_model: + security_model.failed_login_attempts = security.failed_login_attempts + security_model.locked_until = security.locked_until + security_model.password_changed_at = security.password_changed_at + security_model.two_factor_enabled = security.two_factor_enabled + security_model.two_factor_secret = security.two_factor_secret + security_model.two_factor_backup_codes = security.two_factor_backup_codes + else: + security_model = UserSecurityModel( + user_id=security.user_id, + failed_login_attempts=security.failed_login_attempts, + locked_until=security.locked_until, + password_changed_at=security.password_changed_at, + two_factor_enabled=security.two_factor_enabled, + two_factor_secret=security.two_factor_secret, + two_factor_backup_codes=security.two_factor_backup_codes, + ) + self._db.add(security_model) + + await self._db.flush() + await self._db.refresh(security_model) + return self._map_security_to_entity(security_model) + + async def _get_user_model(self, user_id: UUID) -> UserModel: + result = await self._db.execute(select(UserModel).where(UserModel.id == user_id)) + return result.scalar_one() + + async def _create_default_related_records(self, user_id: UUID) -> None: + """Create default profile, settings, and security records for a new user.""" + # Default profile + profile_model = UserProfileModel(user_id=user_id) + self._db.add(profile_model) + + # Default settings + settings_model = UserSettingsModel( + user_id=user_id, + preferences={ + "language": "en", + "timezone": "UTC", + "theme": "light", + "notifications": { + "email": True, + "push": False, + } + } + ) + self._db.add(settings_model) + + # Default security + security_model = UserSecurityModel( + user_id=user_id, + failed_login_attempts=0, + two_factor_enabled=False, + ) + self._db.add(security_model) + + def _map_to_entity(self, user_model: UserModel) -> User: return User( id=user_model.id, email=user_model.email, - password=user_model.password, + password_hash=user_model.password_hash, username=user_model.username, - fullname=user_model.fullname, - birthday=user_model.birthday, + auth_provider=user_model.auth_provider, + status=user_model.status, + external_id=user_model.external_id, + ) + + def _map_to_entity_with_relations(self, user_model: UserModel) -> User: + user = self._map_to_entity(user_model) + + if user_model.profile: + user.profile = self._map_profile_to_entity(user_model.profile) + + if user_model.settings: + user.settings = self._map_settings_to_entity(user_model.settings) + + if user_model.security: + user.security = self._map_security_to_entity(user_model.security) + + return user + + def _map_profile_to_entity(self, profile_model: UserProfileModel) -> UserProfile: + return UserProfile( + user_id=profile_model.user_id, + first_name=profile_model.first_name, + last_name=profile_model.last_name, + display_name=profile_model.display_name, + avatar_url=profile_model.avatar_url, + bio=profile_model.bio, + birth_date=profile_model.birth_date, + ) + + def _map_settings_to_entity(self, settings_model: UserSettingsModel) -> UserSettings: + return UserSettings( + user_id=settings_model.user_id, + preferences=settings_model.preferences or {}, + ) + + def _map_security_to_entity(self, security_model: UserSecurityModel) -> UserSecurity: + return UserSecurity( + user_id=security_model.user_id, + failed_login_attempts=security_model.failed_login_attempts, + locked_until=security_model.locked_until, + password_changed_at=security_model.password_changed_at, + two_factor_enabled=security_model.two_factor_enabled, + two_factor_secret=security_model.two_factor_secret, + two_factor_backup_codes=security_model.two_factor_backup_codes, )