feat(account): GET /me user endpoint only, disable cache and extra routers
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
src/application/abstractions/__init__.py
Normal file
1
src/application/abstractions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.abstractions.i_unit_of_work import IUnitOfWork
|
||||
19
src/application/abstractions/i_unit_of_work.py
Normal file
19
src/application/abstractions/i_unit_of_work.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
from typing import Protocol, runtime_checkable
|
||||
from src.application.abstractions.repositories import IUserRepository, ISessionRepository
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IUnitOfWork(Protocol):
|
||||
async def __aenter__(self) -> "IUnitOfWork": ...
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
|
||||
|
||||
async def commit(self) -> None: ...
|
||||
async def rollback(self) -> None: ...
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository: ...
|
||||
|
||||
@property
|
||||
def session_repository(self) -> ISessionRepository: ...
|
||||
|
||||
2
src/application/abstractions/repositories/__init__.py
Normal file
2
src/application/abstractions/repositories/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.abstractions.repositories.i_user_repository import IUserRepository
|
||||
from src.application.abstractions.repositories.i_session_repository import ISessionRepository
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from src.application.domain.entities import SessionEntity
|
||||
|
||||
|
||||
|
||||
class ISessionRepository(ABC):
|
||||
@abstractmethod
|
||||
async def get_by_sid(self, sid: str) -> SessionEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_user_device(self, user_id: str, device_id: str) -> SessionEntity | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def upsert_by_device(
|
||||
self,
|
||||
user_id: str,
|
||||
device_id: str,
|
||||
sid: str,
|
||||
refresh_jti_hash: str,
|
||||
refresh_expires_at: datetime,
|
||||
user_agent: str | None,
|
||||
ip: str | None,
|
||||
now: datetime,
|
||||
) -> SessionEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def revoke_by_sid(self, sid: str, now: datetime) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def rotate_refresh(
|
||||
self,
|
||||
sid: str,
|
||||
new_jti_hash: str,
|
||||
new_refresh_expires_at: datetime,
|
||||
now: datetime,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def rotate_refresh_if_match(
|
||||
self,
|
||||
*,
|
||||
sid: str,
|
||||
old_jti_hash: str,
|
||||
new_jti_hash: str,
|
||||
new_refresh_expires_at: datetime,
|
||||
now: datetime,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
) -> bool:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
|
||||
class IUserRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_by_id(self, user_id: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_phone(self, user_id: str, phone: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_bank_details(self, user_id: str, **fields: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_crypto_wallet(self, user_id: str, wallet_address: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_password_hash(self, user_id: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_email(self, user_id: str, email: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def email_exists(self, email: str) -> bool:
|
||||
raise NotImplementedError
|
||||
11
src/application/commands/__init__.py
Normal file
11
src/application/commands/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from src.application.commands.get_me import GetMeCommand
|
||||
from src.application.commands.set_phone import SetPhoneCommand
|
||||
from src.application.commands.set_crypto_wallet_start import SetCryptoWalletStartCommand
|
||||
from src.application.commands.set_crypto_wallet_complete import SetCryptoWalletCompleteCommand
|
||||
from src.application.commands.update_bank_details_start import UpdateBankDetailsStartCommand
|
||||
from src.application.commands.update_bank_details_complete import UpdateBankDetailsCompleteCommand
|
||||
from src.application.commands.change_password_start import ChangePasswordStartCommand
|
||||
from src.application.commands.change_password_complete import ChangePasswordCompleteCommand
|
||||
from src.application.commands.change_email_start import ChangeEmailStartCommand
|
||||
from src.application.commands.change_email_confirm_old import ChangeEmailConfirmOldCommand
|
||||
from src.application.commands.change_email_complete import ChangeEmailCompleteCommand
|
||||
63
src/application/commands/change_email_complete.py
Normal file
63
src/application/commands/change_email_complete.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ChangeEmailCompleteCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: IUnitOfWork,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
logger: ILogger,
|
||||
):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._hash_service = hash_service
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, user_id: str, code: str) -> bool:
|
||||
code = (code or '').strip()
|
||||
|
||||
NEW_USER_PREFIX = 'change_email:new_user:'
|
||||
NEW_CODE_PREFIX = 'change_email:new_code:'
|
||||
|
||||
new_user_key = f'{NEW_USER_PREFIX}{user_id}'
|
||||
new_code_key = f'{NEW_CODE_PREFIX}{code}'
|
||||
|
||||
cached_user_id = await self._cache.get(new_code_key)
|
||||
if not cached_user_id:
|
||||
self._logger.info(f'Change email complete failed: code not found (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_user_id != user_id:
|
||||
self._logger.info(f'Change email complete failed: code-user mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
raw_value = await self._cache.get(new_user_key)
|
||||
if not raw_value:
|
||||
self._logger.info(f'Change email complete failed: user key missing (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
separator_idx = raw_value.index(':')
|
||||
code_hash = raw_value[:separator_idx]
|
||||
new_email = raw_value[separator_idx + 1:]
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info(f'Change email complete failed: code hash mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
user = await self._unit_of_work.user_repository.set_email(user_id=user_id, email=new_email)
|
||||
await self._cache.set_user(user_id, user)
|
||||
|
||||
try:
|
||||
await self._cache.delete(new_code_key)
|
||||
await self._cache.delete(new_user_key)
|
||||
except Exception as e:
|
||||
self._logger.warning(f'Change email complete cleanup failed (user_id={user_id}): {e}')
|
||||
|
||||
self._logger.info(f'Email changed for user_id={user_id}')
|
||||
return True
|
||||
145
src/application/commands/change_email_confirm_old.py
Normal file
145
src/application/commands/change_email_confirm_old.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ChangeEmailConfirmOldCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, user_id: str, code: str, new_email: str) -> bool:
|
||||
TTL = 300
|
||||
MAX_ATTEMPTS = 20
|
||||
|
||||
OLD_USER_PREFIX = 'change_email:old_user:'
|
||||
OLD_CODE_PREFIX = 'change_email:old_code:'
|
||||
NEW_USER_PREFIX = 'change_email:new_user:'
|
||||
NEW_CODE_PREFIX = 'change_email:new_code:'
|
||||
|
||||
code = (code or '').strip()
|
||||
old_user_key = f'{OLD_USER_PREFIX}{user_id}'
|
||||
old_code_key = f'{OLD_CODE_PREFIX}{code}'
|
||||
|
||||
cached_user_id = await self._cache.get(old_code_key)
|
||||
if not cached_user_id:
|
||||
self._logger.info(f'Change email confirm-old failed: code not found (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_user_id != user_id:
|
||||
self._logger.info(f'Change email confirm-old failed: code-user mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
code_hash = await self._cache.get(old_user_key)
|
||||
if not code_hash:
|
||||
self._logger.info(f'Change email confirm-old failed: user key missing (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info(f'Change email confirm-old failed: code hash mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
|
||||
if user.email and user.email.lower() == new_email.lower():
|
||||
self._logger.info(f'Change email confirm-old failed: new email same as current (user_id={user_id})')
|
||||
raise ApplicationException(400, 'New email must differ from the current one')
|
||||
|
||||
email_taken = await self._unit_of_work.user_repository.email_exists(email=new_email)
|
||||
if email_taken:
|
||||
self._logger.info(f'Change email confirm-old failed: new email already taken (user_id={user_id})')
|
||||
raise ApplicationException(409, 'Email already in use')
|
||||
|
||||
try:
|
||||
await self._cache.delete(old_code_key)
|
||||
await self._cache.delete(old_user_key)
|
||||
except Exception as e:
|
||||
self._logger.warning(f'Change email confirm-old cleanup failed (user_id={user_id}): {e}')
|
||||
|
||||
trace_id = trace_id_var.get()
|
||||
if not trace_id or trace_id == 'N/A':
|
||||
trace_id = None
|
||||
|
||||
new_user_key = f'{NEW_USER_PREFIX}{user_id}'
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
new_code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
new_code_key = f'{NEW_CODE_PREFIX}{new_code}'
|
||||
|
||||
new_code_hash = await self._hash_service.hash(new_code)
|
||||
|
||||
reserved = await self._cache.set_nx(new_code_key, user_id, ttl=TTL)
|
||||
if not reserved:
|
||||
continue
|
||||
|
||||
saved = await self._cache.set(new_user_key, f'{new_code_hash}:{new_email}', ttl=TTL)
|
||||
if not saved:
|
||||
await self._cache.delete(new_code_key)
|
||||
self._logger.error(f'Change email confirm-old failed: cannot save new code hash for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata = {
|
||||
'trace_id': trace_id,
|
||||
'source': 'user-service',
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
'email': new_email,
|
||||
'code': new_code,
|
||||
'ttl_seconds': TTL,
|
||||
}
|
||||
|
||||
message = {
|
||||
'event': 'change_email_new',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
self._logger.info(f'Change email new code created for user_id={user_id}')
|
||||
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
|
||||
message=message,
|
||||
persist=True,
|
||||
correlation_id=trace_id,
|
||||
message_id=message_id,
|
||||
headers={'trace_id': trace_id} if trace_id else None,
|
||||
)
|
||||
except Exception as exception:
|
||||
try:
|
||||
await self._cache.delete(new_user_key)
|
||||
await self._cache.delete(new_code_key)
|
||||
except Exception as rollback_err:
|
||||
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
|
||||
|
||||
self._logger.error(f'Failed to publish change email new code for user_id={user_id}: {str(exception)}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Change email confirm-old failed: code space exhausted for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
126
src/application/commands/change_email_start.py
Normal file
126
src/application/commands/change_email_start.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ChangeEmailStartCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, user_id: str) -> bool:
|
||||
TTL = 300
|
||||
LOCK_TTL = 30
|
||||
MAX_ATTEMPTS = 20
|
||||
|
||||
USER_PREFIX = 'change_email:old_user:'
|
||||
CODE_PREFIX = 'change_email:old_code:'
|
||||
LOCK_PREFIX = 'change_email:lock:'
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
|
||||
if not user.email:
|
||||
self._logger.warning(f'User {user_id} does not have an email address')
|
||||
raise ApplicationException(404, f'User {user_id} does not have an email address')
|
||||
|
||||
trace_id = trace_id_var.get()
|
||||
if not trace_id or trace_id == 'N/A':
|
||||
trace_id = None
|
||||
|
||||
lock_key = f'{LOCK_PREFIX}{user_id}'
|
||||
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
|
||||
if not locked:
|
||||
self._logger.info(f'Change email throttled by lock (user_id={user_id})')
|
||||
raise ApplicationException(429, 'Too many requests. Please wait.')
|
||||
|
||||
try:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
self._logger.info(f'Change email denied: code already exists for user_id={user_id}')
|
||||
raise ApplicationException(429, 'Code already sent. Please wait before retrying.')
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
code_hash = await self._hash_service.hash(code)
|
||||
|
||||
reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL)
|
||||
if not reserved:
|
||||
continue
|
||||
|
||||
saved = await self._cache.set(user_key, code_hash, ttl=TTL)
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
self._logger.error(f'Change email failed: cannot save code hash for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata = {
|
||||
'trace_id': trace_id,
|
||||
'source': 'user-service',
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
'email': user.email,
|
||||
'code': code,
|
||||
'ttl_seconds': TTL,
|
||||
}
|
||||
|
||||
message = {
|
||||
'event': 'change_email_old',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
self._logger.info(f'Change email old code created for user_id={user_id}')
|
||||
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
|
||||
message=message,
|
||||
persist=True,
|
||||
correlation_id=trace_id,
|
||||
message_id=message_id,
|
||||
headers={'trace_id': trace_id} if trace_id else None,
|
||||
)
|
||||
except Exception as exception:
|
||||
try:
|
||||
await self._cache.delete(user_key)
|
||||
await self._cache.delete(code_key)
|
||||
except Exception as rollback_err:
|
||||
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
|
||||
|
||||
self._logger.error(f'Failed to publish change email old code for user_id={user_id}: {str(exception)}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Change email failed: code space exhausted for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
finally:
|
||||
await self._cache.delete(lock_key)
|
||||
81
src/application/commands/change_password_complete.py
Normal file
81
src/application/commands/change_password_complete.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ChangePasswordCompleteCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: IUnitOfWork,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
logger: ILogger,
|
||||
):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._hash_service = hash_service
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
code: str,
|
||||
new_password: str,
|
||||
confirm_password: str,
|
||||
) -> bool:
|
||||
code = (code or '').strip()
|
||||
|
||||
USER_PREFIX = 'change_password:user:'
|
||||
CODE_PREFIX = 'change_password:code:'
|
||||
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
if new_password != confirm_password:
|
||||
self._logger.info(f'Change password failed: passwords do not match (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Passwords do not match')
|
||||
|
||||
cached_user_id = await self._cache.get(code_key)
|
||||
if not cached_user_id:
|
||||
self._logger.info(f'Change password failed: code not found (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_user_id != user_id:
|
||||
self._logger.info(f'Change password failed: code-user mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
code_hash = await self._cache.get(user_key)
|
||||
if not code_hash:
|
||||
self._logger.info(f'Change password failed: user key missing (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info(f'Change password failed: code hash mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
current_password_hash = await self._unit_of_work.user_repository.get_password_hash(user_id=user_id)
|
||||
|
||||
is_same = await self._hash_service.verify(hashed_value=current_password_hash, plain_value=new_password)
|
||||
if is_same:
|
||||
self._logger.info(f'Change password failed: new password same as current (user_id={user_id})')
|
||||
raise ApplicationException(400, 'New password must differ from the current one')
|
||||
|
||||
new_password_hash = await self._hash_service.hash(new_password)
|
||||
user = await self._unit_of_work.user_repository.set_password(
|
||||
user_id=user_id,
|
||||
password_hash=new_password_hash,
|
||||
)
|
||||
await self._cache.set_user(user_id, user)
|
||||
|
||||
try:
|
||||
await self._cache.delete(code_key)
|
||||
await self._cache.delete(user_key)
|
||||
except Exception as e:
|
||||
self._logger.warning(f'Change password cleanup failed (user_id={user_id}): {e}')
|
||||
|
||||
self._logger.info(f'Password changed for user_id={user_id}')
|
||||
return True
|
||||
126
src/application/commands/change_password_start.py
Normal file
126
src/application/commands/change_password_start.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class ChangePasswordStartCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, user_id: str) -> bool:
|
||||
TTL = 300
|
||||
LOCK_TTL = 30
|
||||
MAX_ATTEMPTS = 20
|
||||
|
||||
USER_PREFIX = 'change_password:user:'
|
||||
CODE_PREFIX = 'change_password:code:'
|
||||
LOCK_PREFIX = 'change_password:lock:'
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
|
||||
if not user.email:
|
||||
self._logger.warning(f'User {user_id} does not have an email address')
|
||||
raise ApplicationException(404, f'User {user_id} does not have an email address')
|
||||
|
||||
trace_id = trace_id_var.get()
|
||||
if not trace_id or trace_id == 'N/A':
|
||||
trace_id = None
|
||||
|
||||
lock_key = f'{LOCK_PREFIX}{user_id}'
|
||||
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
|
||||
if not locked:
|
||||
self._logger.info(f'Change password throttled by lock (user_id={user_id})')
|
||||
raise ApplicationException(429, 'Too many requests. Please wait.')
|
||||
|
||||
try:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
self._logger.info(f'Change password denied: code already exists for user_id={user_id}')
|
||||
raise ApplicationException(429, 'Code already sent. Please wait before retrying.')
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
code_hash = await self._hash_service.hash(code)
|
||||
|
||||
reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL)
|
||||
if not reserved:
|
||||
continue
|
||||
|
||||
saved = await self._cache.set(user_key, code_hash, ttl=TTL)
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
self._logger.error(f'Change password failed: cannot save code hash for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata = {
|
||||
'trace_id': trace_id,
|
||||
'source': 'user-service',
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
'email': user.email,
|
||||
'code': code,
|
||||
'ttl_seconds': TTL,
|
||||
}
|
||||
|
||||
message = {
|
||||
'event': 'change_password',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
self._logger.info(f'Change password code created for user_id={user_id}')
|
||||
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
|
||||
message=message,
|
||||
persist=True,
|
||||
correlation_id=trace_id,
|
||||
message_id=message_id,
|
||||
headers={'trace_id': trace_id} if trace_id else None,
|
||||
)
|
||||
except Exception as exception:
|
||||
try:
|
||||
await self._cache.delete(user_key)
|
||||
await self._cache.delete(code_key)
|
||||
except Exception as rollback_err:
|
||||
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
|
||||
|
||||
self._logger.error(f'Failed to publish change password email for user_id={user_id}: {str(exception)}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Change password failed: code space exhausted for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
finally:
|
||||
await self._cache.delete(lock_key)
|
||||
17
src/application/commands/get_me.py
Normal file
17
src/application/commands/get_me.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger, ICache
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class GetMeCommand:
|
||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
|
||||
@transactional
|
||||
async def __call__(self, user_id: str) -> UserEntity:
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
self._logger.info(f'User ID: {user.id}')
|
||||
return user
|
||||
68
src/application/commands/set_crypto_wallet_complete.py
Normal file
68
src/application/commands/set_crypto_wallet_complete.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class SetCryptoWalletCompleteCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: IUnitOfWork,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
logger: ILogger,
|
||||
):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._hash_service = hash_service
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, *, user_id: str, code: str, wallet_address: str) -> UserEntity:
|
||||
code = (code or '').strip()
|
||||
|
||||
USER_PREFIX = 'crypto_wallet:user:'
|
||||
CODE_PREFIX = 'crypto_wallet:code:'
|
||||
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
if user.crypto_wallet is not None:
|
||||
self._logger.info(f'Crypto wallet already set for user_id={user_id}')
|
||||
raise ApplicationException(409, 'Crypto wallet already set and cannot be changed')
|
||||
|
||||
cached_user_id = await self._cache.get(code_key)
|
||||
if not cached_user_id:
|
||||
self._logger.info(f'Crypto wallet set failed: code not found (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_user_id != user_id:
|
||||
self._logger.info(f'Crypto wallet set failed: code-user mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
code_hash = await self._cache.get(user_key)
|
||||
if not code_hash:
|
||||
self._logger.info(f'Crypto wallet set failed: user key missing (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info(f'Crypto wallet set failed: code hash mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
user = await self._unit_of_work.user_repository.set_crypto_wallet(
|
||||
user_id=user_id,
|
||||
wallet_address=wallet_address,
|
||||
)
|
||||
await self._cache.set_user(user_id, user)
|
||||
|
||||
try:
|
||||
await self._cache.delete(code_key)
|
||||
await self._cache.delete(user_key)
|
||||
except Exception as e:
|
||||
self._logger.warning(f'Crypto wallet set cleanup failed (user_id={user_id}): {e}')
|
||||
|
||||
self._logger.info(f'Crypto wallet set for user_id={user_id}')
|
||||
return user
|
||||
130
src/application/commands/set_crypto_wallet_start.py
Normal file
130
src/application/commands/set_crypto_wallet_start.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class SetCryptoWalletStartCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, user_id: str) -> bool:
|
||||
TTL = 300
|
||||
LOCK_TTL = 30
|
||||
MAX_ATTEMPTS = 20
|
||||
|
||||
USER_PREFIX = 'crypto_wallet:user:'
|
||||
CODE_PREFIX = 'crypto_wallet:code:'
|
||||
LOCK_PREFIX = 'crypto_wallet:lock:'
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
|
||||
if user.crypto_wallet is not None:
|
||||
self._logger.info(f'Crypto wallet already set for user_id={user_id}')
|
||||
raise ApplicationException(409, 'Crypto wallet already set and cannot be changed')
|
||||
|
||||
if not user.email:
|
||||
self._logger.warning(f'User {user_id} does not have an email address')
|
||||
raise ApplicationException(404, f'User {user_id} does not have an email address')
|
||||
|
||||
trace_id = trace_id_var.get()
|
||||
if not trace_id or trace_id == 'N/A':
|
||||
trace_id = None
|
||||
|
||||
lock_key = f'{LOCK_PREFIX}{user_id}'
|
||||
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
|
||||
if not locked:
|
||||
self._logger.info(f'Crypto wallet set throttled by lock (user_id={user_id})')
|
||||
raise ApplicationException(429, 'Too many requests. Please wait.')
|
||||
|
||||
try:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
self._logger.info(f'Crypto wallet set denied: code already exists for user_id={user_id}')
|
||||
raise ApplicationException(429, 'Code already sent. Please wait before retrying.')
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
code_hash = await self._hash_service.hash(code)
|
||||
|
||||
reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL)
|
||||
if not reserved:
|
||||
continue
|
||||
|
||||
saved = await self._cache.set(user_key, code_hash, ttl=TTL)
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
self._logger.error(f'Crypto wallet set failed: cannot save code hash for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata = {
|
||||
'trace_id': trace_id,
|
||||
'source': 'user-service',
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
'email': user.email,
|
||||
'code': code,
|
||||
'ttl_seconds': TTL,
|
||||
}
|
||||
|
||||
message = {
|
||||
'event': 'crypto_wallet_set',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
self._logger.info(f'Crypto wallet set code created for user_id={user_id}')
|
||||
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
|
||||
message=message,
|
||||
persist=True,
|
||||
correlation_id=trace_id,
|
||||
message_id=message_id,
|
||||
headers={'trace_id': trace_id} if trace_id else None,
|
||||
)
|
||||
except Exception as exception:
|
||||
try:
|
||||
await self._cache.delete(user_key)
|
||||
await self._cache.delete(code_key)
|
||||
except Exception as rollback_err:
|
||||
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
|
||||
|
||||
self._logger.error(f'Failed to publish crypto wallet set email for user_id={user_id}: {str(exception)}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Crypto wallet set failed: code space exhausted for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
finally:
|
||||
await self._cache.delete(lock_key)
|
||||
18
src/application/commands/set_phone.py
Normal file
18
src/application/commands/set_phone.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import ILogger, ICache
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class SetPhoneCommand:
|
||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger, cache: ICache):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
|
||||
@transactional
|
||||
async def __call__(self, user_id: str, phone: str) -> UserEntity:
|
||||
user = await self._unit_of_work.user_repository.set_phone(user_id=user_id, phone=phone)
|
||||
await self._cache.set_user(user_id, user)
|
||||
self._logger.info(f'Set phone for user {user_id}')
|
||||
return user
|
||||
76
src/application/commands/update_bank_details_complete.py
Normal file
76
src/application/commands/update_bank_details_complete.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ILogger, ICache
|
||||
from src.application.domain.entities import UserEntity
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class UpdateBankDetailsCompleteCommand:
|
||||
def __init__(
|
||||
self,
|
||||
unit_of_work: IUnitOfWork,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
logger: ILogger,
|
||||
):
|
||||
self._unit_of_work = unit_of_work
|
||||
self._hash_service = hash_service
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
|
||||
@transactional
|
||||
async def __call__(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
code: str,
|
||||
bik: str | None = None,
|
||||
account_number: str | None = None,
|
||||
card_number: str | None = None,
|
||||
) -> UserEntity:
|
||||
code = (code or '').strip()
|
||||
|
||||
USER_PREFIX = 'bank_details:user:'
|
||||
CODE_PREFIX = 'bank_details:code:'
|
||||
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
cached_user_id = await self._cache.get(code_key)
|
||||
if not cached_user_id:
|
||||
self._logger.info(f'Bank details update failed: code not found (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
if cached_user_id != user_id:
|
||||
self._logger.info(f'Bank details update failed: code-user mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
code_hash = await self._cache.get(user_key)
|
||||
if not code_hash:
|
||||
self._logger.info(f'Bank details update failed: user key missing (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
ok = await self._hash_service.verify(hashed_value=code_hash, plain_value=code)
|
||||
if not ok:
|
||||
self._logger.info(f'Bank details update failed: code hash mismatch (user_id={user_id})')
|
||||
raise ApplicationException(400, 'Invalid or expired code')
|
||||
|
||||
fields = {}
|
||||
if bik is not None:
|
||||
fields['bik'] = bik
|
||||
if account_number is not None:
|
||||
fields['account_number'] = account_number
|
||||
if card_number is not None:
|
||||
fields['card_number'] = card_number
|
||||
|
||||
user = await self._unit_of_work.user_repository.set_bank_details(user_id, **fields)
|
||||
await self._cache.set_user(user_id, user)
|
||||
|
||||
try:
|
||||
await self._cache.delete(code_key)
|
||||
await self._cache.delete(user_key)
|
||||
except Exception as e:
|
||||
self._logger.warning(f'Bank details update cleanup failed (user_id={user_id}): {e}')
|
||||
|
||||
self._logger.info(f'Bank details updated for user_id={user_id}, fields={list(fields.keys())}')
|
||||
return user
|
||||
126
src/application/commands/update_bank_details_start.py
Normal file
126
src/application/commands/update_bank_details_start.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IHashService, ICache, ILogger, IQueueMessanger
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.infrastructure.config import settings
|
||||
from src.infrastructure.context_vars import trace_id_var
|
||||
from src.infrastructure.database.decorators import transactional
|
||||
|
||||
|
||||
class UpdateBankDetailsStartCommand:
|
||||
def __init__(
|
||||
self,
|
||||
hash_service: IHashService,
|
||||
cache: ICache,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
messanger: IQueueMessanger,
|
||||
):
|
||||
self._hash_service = hash_service
|
||||
self._unit_of_work = unit_of_work
|
||||
self._cache = cache
|
||||
self._logger = logger
|
||||
self._messanger = messanger
|
||||
|
||||
@transactional
|
||||
async def __call__(self, user_id: str) -> bool:
|
||||
TTL = 300
|
||||
LOCK_TTL = 30
|
||||
MAX_ATTEMPTS = 20
|
||||
|
||||
USER_PREFIX = 'bank_details:user:'
|
||||
CODE_PREFIX = 'bank_details:code:'
|
||||
LOCK_PREFIX = 'bank_details:lock:'
|
||||
|
||||
user = await self._unit_of_work.user_repository.get_user_by_id(user_id=user_id)
|
||||
|
||||
if not user.email:
|
||||
self._logger.warning(f'User {user_id} does not have an email address')
|
||||
raise ApplicationException(status_code=404, message=f'User {user_id} does not have an email address')
|
||||
|
||||
trace_id = trace_id_var.get()
|
||||
if not trace_id or trace_id == 'N/A':
|
||||
trace_id = None
|
||||
|
||||
lock_key = f'{LOCK_PREFIX}{user_id}'
|
||||
locked = await self._cache.set_nx(lock_key, '1', ttl=LOCK_TTL)
|
||||
if not locked:
|
||||
self._logger.info(f'Bank details update throttled by lock (user_id={user_id})')
|
||||
raise ApplicationException(429, 'Too many requests. Please wait.')
|
||||
|
||||
try:
|
||||
user_key = f'{USER_PREFIX}{user_id}'
|
||||
|
||||
existing = await self._cache.get(user_key)
|
||||
if existing:
|
||||
self._logger.info(f'Bank details update denied: code already exists for user_id={user_id}')
|
||||
raise ApplicationException(429, 'Code already sent. Please wait before retrying.')
|
||||
|
||||
for _ in range(MAX_ATTEMPTS):
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
code_key = f'{CODE_PREFIX}{code}'
|
||||
|
||||
code_hash = await self._hash_service.hash(code)
|
||||
|
||||
reserved = await self._cache.set_nx(code_key, user_id, ttl=TTL)
|
||||
if not reserved:
|
||||
continue
|
||||
|
||||
saved = await self._cache.set(user_key, code_hash, ttl=TTL)
|
||||
if not saved:
|
||||
await self._cache.delete(code_key)
|
||||
self._logger.error(f'Bank details update failed: cannot save code hash for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
message_id = str(ULID())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
metadata = {
|
||||
'trace_id': trace_id,
|
||||
'source': 'user-service',
|
||||
'timestamp': now,
|
||||
'message_id': message_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
'email': user.email,
|
||||
'code': code,
|
||||
'ttl_seconds': TTL,
|
||||
}
|
||||
|
||||
message = {
|
||||
'event': 'bank_details_update',
|
||||
'payload': payload,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
self._logger.info(f'Bank details update code created for user_id={user_id}')
|
||||
|
||||
try:
|
||||
await self._messanger.publish_to_queue(
|
||||
queue=settings.RABBIT_EMAIL_CODE_QUEUE,
|
||||
message=message,
|
||||
persist=True,
|
||||
correlation_id=trace_id,
|
||||
message_id=message_id,
|
||||
headers={'trace_id': trace_id} if trace_id else None,
|
||||
)
|
||||
except Exception as exception:
|
||||
try:
|
||||
await self._cache.delete(user_key)
|
||||
await self._cache.delete(code_key)
|
||||
except Exception as rollback_err:
|
||||
self._logger.error(f'Publish failed and rollback cache failed for user_id={user_id}: {str(rollback_err)}')
|
||||
|
||||
self._logger.error(f'Failed to publish bank details update email for user_id={user_id}: {str(exception)}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
return True
|
||||
|
||||
self._logger.error(f'Bank details update failed: code space exhausted for user_id={user_id}')
|
||||
raise ApplicationException(503, 'Temporary error. Please try again.')
|
||||
|
||||
finally:
|
||||
await self._cache.delete(lock_key)
|
||||
6
src/application/contracts/__init__.py
Normal file
6
src/application/contracts/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from src.application.contracts.i_logger import ILogger
|
||||
from src.application.contracts.i_jwt_service import IJwtService
|
||||
from src.application.contracts.i_csrf_service import ICsrfService
|
||||
from src.application.contracts.i_cache import ICache
|
||||
from src.application.contracts.i_hash_service import IHashService
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
30
src/application/contracts/i_cache.py
Normal file
30
src/application/contracts/i_cache.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
class ICache(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, value: str, ttl: int) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_nx(self, key: str, value: str, ttl: int) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_user(self, user_id: str) -> dict | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None:
|
||||
raise NotImplementedError
|
||||
26
src/application/contracts/i_csrf_service.py
Normal file
26
src/application/contracts/i_csrf_service.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional, Mapping
|
||||
|
||||
|
||||
class ICsrfService(ABC):
|
||||
@abstractmethod
|
||||
def issue(self, subject: Optional[str] = None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def verify_pair(
|
||||
self,
|
||||
cookie_token: Optional[str],
|
||||
header_token: Optional[str],
|
||||
expected_subject: Optional[str] = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
12
src/application/contracts/i_hash_service.py
Normal file
12
src/application/contracts/i_hash_service.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class IHashService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def hash(self, value: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def verify(self, hashed_value: str, plain_value: str) -> bool:
|
||||
raise NotImplementedError
|
||||
10
src/application/contracts/i_jwt_service.py
Normal file
10
src/application/contracts/i_jwt_service.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from src.application.domain.dto import AccessTokenPayload
|
||||
|
||||
|
||||
class IJwtService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def decode_access_token(self, token: str) -> AccessTokenPayload:
|
||||
raise NotImplementedError
|
||||
68
src/application/contracts/i_logger.py
Normal file
68
src/application/contracts/i_logger.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from typing import Protocol, Optional, Callable
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
|
||||
|
||||
class ILogger(Protocol):
|
||||
"""Interface for synchronous logger with ContextVar support for trace_id."""
|
||||
|
||||
log_format: LogFormat
|
||||
min_level: LogLevel
|
||||
id_generator: Optional[Callable[[], str]]
|
||||
instance_id: str
|
||||
|
||||
def set_format(self, log_format: LogFormat) -> None:
|
||||
"""Set log format using LogFormat enum"""
|
||||
...
|
||||
|
||||
def set_min_level(self, level: LogLevel) -> None:
|
||||
"""Set minimum log level"""
|
||||
...
|
||||
|
||||
def new_trace_id(self) -> str:
|
||||
"""Create and set new trace_id in context"""
|
||||
...
|
||||
|
||||
def set_trace_id(self, trace_id: str) -> None:
|
||||
"""Set existing trace_id in context"""
|
||||
...
|
||||
|
||||
def get_trace_id(self) -> str:
|
||||
"""Get current trace_id from context"""
|
||||
...
|
||||
|
||||
def clear_trace_id(self) -> None:
|
||||
"""Clear the trace_id in the context"""
|
||||
...
|
||||
|
||||
def set_instance_id(self, instance_id: str) -> None:
|
||||
"""Set service instance id (ULID recommended)"""
|
||||
...
|
||||
|
||||
def get_instance_id(self) -> str:
|
||||
"""Get current service instance id"""
|
||||
...
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
"""Log debug message"""
|
||||
...
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
"""Log info message"""
|
||||
...
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
"""Log warning message"""
|
||||
...
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
"""Log error message"""
|
||||
...
|
||||
|
||||
def critical(self, message: str) -> None:
|
||||
"""Log critical message"""
|
||||
...
|
||||
|
||||
def exception(self, message: str) -> None:
|
||||
"""Log exception with traceback"""
|
||||
...
|
||||
40
src/application/contracts/i_queue_messanger.py
Normal file
40
src/application/contracts/i_queue_messanger.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Mapping, Any
|
||||
|
||||
|
||||
class IQueueMessanger(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def publish_to_queue(
|
||||
self,
|
||||
queue: str,
|
||||
message: Any,
|
||||
*,
|
||||
persist: bool = True,
|
||||
headers: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def publish(
|
||||
self,
|
||||
message: Any,
|
||||
*,
|
||||
exchange: str,
|
||||
routing_key: str,
|
||||
persist: bool = True,
|
||||
headers: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
2
src/application/domain/dto/__init__.py
Normal file
2
src/application/domain/dto/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.domain.dto.token import AccessTokenPayload, AuthContext
|
||||
from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet
|
||||
20
src/application/domain/dto/keys.py
Normal file
20
src/application/domain/dto/keys.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JwtPublicKey:
|
||||
kid: str
|
||||
public_key_pem: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JwtPublicKeySet:
|
||||
active: JwtPublicKey
|
||||
previous: Optional[JwtPublicKey] = None
|
||||
|
||||
def public_keys_by_kid(self) -> Dict[str, str]:
|
||||
out = {self.active.kid: self.active.public_key_pem}
|
||||
if self.previous:
|
||||
out[self.previous.kid] = self.previous.public_key_pem
|
||||
return out
|
||||
18
src/application/domain/dto/token.py
Normal file
18
src/application/domain/dto/token.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AccessTokenPayload(BaseModel):
|
||||
sub: str
|
||||
type: str
|
||||
sid: str
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
iss: str | None = None
|
||||
aud: str | None = None
|
||||
|
||||
|
||||
class AuthContext(BaseModel):
|
||||
user_id: str
|
||||
sid: str
|
||||
token: AccessTokenPayload
|
||||
5
src/application/domain/entities/__init__.py
Normal file
5
src/application/domain/entities/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
from src.application.domain.entities.session import SessionEntity
|
||||
|
||||
|
||||
__all__ = ['UserEntity', 'SessionEntity']
|
||||
20
src/application/domain/entities/session.py
Normal file
20
src/application/domain/entities/session.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SessionEntity:
|
||||
sid: str
|
||||
user_id: str
|
||||
device_id: str
|
||||
|
||||
revoked_at: datetime | None
|
||||
last_seen_at: datetime
|
||||
|
||||
refresh_jti_hash: str | None
|
||||
refresh_expires_at: datetime | None
|
||||
|
||||
user_agent: str | None = None
|
||||
first_ip: str | None = None
|
||||
last_ip: str | None = None
|
||||
30
src/application/domain/entities/user.py
Normal file
30
src/application/domain/entities/user.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserEntity:
|
||||
id: str | None = None
|
||||
email: str | None = None
|
||||
password_hash: str | None = None
|
||||
|
||||
first_name: str | None = None
|
||||
middle_name: str | None = None
|
||||
last_name: str | None = None
|
||||
birth_date: date | None = None
|
||||
|
||||
crypto_wallet: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
bik: str | None = None
|
||||
account_number: str | None = None
|
||||
card_number: str | None = None
|
||||
inn: str | None = None
|
||||
|
||||
kyc_verified: bool | None = None
|
||||
is_deleted: bool | None = None
|
||||
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
kyc_verified_at: datetime | None = None
|
||||
2
src/application/domain/enums/__init__.py
Normal file
2
src/application/domain/enums/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
7
src/application/domain/enums/log_format.py
Normal file
7
src/application/domain/enums/log_format.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogFormat(Enum):
|
||||
"""Enum for supported log formats"""
|
||||
TEXT = 'text'
|
||||
JSON = 'json'
|
||||
54
src/application/domain/enums/log_level.py
Normal file
54
src/application/domain/enums/log_level.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
WARNING = 30
|
||||
ERROR = 40
|
||||
CRITICAL = 50
|
||||
EXCEPTION = 60
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"[{self.value}, '{self.name}']"
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, LogLevel):
|
||||
return self.value == other.value
|
||||
if isinstance(other, int):
|
||||
return self.value == other
|
||||
return NotImplemented
|
||||
|
||||
def __ne__(self, other: object) -> bool:
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if isinstance(other, LogLevel):
|
||||
return self.value < other.value
|
||||
if isinstance(other, int):
|
||||
return self.value < other
|
||||
return NotImplemented
|
||||
|
||||
def __le__(self, other: object) -> bool:
|
||||
if isinstance(other, LogLevel):
|
||||
return self.value <= other.value
|
||||
if isinstance(other, int):
|
||||
return self.value <= other
|
||||
return NotImplemented
|
||||
|
||||
def __gt__(self, other: object) -> bool:
|
||||
if isinstance(other, LogLevel):
|
||||
return self.value > other.value
|
||||
if isinstance(other, int):
|
||||
return self.value > other
|
||||
return NotImplemented
|
||||
|
||||
def __ge__(self, other: object) -> bool:
|
||||
if isinstance(other, LogLevel):
|
||||
return self.value >= other.value
|
||||
if isinstance(other, int):
|
||||
return self.value >= other
|
||||
return NotImplemented
|
||||
1
src/application/domain/exceptions/__init__.py
Normal file
1
src/application/domain/exceptions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
||||
18
src/application/domain/exceptions/application_exceptions.py
Normal file
18
src/application/domain/exceptions/application_exceptions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class ApplicationException(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.headers = headers
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.status_code}: {self.message}"
|
||||
Reference in New Issue
Block a user