From 5fefb72447641ff01ba138be2145527da1fbd17f Mon Sep 17 00:00:00 2001 From: "qwen.ai[bot]" Date: Mon, 22 Jun 2026 03:54:29 +0000 Subject: [PATCH] Title: Implement Normalized User Domain with DDD Structure and Update Handlers Key features implemented: - New docs/NORMALIZED_USER_DOMAIN.md detailing normalized schema design rationale and structure - New src/modules/user/infrastructure/models/__init__.py aggregating all user domain models - New src/modules/user/infrastructure/models/user_*_model.py files for normalized user entities (address, contact, profile, security, settings, verification) - Updated src/modules/authorization/infrastructure/models/*_model.py with indexing optimizations - Updated src/modules/user/application/auth/login_user/handler.py to reference password_hash correctly - Updated src/modules/user/application/auth/register_user/handler.py to use password_hash in User.create - Updated src/modules/user/application/detail_user/handler.py to fetch user with relations via get_by_id_with_relations - Updated src/modules/user/domain/entities/user.py with normalized entity structure including profile, settings, security - Updated src/modules/user/domain/repositories/user_repository.py interface for relation handling - Updated src/modules/user/infrastructure/models/user_model.py to reflect normalized structure and relationships - Updated src/modules/user/infrastructure/repositories/user_repository.py implementation for normalized data access The changes implement a fully normalized user domain following DDD principles, separating concerns into distinct bounded contexts while updating application handlers to utilize the new structure. The repository layer now supports fetching related user data efficiently. --- .gitignore | 35 ++- docs/NORMALIZED_USER_DOMAIN.md | 266 ++++++++++++++++++ .../infrastructure/models/permission_model.py | 31 +- .../infrastructure/models/resource_model.py | 20 +- .../infrastructure/models/role_model.py | 23 +- .../models/role_permission_model.py | 25 +- .../models/user_has_role_model.py | 25 +- .../application/auth/login_user/handler.py | 2 +- .../application/auth/register_user/handler.py | 7 +- .../user/application/detail_user/handler.py | 11 +- src/modules/user/domain/entities/user.py | 67 ++++- .../domain/repositories/user_repository.py | 24 +- .../user/infrastructure/models/__init__.py | 34 +++ .../models/refresh_token_model.py | 57 +++- .../models/user_address_model.py | 52 ++++ .../models/user_contact_model.py | 49 ++++ .../user/infrastructure/models/user_model.py | 84 +++++- .../models/user_profile_model.py | 41 +++ .../models/user_security_model.py | 67 +++++ .../models/user_settings_model.py | 55 ++++ .../models/user_verification_model.py | 50 ++++ .../repositories/user_repository.py | 242 ++++++++++++++-- 22 files changed, 1162 insertions(+), 105 deletions(-) create mode 100644 docs/NORMALIZED_USER_DOMAIN.md create mode 100644 src/modules/user/infrastructure/models/__init__.py create mode 100644 src/modules/user/infrastructure/models/user_address_model.py create mode 100644 src/modules/user/infrastructure/models/user_contact_model.py create mode 100644 src/modules/user/infrastructure/models/user_profile_model.py create mode 100644 src/modules/user/infrastructure/models/user_security_model.py create mode 100644 src/modules/user/infrastructure/models/user_settings_model.py create mode 100644 src/modules/user/infrastructure/models/user_verification_model.py 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, )