from typing import cast import strawberry from strawberry.types import Info from strawberry.relay import GlobalID from channels.db import database_sync_to_async from django.utils import timezone from django.contrib.contenttypes.models import ContentType import json from core.graphql.pubsub import pubsub from core.graphql.inputs.messaging import ( ConversationInput, ConversationUpdateInput, MessageInput, MessageUpdateInput, AddParticipantInput, RemoveParticipantInput, MarkAsReadInput, ArchiveConversationInput, MuteConversationInput, ) from core.graphql.types.messaging import ConversationType, MessageType, ConversationParticipantType from core.models.messaging import Conversation, Message, ConversationParticipant, MessageReadReceipt from core.models.profile import TeamProfile, CustomerProfile from core.models.enums import EventTypeChoices from core.services.events import EventPublisher def is_admin_profile(profile) -> bool: """Check if the profile is the admin profile""" from django.conf import settings return str(profile.id) == settings.DISPATCH_TEAM_PROFILE_ID @database_sync_to_async def get_profile_from_id(participant_id: str): """Helper to get TeamProfile or CustomerProfile from GlobalID""" # Try TeamProfile first try: return TeamProfile.objects.get(pk=participant_id) except TeamProfile.DoesNotExist: pass # Try CustomerProfile try: return CustomerProfile.objects.get(pk=participant_id) except CustomerProfile.DoesNotExist: raise ValueError(f"Profile with ID {participant_id} not found") @database_sync_to_async def get_entity_from_type_and_id(entity_type: str, entity_id: str): """Helper to get entity (Project, Service, etc.) from type and ID""" from django.apps import apps try: model = apps.get_model('core', entity_type) return model.objects.get(pk=entity_id) except Exception as e: raise ValueError(f"Entity {entity_type} with ID {entity_id} not found: {e}") @strawberry.type class Mutation: @strawberry.mutation(description="Create a new conversation") async def create_conversation(self, input: ConversationInput, info: Info) -> ConversationType: """ Create a new conversation with participants and optional entity link. """ profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") # Parse metadata if provided metadata = json.loads(input.metadata) if input.metadata else {} # Create conversation @database_sync_to_async def create(): # Get creator content type inside sync context creator_content_type = ContentType.objects.get_for_model(type(profile)) conversation = Conversation.objects.create( subject=input.subject, conversation_type=input.conversation_type, created_by_content_type=creator_content_type, created_by_object_id=profile.id, metadata=metadata ) # Link to entity if provided if input.entity_type and input.entity_id: from django.apps import apps try: model = apps.get_model('core', input.entity_type) content_type = ContentType.objects.get_for_model(model) conversation.entity_content_type = content_type # Extract UUID from GlobalID conversation.entity_object_id = input.entity_id.node_id conversation.save() except Exception: pass # Add creator as a participant first ConversationParticipant.objects.create( conversation=conversation, participant_content_type=creator_content_type, participant_object_id=profile.id ) # Add other participants for participant_id in input.participant_ids: # Extract UUID from GlobalID uuid = participant_id.node_id try: participant = TeamProfile.objects.get(pk=uuid) content_type = ContentType.objects.get_for_model(TeamProfile) except TeamProfile.DoesNotExist: try: participant = CustomerProfile.objects.get(pk=uuid) content_type = ContentType.objects.get_for_model(CustomerProfile) except CustomerProfile.DoesNotExist: continue # Skip if this participant is the creator (already added) if content_type == creator_content_type and participant.id == profile.id: continue ConversationParticipant.objects.create( conversation=conversation, participant_content_type=content_type, participant_object_id=participant.id ) return conversation instance = await create() await pubsub.publish("conversation_created", instance.id) # Publish event await EventPublisher.publish( event_type=EventTypeChoices.CONVERSATION_CREATED, entity_type='Conversation', entity_id=str(instance.id), triggered_by=profile, metadata={'subject': instance.subject, 'type': instance.conversation_type} ) return cast(ConversationType, instance) @strawberry.mutation(description="Update a conversation") async def update_conversation(self, input: ConversationUpdateInput, info: Info) -> ConversationType: """Update conversation details""" profile = getattr(info.context.request, 'profile', None) @database_sync_to_async def update(): conversation = Conversation.objects.get(pk=input.id.node_id) if input.subject is not None: conversation.subject = input.subject if input.is_archived is not None: conversation.is_archived = input.is_archived if input.metadata is not None: conversation.metadata = json.loads(input.metadata) conversation.save() return conversation instance = await update() await pubsub.publish("conversation_updated", instance.id) if input.is_archived: await EventPublisher.publish( event_type=EventTypeChoices.CONVERSATION_ARCHIVED, entity_type='Conversation', entity_id=str(instance.id), triggered_by=profile ) return cast(ConversationType, instance) @strawberry.mutation(description="Send a message in a conversation") async def send_message(self, input: MessageInput, info: Info) -> MessageType: """ Send a new message in a conversation. Updates unread counts for other participants. """ profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") # Parse attachments and metadata attachments = json.loads(input.attachments) if input.attachments else [] metadata = json.loads(input.metadata) if input.metadata else {} @database_sync_to_async def create(): # Get sender content type inside sync context sender_content_type = ContentType.objects.get_for_model(type(profile)) # Extract UUIDs from GlobalIDs conversation_uuid = input.conversation_id.node_id reply_to_uuid = input.reply_to_id.node_id if input.reply_to_id else None # Create message message = Message.objects.create( conversation_id=conversation_uuid, sender_content_type=sender_content_type, sender_object_id=profile.id, body=input.body, reply_to_id=reply_to_uuid, attachments=attachments, metadata=metadata ) # Update conversation last_message_at conversation = message.conversation conversation.last_message_at = message.created_at conversation.save(update_fields=['last_message_at', 'updated_at']) # Increment unread count for all participants except sender participants = ConversationParticipant.objects.filter( conversation=conversation ).exclude( participant_content_type=sender_content_type, participant_object_id=profile.id ) for participant in participants: participant.unread_count += 1 participant.save(update_fields=['unread_count', 'updated_at']) return message instance = await create() await pubsub.publish("message_sent", { "message_id": instance.id, "conversation_id": str(input.conversation_id) }) # Publish event await EventPublisher.publish( event_type=EventTypeChoices.MESSAGE_SENT, entity_type='Message', entity_id=str(instance.id), triggered_by=profile, metadata={ 'conversation_id': str(input.conversation_id), 'body_preview': instance.body[:100] } ) return cast(MessageType, instance) @strawberry.mutation(description="Mark conversation as read") async def mark_conversation_as_read(self, input: MarkAsReadInput, info: Info) -> ConversationType: """ Mark all messages in a conversation as read for the current user. Resets unread count to 0. """ profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") content_type = ContentType.objects.get_for_model(type(profile)) @database_sync_to_async def mark_read(): conversation = Conversation.objects.get(pk=input.conversation_id.node_id) # Update participant record participant = ConversationParticipant.objects.get( conversation=conversation, participant_content_type=content_type, participant_object_id=profile.id ) participant.last_read_at = timezone.now() participant.unread_count = 0 participant.save(update_fields=['last_read_at', 'unread_count', 'updated_at']) # Create read receipts for unread messages messages = Message.objects.filter( conversation=conversation, created_at__gt=participant.last_read_at or timezone.now() ).exclude( sender_content_type=content_type, sender_object_id=profile.id ) for message in messages: MessageReadReceipt.objects.get_or_create( message=message, reader_content_type=content_type, reader_object_id=profile.id ) return conversation instance = await mark_read() await pubsub.publish("conversation_read", { "conversation_id": instance.id, "participant_id": str(profile.id) }) return cast(ConversationType, instance) @strawberry.mutation(description="Archive or unarchive a conversation") async def archive_conversation(self, input: ArchiveConversationInput, info: Info) -> ConversationType: """Archive or unarchive a conversation for the current user""" profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") content_type = ContentType.objects.get_for_model(type(profile)) @database_sync_to_async def archive(): conversation = Conversation.objects.get(pk=input.conversation_id.node_id) participant = ConversationParticipant.objects.get( conversation=conversation, participant_content_type=content_type, participant_object_id=profile.id ) participant.is_archived = input.is_archived participant.save(update_fields=['is_archived', 'updated_at']) return conversation instance = await archive() return cast(ConversationType, instance) @strawberry.mutation(description="Mute or unmute a conversation") async def mute_conversation(self, input: MuteConversationInput, info: Info) -> ConversationType: """Mute or unmute notifications for a conversation""" profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") content_type = ContentType.objects.get_for_model(type(profile)) @database_sync_to_async def mute(): conversation = Conversation.objects.get(pk=input.conversation_id.node_id) participant = ConversationParticipant.objects.get( conversation=conversation, participant_content_type=content_type, participant_object_id=profile.id ) participant.is_muted = input.is_muted participant.save(update_fields=['is_muted', 'updated_at']) return conversation instance = await mute() return cast(ConversationType, instance) @strawberry.mutation(description="Add a participant to a conversation") async def add_participant(self, input: AddParticipantInput, info: Info) -> ConversationParticipantType: """Add a new participant to an existing conversation""" profile = getattr(info.context.request, 'profile', None) @database_sync_to_async def add(): conversation = Conversation.objects.get(pk=input.conversation_id.node_id) # Get participant profile participant_uuid = input.participant_id.node_id try: participant = TeamProfile.objects.get(pk=participant_uuid) content_type = ContentType.objects.get_for_model(TeamProfile) except TeamProfile.DoesNotExist: participant = CustomerProfile.objects.get(pk=participant_uuid) content_type = ContentType.objects.get_for_model(CustomerProfile) # Create participant record conv_participant, created = ConversationParticipant.objects.get_or_create( conversation=conversation, participant_content_type=content_type, participant_object_id=participant.id ) return conv_participant instance = await add() await pubsub.publish("participant_added", { "conversation_id": str(input.conversation_id), "participant_id": str(input.participant_id) }) # Publish event await EventPublisher.publish( event_type=EventTypeChoices.CONVERSATION_PARTICIPANT_ADDED, entity_type='Conversation', entity_id=str(input.conversation_id), triggered_by=profile, metadata={'participant_id': str(input.participant_id)} ) return cast(ConversationParticipantType, instance) @strawberry.mutation(description="Remove a participant from a conversation") async def remove_participant(self, input: RemoveParticipantInput, info: Info) -> strawberry.ID: """Remove a participant from a conversation""" profile = getattr(info.context.request, 'profile', None) @database_sync_to_async def remove(): conversation = Conversation.objects.get(pk=input.conversation_id.node_id) # Get participant profile participant_uuid = input.participant_id.node_id try: participant = TeamProfile.objects.get(pk=participant_uuid) content_type = ContentType.objects.get_for_model(TeamProfile) except TeamProfile.DoesNotExist: participant = CustomerProfile.objects.get(pk=participant_uuid) content_type = ContentType.objects.get_for_model(CustomerProfile) # Delete participant record ConversationParticipant.objects.filter( conversation=conversation, participant_content_type=content_type, participant_object_id=participant.id ).delete() return conversation.id conversation_id = await remove() await pubsub.publish("participant_removed", { "conversation_id": str(input.conversation_id), "participant_id": str(input.participant_id) }) # Publish event await EventPublisher.publish( event_type=EventTypeChoices.CONVERSATION_PARTICIPANT_REMOVED, entity_type='Conversation', entity_id=str(input.conversation_id), triggered_by=profile, metadata={'participant_id': str(input.participant_id)} ) return input.conversation_id @strawberry.mutation(description="Delete a conversation") async def delete_conversation(self, id: GlobalID, info: Info) -> strawberry.ID: """Delete a conversation (only by creator or admin)""" profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") content_type = ContentType.objects.get_for_model(type(profile)) @database_sync_to_async def delete(): conversation = Conversation.objects.get(pk=id.node_id) # Check if user is the creator OR the admin profile is_creator = (conversation.created_by_content_type == content_type and conversation.created_by_object_id == profile.id) if not (is_creator or is_admin_profile(profile)): raise PermissionError("Only the conversation creator or admin can delete it") conversation.delete() return id conversation_id = await delete() await pubsub.publish("conversation_deleted", str(conversation_id)) return conversation_id @strawberry.mutation(description="Delete a message") async def delete_message(self, id: GlobalID, info: Info) -> strawberry.ID: """Delete a message (only by sender or admin)""" profile = getattr(info.context.request, 'profile', None) if not profile: raise ValueError("User must be authenticated") content_type = ContentType.objects.get_for_model(type(profile)) @database_sync_to_async def delete(): message = Message.objects.get(pk=id.node_id) # Check if user is the sender OR the admin profile is_sender = (message.sender_object_id == profile.id and message.sender_content_type == content_type) if not (is_sender or is_admin_profile(profile)): raise PermissionError("You can only delete your own messages or be an admin") conversation_id = message.conversation_id message.delete() return conversation_id conversation_id = await delete() await pubsub.publish("message_deleted", { "message_id": str(id), "conversation_id": str(conversation_id) }) # Publish event await EventPublisher.publish( event_type=EventTypeChoices.MESSAGE_DELETED, entity_type='Message', entity_id=str(id), triggered_by=profile ) return id