diff --git a/apps/support/__init__.py b/apps/support/__init__.py
new file mode 100644
index 0000000..119b117
--- /dev/null
+++ b/apps/support/__init__.py
@@ -0,0 +1,2 @@
+# Support chat application
+default_app_config = 'apps.support.apps.SupportConfig'
diff --git a/apps/support/admin.py b/apps/support/admin.py
new file mode 100644
index 0000000..32289a8
--- /dev/null
+++ b/apps/support/admin.py
@@ -0,0 +1,67 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+from unfold.admin import ModelAdmin, TabularInline
+from unfold.decorators import display
+
+from apps.support.models import SupportMessage, SupportRoom
+from utils.admin import dovoodi_admin_site, project_admin_site
+
+
+class SupportMessageInline(TabularInline):
+ model = SupportMessage
+ extra = 1
+ fields = ('sender_type', 'sender', 'content', 'is_read', 'created_at')
+ readonly_fields = ('created_at',)
+
+
+class SupportRoomAdmin(ModelAdmin):
+ list_display = (
+ 'id',
+ 'display_user',
+ 'display_status',
+ 'unread_by_admin',
+ 'unread_by_user',
+ 'last_message_at',
+ 'created_at',
+ )
+ list_filter = ('status', 'created_at', 'last_message_at')
+ search_fields = ('user__email', 'user__fullname', 'user__device_id')
+ readonly_fields = ('created_at', 'updated_at', 'last_message_at')
+ inlines = [SupportMessageInline]
+ list_per_page = 25
+
+ @display(description=_('User / Guest'))
+ def display_user(self, obj: SupportRoom):
+ if obj.user.email:
+ return f"{obj.user.fullname or ''} ({obj.user.email})".strip()
+ return f"Guest ({str(obj.user.device_id or obj.user_id)[:12]})"
+
+ @display(description=_('Status'))
+ def display_status(self, obj: SupportRoom):
+ colors = {
+ 'open': '#3B82F6',
+ 'pending': '#F59E0B',
+ 'answered': '#10B981',
+ 'closed': '#6B7280',
+ }
+ color = colors.get(obj.status, '#6B7280')
+ return format_html(
+ '{}',
+ color,
+ obj.get_status_display(),
+ )
+
+ def save_formset(self, request, form, formset, change):
+ instances = formset.save(commit=False)
+ for instance in instances:
+ if isinstance(instance, SupportMessage) and not instance.pk:
+ if not instance.sender_id:
+ instance.sender = request.user
+ instance.sender_type = SupportMessage.SenderType.SUPPORT
+ instance.save()
+ formset.save_m2m()
+
+
+dovoodi_admin_site.register(SupportRoom, SupportRoomAdmin)
+project_admin_site.register(SupportRoom, SupportRoomAdmin)
diff --git a/apps/support/apps.py b/apps/support/apps.py
new file mode 100644
index 0000000..b4962a1
--- /dev/null
+++ b/apps/support/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
+
+
+class SupportConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'apps.support'
+ verbose_name = _('Support Chat')
diff --git a/apps/support/migrations/0001_initial.py b/apps/support/migrations/0001_initial.py
new file mode 100644
index 0000000..6210517
--- /dev/null
+++ b/apps/support/migrations/0001_initial.py
@@ -0,0 +1,51 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='SupportRoom',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('status', models.CharField(choices=[('open', 'Open'), ('pending', 'Pending Support Reply'), ('answered', 'Answered'), ('closed', 'Closed')], db_index=True, default='open', max_length=20, verbose_name='Status')),
+ ('unread_by_admin', models.PositiveIntegerField(default=0, verbose_name='Unread by Admin')),
+ ('unread_by_user', models.PositiveIntegerField(default=0, verbose_name='Unread by User')),
+ ('last_message_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Last Message At')),
+ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
+ ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
+ ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_support_rooms', to=settings.AUTH_USER_MODEL, verbose_name='Assigned Specialist')),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='support_room', to=settings.AUTH_USER_MODEL, verbose_name='User')),
+ ],
+ options={
+ 'verbose_name': 'Support Room',
+ 'verbose_name_plural': 'Support Rooms',
+ 'ordering': ('-last_message_at', '-created_at'),
+ },
+ ),
+ migrations.CreateModel(
+ name='SupportMessage',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sender_type', models.CharField(choices=[('user', 'User'), ('support', 'Support Specialist')], db_index=True, default='user', max_length=16, verbose_name='Sender Type')),
+ ('content', models.TextField(verbose_name='Message Content')),
+ ('is_read', models.BooleanField(db_index=True, default=False, verbose_name='Is Read')),
+ ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Created At')),
+ ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='support.supportroom', verbose_name='Support Room')),
+ ('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_support_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender User')),
+ ],
+ options={
+ 'verbose_name': 'Support Message',
+ 'verbose_name_plural': 'Support Messages',
+ 'ordering': ('id',),
+ },
+ ),
+ ]
diff --git a/apps/support/migrations/__init__.py b/apps/support/migrations/__init__.py
new file mode 100644
index 0000000..4e08fe0
--- /dev/null
+++ b/apps/support/migrations/__init__.py
@@ -0,0 +1 @@
+# Migrations package for apps.support
diff --git a/apps/support/models.py b/apps/support/models.py
new file mode 100644
index 0000000..035a65c
--- /dev/null
+++ b/apps/support/models.py
@@ -0,0 +1,140 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+
+
+class SupportRoom(models.Model):
+ class StatusChoices(models.TextChoices):
+ OPEN = 'open', _('Open')
+ PENDING = 'pending', _('Pending Support Reply')
+ ANSWERED = 'answered', _('Answered')
+ CLOSED = 'closed', _('Closed')
+
+ user = models.OneToOneField(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name='support_room',
+ verbose_name=_('User'),
+ )
+ status = models.CharField(
+ max_length=20,
+ choices=StatusChoices.choices,
+ default=StatusChoices.OPEN,
+ db_index=True,
+ verbose_name=_('Status'),
+ )
+ assigned_to = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='assigned_support_rooms',
+ verbose_name=_('Assigned Specialist'),
+ )
+ unread_by_admin = models.PositiveIntegerField(
+ default=0,
+ verbose_name=_('Unread by Admin'),
+ )
+ unread_by_user = models.PositiveIntegerField(
+ default=0,
+ verbose_name=_('Unread by User'),
+ )
+ last_message_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ db_index=True,
+ verbose_name=_('Last Message At'),
+ )
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ verbose_name=_('Created At'),
+ )
+ updated_at = models.DateTimeField(
+ auto_now=True,
+ verbose_name=_('Updated At'),
+ )
+
+ class Meta:
+ verbose_name = _('Support Room')
+ verbose_name_plural = _('Support Rooms')
+ ordering = ('-last_message_at', '-created_at')
+
+ def __str__(self):
+ user_label = self.user.email or self.user.fullname or f"Guest #{self.user_id}"
+ return f"Support Room #{self.pk} - {user_label}"
+
+
+class SupportMessage(models.Model):
+ class SenderType(models.TextChoices):
+ USER = 'user', _('User')
+ SUPPORT = 'support', _('Support Specialist')
+
+ room = models.ForeignKey(
+ SupportRoom,
+ on_delete=models.CASCADE,
+ related_name='messages',
+ verbose_name=_('Support Room'),
+ )
+ sender_type = models.CharField(
+ max_length=16,
+ choices=SenderType.choices,
+ default=SenderType.USER,
+ db_index=True,
+ verbose_name=_('Sender Type'),
+ )
+ sender = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='sent_support_messages',
+ verbose_name=_('Sender User'),
+ )
+ content = models.TextField(
+ verbose_name=_('Message Content'),
+ )
+ is_read = models.BooleanField(
+ default=False,
+ db_index=True,
+ verbose_name=_('Is Read'),
+ )
+ created_at = models.DateTimeField(
+ auto_now_add=True,
+ db_index=True,
+ verbose_name=_('Created At'),
+ )
+
+ class Meta:
+ verbose_name = _('Support Message')
+ verbose_name_plural = _('Support Messages')
+ ordering = ('id',)
+
+ def __str__(self):
+ return f"[{self.sender_type}] Room #{self.room_id}: {self.content[:40]}"
+
+ def save(self, *args, **kwargs):
+ is_new = self.pk is None
+ super().save(*args, **kwargs)
+ if is_new and self.room_id:
+ now = self.created_at or timezone.now()
+ if self.sender_type == self.SenderType.USER:
+ SupportRoom.objects.filter(pk=self.room_id).update(
+ last_message_at=now,
+ status=SupportRoom.StatusChoices.PENDING,
+ unread_by_admin=models.F('unread_by_admin') + 1,
+ updated_at=now,
+ )
+ elif self.sender_type == self.SenderType.SUPPORT:
+ SupportRoom.objects.filter(pk=self.room_id).update(
+ last_message_at=now,
+ status=SupportRoom.StatusChoices.ANSWERED,
+ unread_by_user=models.F('unread_by_user') + 1,
+ unread_by_admin=0,
+ updated_at=now,
+ )
+ SupportMessage.objects.filter(
+ room_id=self.room_id,
+ sender_type=self.SenderType.USER,
+ is_read=False,
+ ).update(is_read=True)
diff --git a/apps/support/serializers.py b/apps/support/serializers.py
new file mode 100644
index 0000000..70e4ae4
--- /dev/null
+++ b/apps/support/serializers.py
@@ -0,0 +1,57 @@
+from rest_framework import serializers
+from apps.support.models import SupportRoom, SupportMessage
+
+
+class SupportMessageSerializer(serializers.ModelSerializer):
+ sender_name = serializers.SerializerMethodField()
+ timestamp_ms = serializers.SerializerMethodField()
+
+ class Meta:
+ model = SupportMessage
+ fields = (
+ 'id',
+ 'room',
+ 'sender_type',
+ 'sender_name',
+ 'content',
+ 'is_read',
+ 'created_at',
+ 'timestamp_ms',
+ )
+ read_only_fields = fields
+
+ def get_sender_name(self, obj: SupportMessage) -> str:
+ if obj.sender_type == SupportMessage.SenderType.SUPPORT:
+ if obj.sender and obj.sender.fullname:
+ return obj.sender.fullname
+ return 'Support Specialist'
+ if obj.sender and obj.sender.fullname:
+ return obj.sender.fullname
+ return 'You'
+
+ def get_timestamp_ms(self, obj: SupportMessage) -> int:
+ if not obj.created_at:
+ return 0
+ return int(obj.created_at.timestamp() * 1000)
+
+
+class SendSupportMessageSerializer(serializers.Serializer):
+ content = serializers.CharField(
+ required=True,
+ allow_blank=False,
+ max_length=4000,
+ trim_whitespace=True,
+ )
+
+
+class SupportRoomSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = SupportRoom
+ fields = (
+ 'id',
+ 'status',
+ 'unread_by_user',
+ 'last_message_at',
+ 'created_at',
+ )
+ read_only_fields = fields
diff --git a/apps/support/urls.py b/apps/support/urls.py
new file mode 100644
index 0000000..e51e6d4
--- /dev/null
+++ b/apps/support/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+from apps.support.views import SupportChatMessagesView, SupportUnreadCountView
+
+app_name = 'support'
+
+urlpatterns = [
+ path('messages/', SupportChatMessagesView.as_view(), name='support-messages'),
+ path('unread/', SupportUnreadCountView.as_view(), name='support-unread'),
+]
diff --git a/apps/support/views.py b/apps/support/views.py
new file mode 100644
index 0000000..f86b55d
--- /dev/null
+++ b/apps/support/views.py
@@ -0,0 +1,146 @@
+from django.db import transaction
+from rest_framework import status
+from rest_framework.authentication import TokenAuthentication
+from rest_framework.authtoken.models import Token
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from apps.support.models import SupportMessage, SupportRoom
+from apps.support.serializers import (
+ SendSupportMessageSerializer,
+ SupportMessageSerializer,
+ SupportRoomSerializer,
+)
+
+
+def resolve_user_support_room(request, create_if_missing: bool = False):
+ """
+ Resolves the SupportRoom for request.user (works for both logged-in and guest users).
+ If a logged-in user does not yet have a room, checks the optional X-Guest-Token header
+ to seamlessly migrate their existing guest support conversation.
+ """
+ user = request.user
+ room = SupportRoom.objects.filter(user=user).first()
+ if room:
+ return room
+
+ # Check if user recently logged in from a guest session that already had a support chat
+ guest_token_key = request.headers.get('X-Guest-Token', '').strip()
+ if guest_token_key and not getattr(user, 'is_guest', False):
+ guest_token_obj = (
+ Token.objects.select_related('user')
+ .filter(key=guest_token_key)
+ .first()
+ )
+ if guest_token_obj and guest_token_obj.user_id != user.id:
+ guest_room = SupportRoom.objects.filter(user=guest_token_obj.user).first()
+ if guest_room:
+ guest_room.user = user
+ guest_room.save(update_fields=['user', 'updated_at'])
+ return guest_room
+
+ if create_if_missing:
+ room, _ = SupportRoom.objects.get_or_create(user=user)
+ return room
+
+ return None
+
+
+class SupportChatMessagesView(APIView):
+ """
+ GET /api/support/messages/?after_id=
+ Returns the user's support room and messages (all or after `after_id` for short polling).
+ POST /api/support/messages/
+ Sends a new message from the user (or guest user) to support.
+ """
+ authentication_classes = [TokenAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, *args, **kwargs):
+ room = resolve_user_support_room(request, create_if_missing=False)
+ if not room:
+ return Response(
+ {
+ 'room': None,
+ 'messages': [],
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ after_id_param = request.query_params.get('after_id')
+ messages_qs = room.messages.select_related('sender').order_by('id')
+
+ if after_id_param is not None:
+ try:
+ after_id = int(after_id_param)
+ if after_id > 0:
+ messages_qs = messages_qs.filter(id__gt=after_id)
+ except (TypeError, ValueError):
+ pass
+
+ messages_list = list(messages_qs)
+
+ # Mark support replies as read when user fetches messages in open chat
+ mark_read = request.query_params.get('mark_read', 'true').lower() != 'false'
+ if mark_read and (room.unread_by_user > 0 or any(
+ m.sender_type == SupportMessage.SenderType.SUPPORT and not m.is_read
+ for m in messages_list
+ )):
+ SupportMessage.objects.filter(
+ room=room,
+ sender_type=SupportMessage.SenderType.SUPPORT,
+ is_read=False,
+ ).update(is_read=True)
+ if room.unread_by_user != 0:
+ room.unread_by_user = 0
+ room.save(update_fields=['unread_by_user', 'updated_at'])
+
+ return Response(
+ {
+ 'room': SupportRoomSerializer(room).data,
+ 'messages': SupportMessageSerializer(messages_list, many=True).data,
+ },
+ status=status.HTTP_200_OK,
+ )
+
+ @transaction.atomic
+ def post(self, request, *args, **kwargs):
+ serializer = SendSupportMessageSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ room = resolve_user_support_room(request, create_if_missing=True)
+ message = SupportMessage.objects.create(
+ room=room,
+ sender_type=SupportMessage.SenderType.USER,
+ sender=request.user,
+ content=serializer.validated_data['content'],
+ )
+ room.refresh_from_db()
+
+ return Response(
+ {
+ 'room': SupportRoomSerializer(room).data,
+ 'message': SupportMessageSerializer(message).data,
+ },
+ status=status.HTTP_201_CREATED,
+ )
+
+
+class SupportUnreadCountView(APIView):
+ """
+ GET /api/support/unread/
+ Lightweight endpoint to check if the user has unread support specialist replies.
+ """
+ authentication_classes = [TokenAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ def get(self, request, *args, **kwargs):
+ room = resolve_user_support_room(request, create_if_missing=False)
+ return Response(
+ {
+ 'unread_count': room.unread_by_user if room else 0,
+ 'status': room.status if room else None,
+ },
+ status=status.HTTP_200_OK,
+ )
diff --git a/config/settings/base.py b/config/settings/base.py
index 39c5f33..2acb750 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -58,6 +58,7 @@ LOCAL_APPS = [
'apps.article.apps.ArticleConfig',
'apps.dobodbi_calendar.apps.DobodbiCalendarConfig',
'apps.agent.apps.AgentConfig',
+ 'apps.support.apps.SupportConfig',
'dynamic_preferences',
'apps.geolocation_package',
diff --git a/config/urls.py b/config/urls.py
index 7428ec2..bf00ea6 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -93,6 +93,7 @@ api_patterns = [
path('article/', include('apps.article.urls')),
path('podcast/', include('apps.podcast.urls')),
path('agent/', include('apps.agent.urls')),
+ path('support/', include('apps.support.urls')),
path('bookmarks/', include('apps.bookmark.urls')),
path('calendar/', include('apps.dobodbi_calendar.urls')),
path('admin/dashboard-stats/', AdminDashboardStatsView.as_view()),