feat(account): GET /me user endpoint only, disable cache and extra routers

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 20:44:35 +03:00
commit d94dd31439
107 changed files with 5083 additions and 0 deletions

View 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

View 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

View 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.')

View 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)

View 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

View 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)

View 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

View 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

View 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)

View 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

View 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

View 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)