diff --git a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py index a8a1240b4f91..13b92ed4993f 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_controller/_conversations_messages_rest.py @@ -1,6 +1,9 @@ +import functools import logging +from typing import Any from aiohttp import web +from common_library.json_serialization import json_dumps from models_library.api_schemas_webserver.conversations import ( ConversationMessagePatch, ConversationMessageRestGet, @@ -16,6 +19,7 @@ PageQueryParameters, ) from models_library.rest_pagination_utils import paginate_data +from models_library.utils.fastapi_encoders import jsonable_encoder from pydantic import BaseModel, ConfigDict from servicelib.aiohttp import status from servicelib.aiohttp.requests_validation import ( @@ -25,11 +29,13 @@ ) from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON from servicelib.rest_constants import RESPONSE_MODEL_POLICY +from simcore_service_webserver.users import users_service from ..._meta import API_VTAG as VTAG +from ...email import email_service from ...login.decorators import login_required from ...models import AuthenticatedRequestContext -from ...users import users_service +from ...products import products_web from ...utils_aiohttp import envelope_json_response from .. import _conversation_message_service, _conversation_service from ._common import ConversationPathParams, raise_unsupported_type @@ -56,6 +62,10 @@ class _ConversationMessageCreateBodyParams(BaseModel): model_config = ConfigDict(extra="forbid") +def _json_encoder_and_dumps(obj: Any, **kwargs): + return json_dumps(jsonable_encoder(obj), **kwargs) + + @routes.post( f"/{VTAG}/conversations/{{conversation_id}}/messages", name="create_conversation_message", @@ -70,27 +80,75 @@ async def create_conversation_message(request: web.Request): _ConversationMessageCreateBodyParams, request ) - user_primary_gid = await users_service.get_user_primary_group_id( - request.app, user_id=req_ctx.user_id - ) - conversation = await _conversation_service.get_conversation_for_user( - app=request.app, - conversation_id=path_params.conversation_id, - user_group_id=user_primary_gid, + _conversation = await _conversation_service.get_conversation( + request.app, conversation_id=path_params.conversation_id ) - # Ensure only support conversations are allowed - if conversation.type != ConversationType.SUPPORT: - raise_unsupported_type(conversation.type) + if _conversation.type != ConversationType.SUPPORT: + raise_unsupported_type(_conversation.type) - message = await _conversation_message_service.create_message( + # This function takes care of granting support user access to the message + await _conversation_service.get_support_conversation_for_user( app=request.app, user_id=req_ctx.user_id, - project_id=None, # Support conversations don't use project_id + product_name=req_ctx.product_name, conversation_id=path_params.conversation_id, - content=body_params.content, - type_=body_params.type, ) + message, is_first_message = ( + await _conversation_message_service.create_support_message_with_first_check( + app=request.app, + user_id=req_ctx.user_id, + project_id=None, # Support conversations don't use project_id + conversation_id=path_params.conversation_id, + content=body_params.content, + type_=body_params.type, + ) + ) + + # NOTE: This is done here in the Controller layer, as the interface around email currently needs request + if is_first_message: + try: + user = await users_service.get_user(request.app, req_ctx.user_id) + product = products_web.get_current_product(request) + template_name = "request_support.jinja2" + destination_email = product.support_email + email_template_path = await products_web.get_product_template_path( + request, template_name + ) + _url = request.url + if _url.port: + _conversation_url = f"{_url.scheme}://{_url.host}:{_url.port}/#/conversations/{path_params.conversation_id}" + else: + _conversation_url = f"{_url.scheme}://{_url.host}/#/conversations/{path_params.conversation_id}" + _extra_context = _conversation.extra_context + await email_service.send_email_from_template( + request, + from_=product.support_email, + to=destination_email, + template=email_template_path, + context={ + "host": request.host, + "product": product.model_dump( + include={ + "display_name", + } + ), + "first_name": user["first_name"], + "last_name": user["last_name"], + "user_email": user["email"], + "conversation_url": _conversation_url, + "message_content": message.content, + "extra_context": _extra_context, + "dumps": functools.partial(_json_encoder_and_dumps, indent=1), + }, + ) + except Exception: # pylint: disable=broad-except + _logger.exception( + "Failed to send '%s' email to %s (this means the FogBugz case for the request was not created).", + template_name, + destination_email, + ) + data = ConversationMessageRestGet.from_domain_model(message) return envelope_json_response(data, web.HTTPCreated) @@ -109,16 +167,19 @@ async def list_conversation_messages(request: web.Request): _ListConversationMessageQueryParams, request ) - user_primary_gid = await users_service.get_user_primary_group_id( - request.app, user_id=req_ctx.user_id + _conversation = await _conversation_service.get_conversation( + request.app, conversation_id=path_params.conversation_id ) - conversation = await _conversation_service.get_conversation_for_user( + if _conversation.type != ConversationType.SUPPORT: + raise_unsupported_type(_conversation.type) + + # This function takes care of granting support user access to the message + await _conversation_service.get_support_conversation_for_user( app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, conversation_id=path_params.conversation_id, - user_group_id=user_primary_gid, ) - if conversation.type != ConversationType.SUPPORT: - raise_unsupported_type(conversation.type) total, messages = ( await _conversation_message_service.list_messages_for_conversation( @@ -160,16 +221,19 @@ async def get_conversation_message(request: web.Request): _ConversationMessagePathParams, request ) - user_primary_gid = await users_service.get_user_primary_group_id( - request.app, user_id=req_ctx.user_id + _conversation = await _conversation_service.get_conversation( + request.app, conversation_id=path_params.conversation_id ) - conversation = await _conversation_service.get_conversation_for_user( + if _conversation.type != ConversationType.SUPPORT: + raise_unsupported_type(_conversation.type) + + # This function takes care of granting support user access to the message + await _conversation_service.get_support_conversation_for_user( app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, conversation_id=path_params.conversation_id, - user_group_id=user_primary_gid, ) - if conversation.type != ConversationType.SUPPORT: - raise_unsupported_type(conversation.type) message = await _conversation_message_service.get_message( app=request.app, @@ -195,16 +259,19 @@ async def update_conversation_message(request: web.Request): ) body_params = await parse_request_body_as(ConversationMessagePatch, request) - user_primary_gid = await users_service.get_user_primary_group_id( - request.app, user_id=req_ctx.user_id + _conversation = await _conversation_service.get_conversation( + request.app, conversation_id=path_params.conversation_id ) - conversation = await _conversation_service.get_conversation_for_user( + if _conversation.type != ConversationType.SUPPORT: + raise_unsupported_type(_conversation.type) + + # This function takes care of granting support user access to the message + await _conversation_service.get_support_conversation_for_user( app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, conversation_id=path_params.conversation_id, - user_group_id=user_primary_gid, ) - if conversation.type != ConversationType.SUPPORT: - raise_unsupported_type(conversation.type) message = await _conversation_message_service.update_message( app=request.app, @@ -231,16 +298,19 @@ async def delete_conversation_message(request: web.Request): _ConversationMessagePathParams, request ) - user_primary_gid = await users_service.get_user_primary_group_id( - request.app, user_id=req_ctx.user_id + _conversation = await _conversation_service.get_conversation( + request.app, conversation_id=path_params.conversation_id ) - conversation = await _conversation_service.get_conversation_for_user( + if _conversation.type != ConversationType.SUPPORT: + raise_unsupported_type(_conversation.type) + + # This function takes care of granting support user access to the message + await _conversation_service.get_support_conversation_for_user( app=request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, conversation_id=path_params.conversation_id, - user_group_id=user_primary_gid, ) - if conversation.type != ConversationType.SUPPORT: - raise_unsupported_type(conversation.type) await _conversation_message_service.delete_message( app=request.app, diff --git a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py index 0a82df849cc5..1311654d60c6 100644 --- a/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py +++ b/services/web/server/src/simcore_service_webserver/conversations/_conversation_message_service.py @@ -15,8 +15,9 @@ from models_library.rest_ordering import OrderBy, OrderDirection from models_library.rest_pagination import PageTotalCount from models_library.users import UserID +from servicelib.redis import exclusive -# Import or define SocketMessageDict +from ..redis import get_redis_lock_manager_client_sdk from ..users import users_service from . import _conversation_message_repository from ._conversation_service import _get_recipients @@ -28,6 +29,9 @@ _logger = logging.getLogger(__name__) +# Redis lock key for conversation message operations +CONVERSATION_MESSAGE_REDIS_LOCK_KEY = "conversation_message_update:{}" + async def create_message( app: web.Application, @@ -60,6 +64,73 @@ async def create_message( return created_message +async def create_support_message_with_first_check( + app: web.Application, + *, + user_id: UserID, + project_id: ProjectID | None, + conversation_id: ConversationID, + # Creation attributes + content: str, + type_: ConversationMessageType, +) -> tuple[ConversationMessageGetDB, bool]: + """Create a message and check if it's the first one with Redis lock protection. + + This function is protected by Redis exclusive lock because: + - the message creation and first message check must be kept in sync + + Args: + app: The web application instance + user_id: ID of the user creating the message + project_id: ID of the project (optional) + conversation_id: ID of the conversation + content: Content of the message + type_: Type of the message + + Returns: + Tuple containing the created message and whether it's the first message + """ + + @exclusive( + get_redis_lock_manager_client_sdk(app), + lock_key=CONVERSATION_MESSAGE_REDIS_LOCK_KEY.format(conversation_id), + blocking=True, + blocking_timeout=None, # NOTE: this is a blocking call, a timeout has undefined effects + ) + async def _create_support_message_and_check_if_it_is_first_message() -> ( + tuple[ConversationMessageGetDB, bool] + ): + """This function is protected because + - the message creation and first message check must be kept in sync + """ + created_message = await create_message( + app, + user_id=user_id, + project_id=project_id, + conversation_id=conversation_id, + content=content, + type_=type_, + ) + _, messages = await _conversation_message_repository.list_( + app, + conversation_id=conversation_id, + offset=0, + limit=1, + order_by=OrderBy( + field=IDStr("created"), direction=OrderDirection.ASC + ), # NOTE: ASC - first/oldest message first + ) + + is_first_message = False + if messages: + first_message = messages[0] + is_first_message = first_message.message_id == created_message.message_id + + return created_message, is_first_message + + return await _create_support_message_and_check_if_it_is_first_message() + + async def get_message( app: web.Application, *, diff --git a/services/web/server/src/simcore_service_webserver/email/_core.py b/services/web/server/src/simcore_service_webserver/email/_core.py index c9a137337449..8736a640ab96 100644 --- a/services/web/server/src/simcore_service_webserver/email/_core.py +++ b/services/web/server/src/simcore_service_webserver/email/_core.py @@ -15,6 +15,7 @@ from aiohttp_jinja2 import render_string from settings_library.email import EmailProtocol, SMTPSettings +from ..products import products_web from .settings import get_plugin_settings _logger = logging.getLogger(__name__) @@ -225,6 +226,10 @@ def _render_template( return subject, html_body +async def get_template_path(request: web.Request, filename: str) -> Path: + return await products_web.get_product_template_path(request, filename) + + async def send_email_from_template( request: web.Request, *, diff --git a/services/web/server/src/simcore_service_webserver/email/email_service.py b/services/web/server/src/simcore_service_webserver/email/email_service.py new file mode 100644 index 000000000000..0648210d9f14 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/email/email_service.py @@ -0,0 +1,19 @@ +import logging + +from ._core import AttachmentTuple, get_template_path, send_email_from_template + +log = logging.getLogger(__name__) + + +# prevents auto-removal by pycln +# mypy: disable-error-code=truthy-function +assert AttachmentTuple # nosec +assert send_email_from_template # nosec +assert get_template_path # nosec + + +__all__: tuple[str, ...] = ( + "AttachmentTuple", + "send_email_from_template", + "get_template_path", +) diff --git a/services/web/server/src/simcore_service_webserver/email/utils.py b/services/web/server/src/simcore_service_webserver/email/utils.py deleted file mode 100644 index 9be692219271..000000000000 --- a/services/web/server/src/simcore_service_webserver/email/utils.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._core import AttachmentTuple, send_email_from_template - -__all__ = ("AttachmentTuple", "send_email_from_template") diff --git a/services/web/server/src/simcore_service_webserver/login/_emails_service.py b/services/web/server/src/simcore_service_webserver/login/_emails_service.py index 9aef8317104c..4a0fb78f2f86 100644 --- a/services/web/server/src/simcore_service_webserver/login/_emails_service.py +++ b/services/web/server/src/simcore_service_webserver/login/_emails_service.py @@ -1,11 +1,12 @@ import logging from pathlib import Path -from aiohttp import web - from .._resources import webserver_resources -from ..email.utils import AttachmentTuple, send_email_from_template -from ..products import products_web +from ..email.email_service import ( + AttachmentTuple, + get_template_path, + send_email_from_template, +) log = logging.getLogger(__name__) @@ -15,17 +16,15 @@ def themed(dirname: str, template: str) -> Path: return path -async def get_template_path(request: web.Request, filename: str) -> Path: - return await products_web.get_product_template_path(request, filename) - - # prevents auto-removal by pycln # mypy: disable-error-code=truthy-function assert AttachmentTuple # nosec assert send_email_from_template # nosec +assert get_template_path # nosec __all__: tuple[str, ...] = ( "AttachmentTuple", "send_email_from_template", + "get_template_path", ) diff --git a/services/web/server/src/simcore_service_webserver/login_accounts/_service.py b/services/web/server/src/simcore_service_webserver/login_accounts/_service.py index 5173a861ce2a..c882f9501e9d 100644 --- a/services/web/server/src/simcore_service_webserver/login_accounts/_service.py +++ b/services/web/server/src/simcore_service_webserver/login_accounts/_service.py @@ -14,7 +14,7 @@ from pydantic import EmailStr, PositiveInt, TypeAdapter, ValidationError from servicelib.utils_secrets import generate_passcode -from ..email.utils import send_email_from_template +from ..email.email_service import send_email_from_template from ..products import products_web from ..products.models import Product from ..users import _accounts_service diff --git a/services/web/server/src/simcore_service_webserver/templates/common/request_support.jinja2 b/services/web/server/src/simcore_service_webserver/templates/common/request_support.jinja2 new file mode 100644 index 000000000000..8924f2e33e17 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/templates/common/request_support.jinja2 @@ -0,0 +1,36 @@ +Request for Support on {{ host }} + +

+ Dear Support Team, +

+ +

+ We have received a support request from {{ first_name }} {{ last_name }} ({{ user_email }}) regarding an account in {{ product.display_name }} on {{ host }}. +

+ +

+ All communication should take place in the Platform Support Center at the following link: + {{ conversation_url }} +

+ +

+ First message content: {{ message_content }} +

+ +

+ Extra Context: +

+ +
+{{ dumps(extra_context) }}
+
diff --git a/services/web/server/tests/unit/with_dbs/03/test_email.py b/services/web/server/tests/unit/with_dbs/03/test_email.py index 5141bd862db1..5cf04129d014 100644 --- a/services/web/server/tests/unit/with_dbs/03/test_email.py +++ b/services/web/server/tests/unit/with_dbs/03/test_email.py @@ -214,6 +214,7 @@ def test_render_templates(template_path: Path, faker: Faker): "dumps": functools.partial(_json_encoder_and_dumps, indent=1), "request_form": fake_request_form, "ipinfo": _get_ipinfo(request), + "extra_context": {"extra": "information"}, }, ) diff --git a/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py b/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py index d2f6fcd3e0a6..2c8c1a8e15af 100644 --- a/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py +++ b/services/web/server/tests/unit/with_dbs/04/conversations/test_conversations_messages_rest.py @@ -448,3 +448,61 @@ async def test_conversation_messages_nonexistent_resources( ) resp = await client.delete(f"{delete_url}") await assert_status(resp, status.HTTP_404_NOT_FOUND) + + +@pytest.mark.parametrize("user_role", [UserRole.USER]) +async def test_conversation_messages_with_database( + client: TestClient, + logged_user: UserInfoDict, + mocker: MockerFixture, +): + """Test conversation messages with direct database interaction""" + # Mock the email service to verify it's called for first message + mock_send_email = mocker.patch( + "simcore_service_webserver.email.email_service.send_email_from_template" + ) + + assert client.app + + # Create a conversation directly via API (no mocks) + base_url = client.app.router["list_conversations"].url_for() + body = {"name": "Database Test Conversation", "type": "SUPPORT"} + resp = await client.post(f"{base_url}", json=body) + data, _ = await assert_status(resp, status.HTTP_201_CREATED) + conversation_id = data["conversationId"] + + # Verify the conversation was created + assert conversation_id is not None + assert data["name"] == "Database Test Conversation" + assert data["type"] == "SUPPORT" + + # Create a message in the conversation + create_message_url = client.app.router["create_conversation_message"].url_for( + conversation_id=conversation_id + ) + message_body = {"content": "Hello from database test", "type": "MESSAGE"} + resp = await client.post(f"{create_message_url}", json=message_body) + message_data, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # Verify the message was created + assert message_data["messageId"] is not None + assert message_data["content"] == "Hello from database test" + assert message_data["type"] == "MESSAGE" + assert message_data["conversationId"] == conversation_id + + # Verify email was sent for first message + assert mock_send_email.call_count == 1 + + # Create a second message + second_message_body = {"content": "Second message", "type": "MESSAGE"} + resp = await client.post(f"{create_message_url}", json=second_message_body) + second_message_data, _ = await assert_status(resp, status.HTTP_201_CREATED) + + # Verify the second message was created + assert second_message_data["messageId"] is not None + assert second_message_data["content"] == "Second message" + assert second_message_data["type"] == "MESSAGE" + assert second_message_data["conversationId"] == conversation_id + + # Verify email was NOT sent again for second message (still only 1 call) + assert mock_send_email.call_count == 1