nexus-5/core/graphql/mutations/messaging.py
2026-01-26 11:09:40 -05:00

514 lines
20 KiB
Python

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