514 lines
20 KiB
Python
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
|