diff --git a/apps/account/guest_merge.py b/apps/account/guest_merge.py new file mode 100644 index 0000000..296eaee --- /dev/null +++ b/apps/account/guest_merge.py @@ -0,0 +1,165 @@ +import hashlib +import logging +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework.authtoken.models import Token +from utils.ip_helper import get_client_ip + +logger = logging.getLogger(__name__) +User = get_user_model() + + +def _extract_token_from_auth_header(request) -> str: + auth_header = (request.META.get('HTTP_AUTHORIZATION') or '').strip() + if not auth_header: + return '' + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() in ('token', 'bearer'): + return parts[1].strip() + return '' + + +def compute_web_guest_device_id(request) -> str: + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + client_ip = x_forwarded_for.split(',')[0] + else: + client_ip = request.META.get('REMOTE_ADDR') or get_client_ip(request) or '' + user_agent = request.META.get('HTTP_USER_AGENT', '') + ua_hash = hashlib.md5(user_agent.encode('utf-8')).hexdigest()[:8] + return f"{client_ip}_{ua_hash}" + + +def resolve_guest_users_from_request(request, device_id: str = None) -> list: + """ + Finds any Guest User(s) (`email__isnull=True`) associated with the current request + via X-Guest-Token header, Authorization token header, explicit device_id, or Web IP+UA device_id. + Returns a deduplicated list of guest User instances in priority order. + """ + candidates = [] + seen_ids = set() + + def _add(u): + if u and getattr(u, 'email', None) is None and u.id not in seen_ids: + seen_ids.add(u.id) + candidates.append(u) + + # 1. Explicit X-Guest-Token header + x_guest_key = (request.headers.get('X-Guest-Token') or '').strip() + if x_guest_key: + token_obj = Token.objects.select_related('user').filter(key=x_guest_key).first() + if token_obj: + _add(token_obj.user) + + # 2. Authorization: token header (sent automatically by frontend before auth_token is set) + auth_key = _extract_token_from_auth_header(request) + if auth_key and auth_key != x_guest_key: + token_obj = Token.objects.select_related('user').filter(key=auth_key).first() + if token_obj: + _add(token_obj.user) + + # 3. Explicit device_id parameter + if device_id: + guest_by_device = User.objects.filter(device_id=device_id, email__isnull=True).first() + _add(guest_by_device) + + # 4. Web IP + User-Agent hash fallback (matches WebUserGuestView) + try: + web_device_id = compute_web_guest_device_id(request) + if web_device_id and web_device_id != device_id: + guest_by_web_id = User.objects.filter(device_id=web_device_id, email__isnull=True).first() + _add(guest_by_web_id) + except Exception as exc: + logger.debug("Could not compute web guest device_id fallback: %s", exc) + + return candidates + + +@transaction.atomic +def merge_guest_user_data(guest_user, target_user) -> None: + """ + Merges all guest activity (SupportRoom, SupportMessage, Bookmark, Rate) + from `guest_user` into `target_user`. + Safe to call multiple times (idempotent). + """ + if not guest_user or not target_user or guest_user.id == target_user.id: + return + + # 1. Merge Support Chat (SupportRoom & SupportMessage) + try: + from apps.support.models import SupportRoom, SupportMessage + + guest_room = SupportRoom.objects.select_for_update().filter(user=guest_user).first() + if guest_room: + target_room = SupportRoom.objects.select_for_update().filter(user=target_user).first() + if not target_room: + guest_room.user = target_user + guest_room.save(update_fields=['user', 'updated_at']) + SupportMessage.objects.filter(room=guest_room, sender=guest_user).update(sender=target_user) + else: + # Both guest and target user already have support rooms: merge messages into target_room + SupportMessage.objects.filter(room=guest_room).update(room=target_room) + SupportMessage.objects.filter(room=target_room, sender=guest_user).update(sender=target_user) + + target_room.unread_by_admin = (target_room.unread_by_admin or 0) + (guest_room.unread_by_admin or 0) + target_room.unread_by_user = (target_room.unread_by_user or 0) + (guest_room.unread_by_user or 0) + + last_msg = target_room.messages.order_by('-id').first() + if last_msg: + target_room.last_message_at = last_msg.created_at + if last_msg.sender_type == SupportMessage.SenderType.USER: + target_room.status = SupportRoom.StatusChoices.PENDING + else: + target_room.status = SupportRoom.StatusChoices.ANSWERED + + target_room.save( + update_fields=[ + 'unread_by_admin', + 'unread_by_user', + 'last_message_at', + 'status', + 'updated_at', + ] + ) + guest_room.delete() + except Exception as exc: + logger.error("Error merging support chat from guest %s to user %s: %s", guest_user.id, target_user.id, exc) + + # 2. Merge Bookmarks + try: + from apps.bookmark.models import Bookmark + + for bm in Bookmark.objects.filter(user=guest_user): + existing = Bookmark.objects.filter( + user=target_user, + service=bm.service, + content_id=bm.content_id, + ).first() + if existing: + if bm.status and not existing.status: + existing.status = True + existing.save(update_fields=['status', 'updated_at']) + bm.delete() + else: + bm.user = target_user + bm.save(update_fields=['user', 'updated_at']) + except Exception as exc: + logger.error("Error merging bookmarks from guest %s to user %s: %s", guest_user.id, target_user.id, exc) + + # 3. Merge Rates + try: + from apps.bookmark.models import Rate + + for rt in Rate.objects.filter(user=guest_user): + existing = Rate.objects.filter( + user=target_user, + service=rt.service, + content_id=rt.content_id, + ).first() + if existing: + rt.delete() + else: + rt.user = target_user + rt.save(update_fields=['user', 'updated_at']) + except Exception as exc: + logger.error("Error merging rates from guest %s to user %s: %s", guest_user.id, target_user.id, exc) diff --git a/apps/account/views/user.py b/apps/account/views/user.py index abfbc59..bc78349 100644 --- a/apps/account/views/user.py +++ b/apps/account/views/user.py @@ -338,6 +338,8 @@ class UserVerifyView(CreateAPIView): return current_code def perform_create(self, *args, **kwargs): + from apps.account.guest_merge import resolve_guest_users_from_request, merge_guest_user_data + email = kwargs.get('email') device_id = kwargs.get('device_id') password = kwargs.get('password') @@ -353,6 +355,8 @@ class UserVerifyView(CreateAPIView): create_kwargs.pop('user_agent', None) create_kwargs.pop('device_os', None) + guest_candidates = resolve_guest_users_from_request(self.request, device_id=device_id) + user = User.objects.filter(email=email).first() if user: if password and str(password).lower() != 'none': @@ -369,12 +373,13 @@ class UserVerifyView(CreateAPIView): user.device_os = device_os user.last_login = timezone.now() user.save() + + for guest_user in guest_candidates: + if guest_user.id != user.id: + merge_guest_user_data(guest_user, user) else: - # If device_id is provided, try to find existing user with that device_id - if device_id: - user = User.objects.filter(device_id=device_id, email__isnull=True).first() - else: - user = None + # Upgrade the primary guest user in-place if one exists (from guest_token, device_id, or Web IP+UA) + user = guest_candidates[0] if guest_candidates else None if not user: user = User(**create_kwargs) @@ -401,6 +406,11 @@ class UserVerifyView(CreateAPIView): user.deleted_at = None user.save() + # Merge any remaining guest candidates into the newly upgraded user + for extra_guest in guest_candidates[1:]: + if extra_guest.id != user.id: + merge_guest_user_data(extra_guest, user) + user.login_history.create( ip=client_ip, user_agent=user_agent, @@ -490,6 +500,12 @@ class UserLoginView(CreateAPIView): device_os = detect_device_os(user_agent) device_id = request.data.get('device_id') + from apps.account.guest_merge import resolve_guest_users_from_request, merge_guest_user_data + guest_candidates = resolve_guest_users_from_request(request, device_id=device_id) + for guest_user in guest_candidates: + if guest_user.id != user.id: + merge_guest_user_data(guest_user, user) + user_timezone = serializer.validated_data.pop('timezone', None) user.last_login = timezone.now() user.is_active = True diff --git a/apps/support/views.py b/apps/support/views.py index eb35f2d..64422e1 100644 --- a/apps/support/views.py +++ b/apps/support/views.py @@ -48,29 +48,22 @@ def get_total_admin_unread_count() -> int: 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. + If the user is logged in, automatically checks for and merges any unmerged guest support room + (from X-Guest-Token or Web IP+UA device_id). """ user = request.user + if not getattr(user, 'is_guest', False) and getattr(user, 'email', None): + from apps.account.guest_merge import resolve_guest_users_from_request, merge_guest_user_data + + guest_candidates = resolve_guest_users_from_request(request) + for guest_user in guest_candidates: + if guest_user.id != user.id: + merge_guest_user_data(guest_user, 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