From 1cbbbbd0dcc05f64d78f1d69801c7298ce3f50c5 Mon Sep 17 00:00:00 2001 From: javad Date: Tue, 6 Oct 2026 11:18:31 +0330 Subject: [PATCH] fix payement link and gallery image --- apps/messaging/read_receipts.py | 29 ++++ apps/payments/access.py | 19 ++- apps/payments/forms.py | 2 +- apps/payments/link_views.py | 3 +- apps/tickets/selectors.py | 7 +- apps/tickets/sharing_views.py | 28 +++- apps/tickets/views.py | 43 +++--- config/urls.py | 2 + docs/MESSAGE_READ_RECEIPTS.md | 21 +++ docs/PAYMENT_LINKS.md | 3 +- static/js/conversation-read.js | 77 ++++++++++ static/js/conversation.js | 28 ++-- static/js/staff-sharing.js | 69 +++------ templates/tickets/_messages.html | 2 +- templates/tickets/_staff_messages.html | 2 +- templates/tickets/customer_detail.html | 1 + templates/tickets/staff_detail.html | 3 +- tests/e2e/pages/message_reads.py | 101 +++++++++++++ tests/e2e/pages/payment_link.py | 6 +- tests/e2e/test_message_read_receipts_e2e.py | 108 ++++++++++++++ tests/test_message_read_receipts.py | 153 ++++++++++++++++++++ tests/test_payment_links.py | 52 ++++++- tests/test_payments.py | 6 +- tests/test_views.py | 5 +- 24 files changed, 654 insertions(+), 116 deletions(-) create mode 100644 apps/messaging/read_receipts.py create mode 100644 docs/MESSAGE_READ_RECEIPTS.md create mode 100644 static/js/conversation-read.js create mode 100644 tests/e2e/pages/message_reads.py create mode 100644 tests/e2e/test_message_read_receipts_e2e.py create mode 100644 tests/test_message_read_receipts.py diff --git a/apps/messaging/read_receipts.py b/apps/messaging/read_receipts.py new file mode 100644 index 0000000..08d6978 --- /dev/null +++ b/apps/messaging/read_receipts.py @@ -0,0 +1,29 @@ +"""Bounded parsing for explicit message-read acknowledgements.""" +import json +import uuid + +from django.core.exceptions import ValidationError + + +def message_read_ids(request): + if request.content_type == 'application/json': + try: + payload = json.loads(request.body or b'{}') + except (TypeError, ValueError): + raise ValidationError('invalid_json') + if not isinstance(payload, dict): + raise ValidationError('invalid_json') + values = payload.get('message_ids', []) + else: + values = request.POST.getlist('message_ids') + if not isinstance(values, list) or len(values) > 100: + raise ValidationError('invalid_message_ids') + identifiers = set() + for value in values: + if not isinstance(value, str) or len(value) > 45: + continue + try: + identifiers.add(uuid.UUID(value)) + except ValueError: + continue + return identifiers diff --git a/apps/payments/access.py b/apps/payments/access.py index 2bb66fd..21ddfa6 100644 --- a/apps/payments/access.py +++ b/apps/payments/access.py @@ -1,10 +1,10 @@ """A payment link never authenticates a user or grants access to their whole account.""" import hashlib -import secrets import uuid from datetime import timedelta from django.conf import settings +from django.db import IntegrityError, transaction from django.db.models import Q from django.contrib.auth.views import redirect_to_login from django.core.exceptions import PermissionDenied @@ -13,6 +13,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils import timezone from apps.core.session_access import grant_scoped_session_access +from apps.accounts.models import TicketMagicLink from . import policies from .models import PaymentAccessLink, PaymentNotice @@ -30,11 +31,17 @@ def issue_payment_link(*, notice=None, receipt=None): raise ValueError('Payment link requires a notice or receipt') if receipt is not None: notice = receipt.notice - token = secrets.token_urlsafe(32) - link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt, - token_hash=token_digest(token), - expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS)) - return link, settings.PUBLIC_BASE_URL + reverse('payments:open_link', args=[token]) + for attempt in range(5): + token = TicketMagicLink.generate_secure_token(10) + try: + with transaction.atomic(): + link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt, + token_hash=token_digest(token), + expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS)) + return link, settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token]) + except IntegrityError: + if attempt == 4: + raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.') def active_links(): diff --git a/apps/payments/forms.py b/apps/payments/forms.py index 0a60b30..374e1e3 100644 --- a/apps/payments/forms.py +++ b/apps/payments/forms.py @@ -81,7 +81,7 @@ class ReceiptForm(forms.Form): paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)]) paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']))) paid_year = forms.ChoiceField(label='سال') - receipt_image = forms.FileField(label='تصویر فیش') + receipt_image = forms.FileField(label='تصویر فیش', widget=forms.ClearableFileInput(attrs={'accept': 'image/*'})) tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False) description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea) submission_key = forms.UUIDField(widget=forms.HiddenInput) diff --git a/apps/payments/link_views.py b/apps/payments/link_views.py index 57629a8..e2a30db 100644 --- a/apps/payments/link_views.py +++ b/apps/payments/link_views.py @@ -15,7 +15,8 @@ from .models import PaymentAccessLink @never_cache @require_http_methods(['GET', 'POST']) def open_link(request, token): - link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'[A-Za-z0-9_-]{43}', token) else None + # Keep previously issued long links valid until their original expiry/revocation. + link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'(?:[a-z0-9]{10}|[A-Za-z0-9_-]{43})', token) else None if link is None: return access.unavailable_link(request) if request.user.is_authenticated and request.user.pk != link.customer_id: diff --git a/apps/tickets/selectors.py b/apps/tickets/selectors.py index b4ce7af..60608b7 100644 --- a/apps/tickets/selectors.py +++ b/apps/tickets/selectors.py @@ -1,7 +1,7 @@ -from django.db.models import Case, F, IntegerField, Q, Value, When +from django.db.models import Case, Exists, IntegerField, OuterRef, Q, Value, When from django.utils import timezone from django.utils.dateparse import parse_date -from .models import Ticket +from .models import Ticket, TicketReadState from .policies import TicketPolicy def customer_tickets(user): return Ticket.objects.filter(customer=user).select_related("category","owner__staff_profile","selected_expert__staff_profile") def staff_ticket_rows(user): @@ -56,7 +56,8 @@ def filter_staff_tickets(qs, user, params): if tab == "shared": qs=qs.filter(Q(access_grants__user=user,access_grants__revoked_at__isnull=True,access_grants__first_viewed_at__isnull=True) | Q(pk__in=received_messages(user).filter(first_viewed_at__isnull=True).values("message__ticket_id"))) else: - qs=qs.filter(Q(read_states__user=user,read_states__last_read_at__lt=F("last_activity_at"))|Q(read_states__isnull=True)) + read_by_user=TicketReadState.objects.filter(ticket_id=OuterRef('pk'),user=user,last_read_at__gte=OuterRef('last_activity_at')) + qs=qs.alias(_read_by_current_user=Exists(read_by_user)).filter(_read_by_current_user=False) qs=qs.distinct() if tab in ("", "waiting_staff"): return qs.annotate( diff --git a/apps/tickets/sharing_views.py b/apps/tickets/sharing_views.py index 1a4b8a3..c8a2b8c 100644 --- a/apps/tickets/sharing_views.py +++ b/apps/tickets/sharing_views.py @@ -3,6 +3,8 @@ import mimetypes from django.conf import settings from django.contrib import messages +from django.db import transaction +from django.utils import timezone from django.core.exceptions import PermissionDenied, ValidationError from django.http import FileResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect @@ -11,10 +13,12 @@ from django.views.decorators.http import require_GET, require_POST from apps.attachments.delivery import signed_media_redirect from apps.attachments.models import Attachment -from .models import MessageShare, Ticket +from .models import MessageShare, Ticket, TicketReadState +from .policies import TicketPolicy +from apps.messaging.read_receipts import message_read_ids from .staff_conversation import received_messages, unread_shares, visible_messages from .sharing import mark_share_opened, revoke_staff_share -from .views import staff_required +from .views import staff_required, mark_messages_read def received_message_share(request, share_id): @@ -66,18 +70,26 @@ def shared_message(request, share_id): @staff_required @require_POST @never_cache +@transaction.atomic def read_shared_messages(request, public_id): - ticket = get_object_or_404(Ticket, public_id=public_id) + ticket = get_object_or_404(Ticket.objects.select_for_update(), public_id=public_id) allowed = visible_messages(ticket, request.user) - ids = request.POST.getlist("message_ids")[:100] - # Filter strings without parsing attacker-controlled UUIDs in the ORM. - visible = {str(value) for value in allowed.values_list("public_id", flat=True)} - accepted = set(ids) & visible + try: + ids = message_read_ids(request) + except ValidationError: + return JsonResponse({"ok": False, "error": "invalid_message_ids"}, status=400) + acknowledged = list(allowed.filter(public_id__in=ids, deleted_at__isnull=True)) + mark_messages_read(acknowledged, request.user) + accepted = [item.public_id for item in acknowledged] grants = received_messages(request.user, ticket).filter(message__public_id__in=accepted) for grant in grants: if grant.first_viewed_at is None: mark_share_opened(grant, request.user) - return JsonResponse({"unread_shares": unread_shares(request.user)}) + latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first() + if latest and latest.public_id in accepted and TicketPolicy.can_view(request.user, ticket): + TicketReadState.objects.update_or_create(ticket=ticket, user=request.user, + defaults={'last_read_at': timezone.now(), 'last_read_message': latest}) + return JsonResponse({"unread_shares": unread_shares(request.user), "read_ids": [str(value) for value in accepted]}) @staff_required diff --git a/apps/tickets/views.py b/apps/tickets/views.py index f905cd3..d0080db 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -8,7 +8,7 @@ from django.core.exceptions import PermissionDenied,ValidationError from django.http import FileResponse, HttpResponse, JsonResponse from django.template.loader import render_to_string from django.db import transaction -from django.db.models import Count, Prefetch, Q, Sum +from django.db.models import Count, Max, Prefetch, Q, Sum from django.shortcuts import get_object_or_404,redirect,render from django.urls import reverse from django.utils import timezone @@ -32,7 +32,7 @@ from apps.core.persian_text import normalize_persian_text from apps.integrations.voip import place_voip_call from apps.staff.models import OrganizationalUnit, StaffProfile from .forms import ContactGroupForm,GroupDispatchForm,ReplyForm,StaffContactForm,StaffCreateTicketForm,StaffReplyForm,TicketForm,TransferForm,UnitSMSTemplateForm -from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketReadState,TicketTag +from .models import GroupDispatch,MessageShare,Ticket,TicketCategory,TicketTag from .forms import StaffMessageShareForm, StaffTicketShareForm from .sharing import mark_ticket_share_opened, require_share_authority, revoke_staff_share, share_staff_message, share_staff_ticket, sharing_context, staff_message_context from .policies import DispatchPolicy, TicketPolicy @@ -97,7 +97,7 @@ def conversation_page(queryset,before=None): return conversation,has_older,(conversation[0].public_id if conversation and has_older else None) def mark_messages_read(conversation,user): MessageReadReceipt.objects.bulk_create( - [MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id], + [MessageReadReceipt(message=item,user=user) for item in conversation if item.author_id != user.id and not item.deleted_at], ignore_conflicts=True, ) def customer_required(view): @@ -118,14 +118,18 @@ def customer_ticket_actor(request,ticket): return request.user,False link=get_ticket_magic_access(request,ticket) return (link.customer,True) if link else (None,False) -def conversation_version(ticket,latest): +def conversation_version(ticket,latest,include_internal=False): + query=ticket.ticket_messages.all() + if not include_internal: query=query.filter(is_internal=False) + edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at')) + revision=hashlib.sha256(repr(edits).encode()).hexdigest()[:20] source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}" - return hashlib.sha256(source.encode()).hexdigest()[:20] + return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision def conversation_poll_response(request,ticket,actor,include_internal=False): qs=ticket.ticket_messages.all() if not include_internal: qs=qs.filter(is_internal=False) latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first() - version=conversation_version(ticket,latest) + version=conversation_version(ticket,latest,include_internal=include_internal) requested_cursor = request.GET.get("cursor", "") latest_cursor = str(latest.public_id) if latest else "" # A client can have the newest version token but still miss the DOM update @@ -136,7 +140,7 @@ def conversation_poll_response(request,ticket,actor,include_internal=False): response["Cache-Control"]="private, no-cache, no-store, must-revalidate" return response cursor=requested_cursor - replace=not cursor or not latest or str(latest.public_id)==cursor + replace=not cursor or not latest or str(latest.public_id)==cursor or request.GET.get('version','').partition(':')[2]!=version.partition(':')[2] messages_qs=qs.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile") if not replace: cursor_message=qs.filter(public_id=cursor).only("created_at","id").first() @@ -148,14 +152,14 @@ def conversation_poll_response(request,ticket,actor,include_internal=False): recent=list(messages_qs.order_by("-created_at","-id")[:MESSAGE_PAGE_SIZE]); conversation=list(reversed(recent)) else: conversation=list(messages_qs[:MESSAGE_PAGE_SIZE]) - if include_internal: mark_messages_read(conversation,actor) context={"conversation":conversation,"ticket":ticket,"viewer_user":actor} template="tickets/_messages.html" if include_internal: context.update(message_context(ticket,actor,conversation)) template="tickets/_staff_messages.html" html=render_to_string(template,context,request=request) - response=JsonResponse({"changed":True,"version":version,"cursor":str(latest.public_id) if latest else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()}) + delivered = conversation[-1] if conversation else latest + response=JsonResponse({"changed":True,"version":version,"cursor":str(delivered.public_id) if delivered else "","replace":replace,"html":html,"count":qs.count(),"status":ticket.get_status_display()}) response["Cache-Control"]="private, no-cache, no-store, must-revalidate" return response @customer_required @@ -327,17 +331,19 @@ def customer_poll(request,public_id): if actor is None: return redirect_to_login(request.get_full_path(),reverse("accounts:login")) return conversation_poll_response(request,ticket,actor,include_internal=False) @require_POST +@transaction.atomic def customer_read(request,public_id): - ticket=get_object_or_404(Ticket,public_id=public_id) + from apps.messaging.read_receipts import message_read_ids + ticket=get_object_or_404(Ticket.objects.select_for_update(),public_id=public_id) actor,_=customer_ticket_actor(request,ticket) if actor is None: return JsonResponse({"ok":False},status=401) - try: message_ids=json.loads(request.body or b"{}").get("message_ids",[]) - except (TypeError,ValueError): return JsonResponse({"ok":False,"error":"invalid_json"},status=400) - messages_qs=ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False).exclude(author=actor) - MessageReadReceipt.objects.bulk_create([MessageReadReceipt(message=item,user=actor) for item in messages_qs],ignore_conflicts=True) + try: message_ids=message_read_ids(request) + except ValidationError: return JsonResponse({"ok":False,"error":"invalid_message_ids"},status=400) + acknowledged=list(ticket.ticket_messages.filter(public_id__in=message_ids,is_internal=False,deleted_at__isnull=True)) + mark_messages_read(acknowledged,actor) from apps.notifications.services import cancel_staff_reply_sms_if_read cancel_staff_reply_sms_if_read(ticket,actor) - return JsonResponse({"ok":True,"read":messages_qs.count()}) + return JsonResponse({"ok":True,"read":sum(item.author_id != actor.pk for item in acknowledged),"read_ids":[str(item.public_id) for item in acknowledged]}) @require_POST def customer_reply(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id); form=ReplyForm(request.POST,request.FILES) @@ -450,12 +456,8 @@ def staff_detail(request,public_id): ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id) visible=visible_messages(ticket,request.user) conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) - mark_messages_read(conversation,request.user) mark_ticket_share_opened(ticket,request.user) message_sharing=message_context(ticket,request.user,conversation) - if TicketPolicy.can_view(request.user,ticket): - last=conversation[-1] if conversation else None - TicketReadState.objects.update_or_create(ticket=ticket,user=request.user,defaults={"last_read_at":timezone.now(),"last_read_message":last}) history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5] sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk")) @@ -472,7 +474,7 @@ def staff_detail(request,public_id): customer_notices = payment_selectors.notices_with_totals( payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user) ).order_by('-created_at', '-pk')[:3] - return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) + return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest,include_internal=True),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) @staff_required @@ -514,7 +516,6 @@ def staff_sms_retry(request,public_id,job_id): def staff_messages(request,public_id): ticket=get_object_or_404(Ticket,public_id=public_id) conversation,has_older,older_cursor=conversation_page(visible_messages(ticket,request.user).select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"),request.GET.get("before")) - mark_messages_read(conversation,request.user) return render(request,"tickets/_staff_older_messages.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"viewer_user":request.user,**message_context(ticket,request.user,conversation)}) @staff_required def staff_poll(request,public_id): diff --git a/config/urls.py b/config/urls.py index b197b23..c02ffa4 100644 --- a/config/urls.py +++ b/config/urls.py @@ -9,6 +9,7 @@ from django.urls import include, path from django.views.static import serve from apps.core.models import SiteBranding +from apps.payments import link_views as payment_link_views def healthz(request): @@ -48,6 +49,7 @@ def web_manifest(request): return response urlpatterns = [ + path("p//", payment_link_views.open_link, name="payment_short_link"), path("healthz/", healthz, name="healthz"), path("media/branding/", public_branding, name="public_branding"), path("service-worker.js", service_worker, name="service_worker"), diff --git a/docs/MESSAGE_READ_RECEIPTS.md b/docs/MESSAGE_READ_RECEIPTS.md new file mode 100644 index 0000000..4399e85 --- /dev/null +++ b/docs/MESSAGE_READ_RECEIPTS.md @@ -0,0 +1,21 @@ +# ثبت مشاهده پیام و polling تیکت + +دریافت پیام، بازکردن صفحه، بارگذاری مکاتبات قدیمی و polling به‌تنهایی رسید مشاهده ثبت نمی‌کنند. ثبت seen از یک درخواست POST جدا انجام می‌شود؛ نمای فعلی تیکت و مدل‌های موجود حفظ شده‌اند. + +مرورگر فقط در تب قابل مشاهده، پیام‌هایی را ارسال می‌کند که حداقل ۴۸ پیکسل از ارتفاع آن‌ها (یا تمام ارتفاع پیام کوتاه‌تر) در محدوده مکاتبات و نمایشگر قرار گرفته باشد. پس از ثابت‌شدن موقعیت برای ۲۵۰ میلی‌ثانیه، موقعیت دوباره بررسی می‌شود. محدوده واقعی نمایشگر در موبایل و بزرگ‌نمایی لحاظ می‌شود و مرکز بخش نمایان پیام نباید زیر پنجره یا لایه دیگری باشد. پیام حذف‌شده ارسال نمی‌شود. پاسخ موفق، شناسه‌های پذیرفته‌شده را برمی‌گرداند تا درخواست تکراری ارسال نشود؛ خطای موقت شبکه باعث تلاش مجدد می‌شود. شناسه‌ها فقط در حافظه همان صفحه نگهداری می‌شوند. + +سرور در هر درخواست مجوز را دوباره بررسی می‌کند: مشتری و magic link فقط پیام‌های عمومی همان تیکت، و کارشناس فقط پیام‌های مجاز خود را می‌توانند تأیید کنند. پیام خود کاربر و پیام حذف‌شده رسید مشاهده نمی‌گیرند. ورودی نامعتبر پاسخ ۴۰۰ می‌گیرد، شناسه‌های خراب یا خارج از محدوده نادیده گرفته می‌شوند، و هر درخواست حداکثر ۱۰۰ شناسه دارد. CSRF، ثبت تکراریِ بدون اثر و کنترل ابطال حفظ شده‌اند. قفل تیکت، ثبت مشاهده را با لغو اشتراک و پردازش اعلان هماهنگ می‌کند. + +پاسخ polling پس از لغو دسترسی یا جایگزین‌شدن درخواست اعمال نمی‌شود. در خطای دسترسی، پیام‌های صفحه پاک و polling متوقف می‌شود. نشانگر polling آخرین پیام تحویل‌داده‌شده است، تا صف بیش از ۵۰ پیام باعث حذف مکاتبات بین دو درخواست نشود. ویرایش و حذف پیام قدیمی، نسخه polling را تغییر می‌دهد و فهرست تازه دریافت می‌شود. + +وضعیت خوانده‌شدن تیکت فقط پس از تأیید مشاهده آخرین پیام مجاز ثبت می‌شود. فیلتر «خوانده‌نشده» به رسید همان کاربر وابسته است و مشاهده همکار دیگر آن را تغییر نمی‌دهد. زمان بازشدن «اشتراک کل تیکت» در ممیزی اشتراک همچنان مستقل از رسید تک‌پیام است. + +این قرارداد مشاهده در صفحه را ثبت می‌کند و اثبات خواندن انسانی متن نیست. رسیدهای قدیمی حذف یا بازنویسی نشده‌اند؛ داده‌های تاریخی به‌تنهایی امکان تشخیص seen اشتباه را نمی‌دهند. اشتراک پیام مجوز کل تیکت ایجاد نمی‌کند؛ دسترسی کامل قبلی کاربر همچنان برقرار است. + +تست‌های دائمی: + +```bash +.venv/Scripts/python.exe -m pytest tests/test_message_read_receipts.py tests/e2e/test_message_read_receipts_e2e.py -q +``` + +تست‌ها از دیتابیس و سرور جداگانه استفاده می‌کنند. تب پس‌زمینه در headless Chromium با سیگنال visibility کنترل‌شده شبیه‌سازی می‌شود؛ محدوده نمایش، اسکرول، خطای شبکه و رقابت لغو دسترسی با پاسخ polling نیز در مرورگر بررسی می‌شوند. diff --git a/docs/PAYMENT_LINKS.md b/docs/PAYMENT_LINKS.md index 139612a..f55b523 100644 --- a/docs/PAYMENT_LINKS.md +++ b/docs/PAYMENT_LINKS.md @@ -1,6 +1,6 @@ # لینک موقت پرداخت -لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، اکنون یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. مسیر ورودی `/chaharsu/link//` پس از بررسی، به آدرس بدون توکن منتقل می‌شود. +لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. لینک‌های جدید مسیر کوتاه `/p//` دارند؛ توکن ۱۰ کاراکتری با همان مولد تصادفی امن magic link ساخته و فقط هش آن ذخیره می‌شود. برخورد توکن حداکثر پنج بار در تراکنش مستقل تکرار می‌شود. مسیر قدیمی `/chaharsu/link//` نیز برای لینک‌های قبلی تا انقضا یا ابطال معتبر است. هر دو مسیر پس از بررسی، به آدرس بدون توکن منتقل می‌شوند. ## محدوده و عمر دسترسی @@ -9,6 +9,7 @@ - مشاهده اطلاعات بانکی، کپی تک‌فیلد و کامل، ثبت فیش مرتبط و مستقل، مشاهده وضعیت، دریافت تصویر و اصلاح فیش ردشده بدون ورود ممکن است. - فیلد «شناسه واریز که با آن پرداخت انجام داده‌اید» هنگام ارسال فیش اختیاری است؛ خالی‌بودن آن مانع ارسال نمی‌شود. هشدار ثبت شناسه در عملیات پرداخت بانکی همچنان نمایش داده می‌شود. - انقضا، ابطال و غیرفعال‌شدن مشتری در هر درخواست دوباره بررسی می‌شود. CSRF و اعتبارسنجی تصویر، مبلغ و تاریخ همچنان برقرارند. +- دکمه انتخاب تصویر فیش از ورودی `accept="image/*"` بدون `capture` استفاده می‌کند تا انتخاب عکس و اسکرین‌شات از گالری ممکن باشد. نمایش دقیق انتخاب‌گر به مرورگر و سیستم‌عامل گوشی بستگی دارد؛ فرمت و اندازه فایل همچنان در مرورگر و سرور اعتبارسنجی می‌شوند. - تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد. - اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود. diff --git a/static/js/conversation-read.js b/static/js/conversation-read.js new file mode 100644 index 0000000..c8ffe72 --- /dev/null +++ b/static/js/conversation-read.js @@ -0,0 +1,77 @@ +/* Receiving a message is not evidence of seeing it. No receipts are cached on disk. */ +document.querySelectorAll('[data-conversation-workspace][data-read-url]').forEach(workspace=>{ + const scroller=workspace.querySelector('[data-conversation]'); + if(!scroller)return; + const acknowledged=new Set(),tracked=new Set(); + let running=false,timer=0,controller=null; + const cookie=name=>document.cookie.split('; ').find(row=>row.startsWith(`${name}=`))?.split('=').slice(1).join('=')||''; + const visibleIds=()=>{ + if(document.visibilityState!=='visible' || workspace.dataset.accessDenied)return []; + const bounds=scroller.getBoundingClientRect(); + const viewport=window.visualViewport; + const viewTop=viewport?.offsetTop||0,viewLeft=viewport?.offsetLeft||0; + const top=Math.max(bounds.top,viewTop),bottom=Math.min(bounds.bottom,viewTop+(viewport?.height||innerHeight)); + const left=Math.max(bounds.left,viewLeft),right=Math.min(bounds.right,viewLeft+(viewport?.width||innerWidth)); + return [...scroller.querySelectorAll('[data-message-id]')].filter(message=>{ + if(acknowledged.has(message.dataset.messageId) || message.hasAttribute('data-message-deleted'))return false; + const box=message.getBoundingClientRect(); + const height=Math.min(box.bottom,bottom)-Math.max(box.top,top); + if(box.height<=0 || box.width<=0 || heightmessage.dataset.messageId); + }; + const schedule=()=>{ + clearTimeout(timer); + timer=setTimeout(send,250); + }; + const denyAccess=()=>{ + workspace.dataset.accessDenied='true'; + delete workspace.dataset.pollUrl; + controller?.abort();clearTimeout(timer); + scroller.querySelectorAll('[data-load-older]').forEach(button=>window.htmx?.trigger(button,'htmx:abort')); + scroller.querySelectorAll('[data-message],.date-divider,.conversation-empty,[data-load-older]').forEach(item=>item.remove()); + const notice=document.createElement('p'); + notice.className='conversation-empty';notice.textContent='دسترسی به این مکاتبات لغو شده است.'; + scroller.append(notice); + const composer=workspace.querySelector('[data-composer-shell]');if(composer)composer.hidden=true; + const jump=workspace.querySelector('[data-jump-latest]');if(jump)jump.hidden=true; + }; + async function send(){ + if(running)return; + const ids=visibleIds();if(!ids.length)return; + running=true;controller=new AbortController(); + try{ + const response=await fetch(workspace.dataset.readUrl,{method:'POST',credentials:'same-origin',cache:'no-store',signal:controller.signal, + headers:{'Content-Type':'application/json','X-CSRFToken':decodeURIComponent(cookie('csrftoken'))},body:JSON.stringify({message_ids:ids})}); + if(response.redirected || [401,403,404].includes(response.status)){workspace.dispatchEvent(new Event('conversation:access-denied'));return} + if(!response.ok)throw new Error(`HTTP ${response.status}`); + const data=await response.json(); + if(workspace.dataset.accessDenied)return; + (data.read_ids||[]).forEach(id=>acknowledged.add(id)); + if(typeof data.unread_shares==='number')document.querySelectorAll('[data-share-unread-count]').forEach(badge=>{ + badge.textContent=String(data.unread_shares);badge.hidden=!data.unread_shares; + }); + }catch(error){if(error.name!=='AbortError')console.error('Message read acknowledgement failed',error)} + finally{running=false;controller=null;if(visibleIds().length)timer=setTimeout(send,3000)} + } + const observer=new IntersectionObserver(schedule,{root:scroller,threshold:[0,0.1,0.5,1]}); + const observeMessages=()=>{ + tracked.forEach(message=>{if(!scroller.contains(message)){observer.unobserve(message);tracked.delete(message)}}); + scroller.querySelectorAll('[data-message-id]').forEach(message=>{if(!tracked.has(message)){tracked.add(message);observer.observe(message)}}); + schedule(); + }; + new MutationObserver(observeMessages).observe(scroller,{childList:true,subtree:true}); + scroller.addEventListener('scroll',schedule,{passive:true}); + new ResizeObserver(schedule).observe(scroller); + document.addEventListener('click',schedule); + document.addEventListener('keydown',schedule); + window.addEventListener('resize',schedule); + window.visualViewport?.addEventListener('resize',schedule); + document.addEventListener('visibilitychange',schedule); + window.addEventListener('pageshow',schedule); + workspace.addEventListener('conversation:access-denied',denyAccess); + window.addEventListener('pagehide',()=>{clearTimeout(timer);controller?.abort()}); + observeMessages(); +}); diff --git a/static/js/conversation.js b/static/js/conversation.js index 768cd1c..ed13c29 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -87,20 +87,8 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{ form?.addEventListener('submit',()=>{form.dataset.submitting='true'}); window.addEventListener('pageshow',()=>{if(submissionSucceeded){form?.reset();if(input){input.value='';resize()}setComposerOpen(false,false)}}); - const pollUrl=workspace.dataset.pollUrl; - const readUrl=workspace.dataset.readUrl; const pollInterval=3000; let pollController=null,pollRunning=false,pollGeneration=0,pollErrors=0,nextPollAt=0; - let readRunning=false; - const cookie=name=>document.cookie.split('; ').find(row=>row.startsWith(`${name}=`))?.split('=').slice(1).join('=')||''; - const acknowledgeVisibleMessages=async()=>{ - if(!readUrl||readRunning||document.visibilityState!=='visible')return; - const messageIds=[...scroller.querySelectorAll('[data-message-id]')].map(item=>item.dataset.messageId).filter(Boolean); - if(!messageIds.length)return; - readRunning=true; - try{await fetch(readUrl,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRFToken':decodeURIComponent(cookie('csrftoken'))},body:JSON.stringify({message_ids:messageIds})})}catch(error){console.error('Message read acknowledgement failed',error)}finally{readRunning=false} - }; - acknowledgeVisibleMessages(); // Keep the staff/customer conversation synchronized even while another tab is // active. Browsers may throttle background timers, so visibility is used only // to trigger an immediate catch-up, never as a hard polling gate. @@ -109,7 +97,7 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{ // local networks, installed PWAs and some mobile browsers). Let fetch be the // source of truth and use the existing error backoff when the origin really // is unavailable. - const canPoll=()=>Boolean(pollUrl); + const canPoll=()=>Boolean(workspace.dataset.pollUrl) && !workspace.dataset.accessDenied; const schedulePoll=()=>{const delay=pollErrors?Math.min(30000,pollInterval*(2**Math.min(pollErrors,3))):pollInterval;nextPollAt=Date.now()+delay}; const applyPollUpdate=data=>{ if(!data.changed)return; @@ -124,7 +112,6 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{ const count=workspace.querySelector('[data-conversation-count]');if(count)count.textContent=String(data.count??''); workspace.querySelectorAll('[data-ticket-status]').forEach(item=>item.textContent=data.status||item.textContent); groupDates(); - acknowledgeVisibleMessages(); scroller.querySelectorAll('img:not([data-poll-bound]),audio:not([data-poll-bound])').forEach(media=>{media.dataset.pollBound='true';const event=media.tagName==='IMG'?'load':'loadedmetadata';media.addEventListener(event,()=>{if(wasAtBottom)scrollLatest(false)},{once:true})}); if(wasAtBottom){scrollLatest(true);jump.hidden=true}else jump.hidden=false; }; @@ -132,17 +119,22 @@ document.querySelectorAll('[data-conversation-workspace]').forEach(workspace=>{ if(!canPoll()||pollRunning)return; const generation=++pollGeneration;pollRunning=true;const controller=new AbortController();pollController=controller; try{ - const url=new URL(pollUrl,location.origin);url.searchParams.set('version',workspace.dataset.pollVersion||'');url.searchParams.set('cursor',workspace.dataset.pollCursor||''); + const url=new URL(workspace.dataset.pollUrl,location.origin);url.searchParams.set('version',workspace.dataset.pollVersion||'');url.searchParams.set('cursor',workspace.dataset.pollCursor||''); const response=await fetch(url,{method:'GET',signal:controller.signal,cache:'no-store',headers:{Accept:'application/json'}}); - if(response.redirected){location.assign(response.url);return} + if(generation!==pollGeneration || !canPoll())return; + if(response.redirected){workspace.dispatchEvent(new Event('conversation:access-denied'));location.assign(response.url);return} + if([401,403,404].includes(response.status)){workspace.dispatchEvent(new Event('conversation:access-denied'));return} if(!response.ok)throw new Error(`HTTP ${response.status}`); - applyPollUpdate(await response.json());pollErrors=0; + const data=await response.json(); + if(generation!==pollGeneration || !canPoll())return; + applyPollUpdate(data);pollErrors=0; }catch(error){if(error.name!=='AbortError'){pollErrors+=1;console.error('Ticket polling failed',error)}}finally{if(generation===pollGeneration){pollRunning=false;pollController=null;schedulePoll()}} }; const startPolling=()=>{if(!canPoll()||pollRunning||Date.now(){pollGeneration+=1;if(pollController){pollController.abort();pollController=null}pollRunning=false;nextPollAt=0}; const restartPolling=()=>{stopPolling();startPolling()}; - document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible'){restartPolling();acknowledgeVisibleMessages()}}); + document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')restartPolling()}); + workspace.addEventListener('conversation:access-denied',stopPolling); window.addEventListener('focus',startPolling); window.addEventListener('pageshow',restartPolling); window.addEventListener('online',restartPolling); diff --git a/static/js/staff-sharing.js b/static/js/staff-sharing.js index 8f11c86..b80d30e 100644 --- a/static/js/staff-sharing.js +++ b/static/js/staff-sharing.js @@ -1,54 +1,23 @@ (() => { - const badges = document.querySelectorAll('[data-share-unread-count]'); - if (!badges.length) return; - const workspace = document.querySelector('[data-share-read-url]'); - const scroller = workspace?.querySelector('[data-conversation]'); - const countUrl = badges[0].dataset.countUrl; - let running = false; - const cookie = name => document.cookie.split('; ').find(row => row.startsWith(`${name}=`))?.split('=').slice(1).join('=') || ''; - const refresh = async () => { - if (running || document.visibilityState !== 'visible') return; - running = true; - try { - const ids = []; - if (scroller) { - const bounds = scroller.getBoundingClientRect(); - scroller.querySelectorAll('[data-message-id]').forEach(message => { - const box = message.getBoundingClientRect(); - if (box.bottom > Math.max(bounds.top, 0) && box.top < Math.min(bounds.bottom, innerHeight)) ids.push(message.dataset.messageId); - }); - } - const body = new URLSearchParams(); - ids.forEach(id => body.append('message_ids', id)); - const response = await fetch(workspace?.dataset.shareReadUrl || countUrl, workspace ? { - method: 'POST', body, headers: {'X-CSRFToken': cookie('csrftoken')}, cache: 'no-store', credentials: 'same-origin' - } : {cache: 'no-store', credentials: 'same-origin'}); - if (response.status === 403 && workspace) { - scroller.querySelectorAll('[data-message], .date-divider, .conversation-empty').forEach(item => item.remove()); - const notice = document.createElement('p'); - notice.className = 'conversation-empty'; - notice.textContent = 'دسترسی به این اشتراک لغو شده است.'; - scroller.append(notice); - workspace.removeAttribute('data-poll-url'); - const counts = await fetch(countUrl, {cache: 'no-store', credentials: 'same-origin'}); - if (counts.ok) { - const data = await counts.json(); - badges.forEach(badge => {badge.textContent = data.unread_shares; badge.hidden = !data.unread_shares;}); - } - return; - } - if (!response.ok) return; - const data = await response.json(); - badges.forEach(badge => {badge.textContent = data.unread_shares; badge.hidden = !data.unread_shares;}); - } catch (_) { - // A failed request leaves the count and read status available for retry. - } finally { running = false; } + const badges=document.querySelectorAll('[data-share-unread-count]'); + if(!badges.length)return; + const countUrl=badges[0].dataset.countUrl; + let running=false; + const refresh=async()=>{ + if(running || document.visibilityState!=='visible')return; + running=true; + try{ + const response=await fetch(countUrl,{cache:'no-store',credentials:'same-origin'}); + if(!response.ok || response.redirected)return; + const data=await response.json(); + badges.forEach(badge=>{badge.textContent=String(data.unread_shares);badge.hidden=!data.unread_shares}); + }catch(_){ + // Retain the last count until the next successful refresh. + }finally{running=false} }; - scroller?.addEventListener('scroll', refresh, {passive: true}); - document.addEventListener('visibilitychange', refresh); - window.addEventListener('pageshow', refresh); - window.addEventListener('focus', refresh); - document.body.addEventListener('htmx:afterSwap', refresh); - setInterval(refresh, workspace ? 3000 : 15000); + document.addEventListener('visibilitychange',refresh); + window.addEventListener('pageshow',refresh); + window.addEventListener('focus',refresh); + setInterval(refresh,15000); refresh(); })(); diff --git a/templates/tickets/_messages.html b/templates/tickets/_messages.html index 45acedb..38f211b 100644 --- a/templates/tickets/_messages.html +++ b/templates/tickets/_messages.html @@ -1,5 +1,5 @@ {% load jalali %}{% for item in conversation %} -
+
{{ item.author_name }}{% if item.is_internal %}🔒 یادداشت داخلی{% endif %}
diff --git a/templates/tickets/_staff_messages.html b/templates/tickets/_staff_messages.html index f22d66d..849de07 100644 --- a/templates/tickets/_staff_messages.html +++ b/templates/tickets/_staff_messages.html @@ -1,5 +1,5 @@ {% load jalali %}{% for item in conversation %} -
+
{{ item.author_name }}{% if item.is_internal %}🔒 یادداشت داخلی{% endif %}
diff --git a/templates/tickets/customer_detail.html b/templates/tickets/customer_detail.html index b564dd2..a54bdbb 100644 --- a/templates/tickets/customer_detail.html +++ b/templates/tickets/customer_detail.html @@ -26,6 +26,7 @@ {% if magic_link_access %}
{{ developer_credit }}
{% endif %}
{% include 'tickets/_image_viewer.html' %} + {% endblock %} {% block footer %}{% endblock %} diff --git a/templates/tickets/staff_detail.html b/templates/tickets/staff_detail.html index 02fdd2e..498373b 100644 --- a/templates/tickets/staff_detail.html +++ b/templates/tickets/staff_detail.html @@ -1,7 +1,7 @@ {% extends 'base.html' %}{% load static jalali %} {% block title %}{{ page_title }}{% endblock %} {% block content %} -
+
→
{{ ticket.ticket_number }}

{{ ticket.subject }}

{{ ticket.get_status_display }}{% include 'tickets/_voip_call_button.html' with customer=ticket.customer %}{% if can_claim %}
{% csrf_token %}
{% endif %}
@@ -46,6 +46,7 @@
{% include 'tickets/_image_viewer.html' %} + {% endblock %} diff --git a/tests/e2e/pages/message_reads.py b/tests/e2e/pages/message_reads.py new file mode 100644 index 0000000..5c318bd --- /dev/null +++ b/tests/e2e/pages/message_reads.py @@ -0,0 +1,101 @@ +from playwright.sync_api import expect +from django.urls import reverse + +from .base import BasePage + + +class MessageReadsPage(BasePage): + def acknowledgement(self, read_path, message): + return self.page.expect_response(lambda response: response.request.method == 'POST' + and response.url.endswith(read_path) + and str(message.public_id) in (response.request.post_data_json or {}).get('message_ids', [])) + + def open_and_acknowledge(self, path, read_path, message): + with self.acknowledgement(read_path, message): + self.goto(path) + expect(self.message(message)).to_be_in_viewport() + + def message(self, message): + return self.page.locator(f'[data-message-id="{message.public_id}"]') + + def stop_initial_follow(self): + self.page.locator('[data-conversation]').hover() + self.page.mouse.wheel(0, -1) + + def scroll_and_acknowledge(self, message, read_path): + self.stop_initial_follow() + with self.acknowledgement(read_path, message): + self.message(message).scroll_into_view_if_needed() + expect(self.message(message)).to_be_in_viewport() + + def expect_unseen(self, message): + expect(self.message(message).locator('.message-receipts')).to_have_count(0) + + def expect_seen_by(self, message, name): + self.stop_initial_follow() + self.message(message).locator('.message-receipts summary').click() + expect(self.message(message).locator('.message-receipts')).to_contain_text(name) + + def emulate_background_tab(self): + # Headless Chromium can keep multiple pages visible; control this signal + # explicitly to test the same visibilitychange branch deterministically. + self.page.add_init_script("Object.defineProperty(document, 'visibilityState', {get: () => window.receiptVisibility || 'hidden'});") + + def activate_and_acknowledge(self, message, read_path): + with self.acknowledgement(read_path, message): + self.page.evaluate("window.receiptVisibility = 'visible'; document.dispatchEvent(new Event('visibilitychange'));") + + def open_in_background(self, path, poll_path): + with self.page.expect_response(lambda response: response.url.split('?')[0].endswith(poll_path)): + self.goto(path) + + def fail_first_acknowledgement(self, read_path): + def fail(route): + route.fulfill(status=503, content_type='application/json', body='{}') + self.page.route('**' + read_path, fail, times=1) + + def wait_for_successful_acknowledgement(self, read_path): + with self.page.expect_response(lambda response: response.url.endswith(read_path) and response.status == 200): + pass + + +class MessageReadsRacePage(MessageReadsPage): + def hold_poll_after_receiving_its_body(self): + self.page.add_init_script(""" + const nativeFetch = window.fetch.bind(window); + window.fetch = async (input, options) => { + const url = new URL(input, location.href); + const polling = url.pathname.includes('/messages/poll/'); + if (polling) { url.searchParams.delete('version'); url.searchParams.delete('cursor'); } + const response = await nativeFetch(polling ? url : input, options); + if (polling && response.ok && !window.blockedPoll) { + window.blockedPoll = true; + const json = response.json.bind(response); + response.json = async () => { + const data = await json(); + await new Promise(resolve => { window.releaseHeldPoll = resolve; }); + window.heldPollParsed = true; + return data; + }; + } + return response; + }; + """) + + def revoke_ticket(self, ticket, recipient, session_cookie, csrf): + self.page.context.request.post(self.url(reverse('staff_tickets:revoke', args=[ticket.public_id, recipient.public_id])), + headers={'Cookie': session_cookie, 'X-CSRFToken': csrf}, max_redirects=0) + + def scroll_to_revoked_message(self, message, read_path): + self.stop_initial_follow() + with self.page.expect_response(lambda response: response.url.endswith(read_path) and response.status == 403): + self.message(message).scroll_into_view_if_needed() + expect(self.page.get_by_text('دسترسی به این مکاتبات لغو شده است.', exact=True)).to_be_visible() + + def release_poll_and_expect_no_messages(self): + self.page.wait_for_function('typeof window.releaseHeldPoll === "function"') + self.page.evaluate('window.releaseHeldPoll()') + self.page.wait_for_function('window.heldPollParsed === true') + self.page.evaluate('() => new Promise(requestAnimationFrame)') + expect(self.page.locator('[data-message-id]')).to_have_count(0) + expect(self.page.get_by_text('دسترسی به این مکاتبات لغو شده است.', exact=True)).to_be_visible() diff --git a/tests/e2e/pages/payment_link.py b/tests/e2e/pages/payment_link.py index aa98572..579a7b8 100644 --- a/tests/e2e/pages/payment_link.py +++ b/tests/e2e/pages/payment_link.py @@ -35,6 +35,10 @@ class PaymentLinkPage(PaymentsPage): self.page.locator('#id_paid_amount').fill('۱۲۳۴۵') expect(self.page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵') self.page.get_by_role('button', name='امروز', exact=True).click() - self.page.locator('#id_receipt_image').set_input_files({'name': 'payment.png', 'mimeType': 'image/png', 'buffer': png}) + with self.page.expect_file_chooser() as picker: + self.page.locator('label.customer-file-button').click() + picker.value.set_files({'name': 'screenshot.png', 'mimeType': 'image/png', 'buffer': png}) + expect(self.page.locator('#receipt-file-status')).to_contain_text('screenshot.png') + expect(self.page.locator('#receipt-file-preview')).to_be_visible() self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click() expect(self.page.locator('.flash')).to_contain_text('فیش ثبت شد و در انتظار بررسی است.') diff --git a/tests/e2e/test_message_read_receipts_e2e.py b/tests/e2e/test_message_read_receipts_e2e.py new file mode 100644 index 0000000..b717cf7 --- /dev/null +++ b/tests/e2e/test_message_read_receipts_e2e.py @@ -0,0 +1,108 @@ +import pytest +from django.test import Client +from django.urls import reverse + +from apps.customers.models import CustomerProfile +from apps.messaging.models import Message +from apps.tickets.sharing import share_staff_ticket +from tests.test_staff_sharing import make_sharing_case +from tests.e2e.pages.auth import AuthenticationPage +from tests.e2e.pages.message_reads import MessageReadsPage, MessageReadsRacePage + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] +PASSWORD = 'SharingTest123!' + + +@pytest.fixture +def reading_case(settings): + settings.PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] + case = make_sharing_case() + case['customer'].set_password(PASSWORD) + case['customer'].save(update_fields=['password']) + CustomerProfile.objects.create(user=case['customer'], first_name='مشتری', last_name='خواننده') + share_staff_ticket(case['ticket'], case['sender'], case['recipient'], 'VIEW_ONLY') + case['probes'] = [Message.objects.create(ticket=case['ticket'], author=case['sender'], + body=f'پیام آزمایشی {i}\n' + ('متن برای آزمایش محدوده مشاهده. ' * 45)) for i in range(12)] + return case + + +@pytest.mark.parametrize('staff,width', [(True, 1280), (False, 390)]) +def test_only_on_screen_messages_receive_seen_and_scrolling_acknowledges_more(live_server, browser_page, reading_case, staff, width): + c = reading_case + namespace = 'staff_tickets' if staff else 'tickets' + detail = reverse(namespace + ':detail', args=[c['ticket'].public_id]) + read = reverse(namespace + (':read_shares' if staff else ':read'), args=[c['ticket'].public_id]) + reader_name = 'کارشناس recipient' if staff else 'مشتری خواننده' + oldest, latest = c['probes'][0], c['probes'][-1] + with browser_page() as page: + page.set_viewport_size({'width': width, 'height': 900}) + auth = AuthenticationPage(page, live_server.url) + if staff: + auth.login_staff(c['recipient'], PASSWORD) + else: + auth.login_customer(c['customer'], PASSWORD) + MessageReadsPage(page, live_server.url).open_and_acknowledge(detail, read, latest) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD) + view = MessageReadsPage(page, live_server.url) + view.goto(reverse('staff_tickets:detail', args=[c['ticket'].public_id])) + view.expect_unseen(oldest) + view.expect_seen_by(latest, reader_name) + with browser_page() as page: + auth = AuthenticationPage(page, live_server.url) + if staff: + auth.login_staff(c['recipient'], PASSWORD) + else: + auth.login_customer(c['customer'], PASSWORD) + view = MessageReadsPage(page, live_server.url) + view.open_and_acknowledge(detail, read, latest) + view.scroll_and_acknowledge(oldest, read) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD) + view = MessageReadsPage(page, live_server.url) + view.goto(reverse('staff_tickets:detail', args=[c['ticket'].public_id])) + view.expect_seen_by(oldest, reader_name) + + +def test_background_poll_does_not_mark_seen_and_failed_acknowledgement_retries(live_server, browser_page, reading_case): + c = reading_case + detail = reverse('staff_tickets:detail', args=[c['ticket'].public_id]) + read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id]) + poll = reverse('staff_tickets:poll', args=[c['ticket'].public_id]) + latest = c['probes'][-1] + with browser_page() as page: + view = MessageReadsPage(page, live_server.url) + view.emulate_background_tab() + AuthenticationPage(page, live_server.url).login_staff(c['recipient'], PASSWORD) + view.open_in_background(detail, poll) + # A second server render exposes any erroneous background receipts. + view.open_in_background(detail, poll) + view.expect_unseen(latest) + view.fail_first_acknowledgement(read) + view.activate_and_acknowledge(latest, read) + # The first response is deliberately 503; the successful retry must arrive. + view.wait_for_successful_acknowledgement(read) + with browser_page() as page: + AuthenticationPage(page, live_server.url).login_staff(c['sender'], PASSWORD) + view = MessageReadsPage(page, live_server.url) + view.goto(detail) + view.expect_seen_by(latest, 'کارشناس recipient') + + +def test_revocation_wins_over_a_poll_response_already_in_flight(live_server, browser_page, reading_case): + c = reading_case + detail = reverse('staff_tickets:detail', args=[c['ticket'].public_id]) + read = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id]) + sender = Client() + sender.force_login(c['sender']) + sender.get(detail) + csrf = sender.cookies['csrftoken'].value + cookie = f"sessionid={sender.cookies['sessionid'].value}; csrftoken={csrf}" + with browser_page() as reader: + view = MessageReadsRacePage(reader, live_server.url) + view.hold_poll_after_receiving_its_body() + AuthenticationPage(reader, live_server.url).login_staff(c['recipient'], PASSWORD) + view.open_and_acknowledge(detail, read, c['probes'][-1]) + view.revoke_ticket(c['ticket'], c['recipient'], cookie, csrf) + view.scroll_to_revoked_message(c['probes'][0], read) + view.release_poll_and_expect_no_messages() diff --git a/tests/test_message_read_receipts.py b/tests/test_message_read_receipts.py new file mode 100644 index 0000000..a8d6760 --- /dev/null +++ b/tests/test_message_read_receipts.py @@ -0,0 +1,153 @@ +import json +import uuid +from datetime import timedelta + +import pytest +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import TicketMagicLink +from apps.messaging.models import Message, MessageReadReceipt +from apps.tickets.models import TicketReadState +from apps.tickets.sharing import revoke_staff_share, share_staff_message, share_staff_ticket +from tests.test_staff_sharing import make_sharing_case + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def seen_case(settings): + settings.PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] + case = make_sharing_case() + case['reply'] = Message.objects.create(ticket=case['ticket'], author=case['sender'], body='پاسخ قابل مشاهده') + return case + + +@pytest.mark.parametrize('route', ['detail', 'messages', 'poll']) +def test_staff_get_requests_never_mark_messages_or_ticket_read(client, seen_case, route): + c = seen_case + share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY') + client.force_login(c['recipient']) + response = client.get(reverse('staff_tickets:' + route, args=[c['ticket'].public_id])) + assert response.status_code == 200 + assert not MessageReadReceipt.objects.filter(user=c['recipient']).exists() + assert not TicketReadState.objects.filter(user=c['recipient']).exists() + + +def test_staff_acknowledgement_is_scoped_idempotent_and_rechecked_after_revocation(client, seen_case): + c = seen_case + grant = share_staff_message(c['selected'], c['sender'], c['recipient']) + client.force_login(c['recipient']) + url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id]) + values = [str(c['selected'].public_id), str(c['reply'].public_id), str(uuid.uuid4()), 'bad-uuid'] + for _ in range(2): + response = client.post(url, {'message_ids': values}) + assert response.status_code == 200 + assert response.json()['read_ids'] == [str(c['selected'].public_id)] + assert list(MessageReadReceipt.objects.filter(user=c['recipient']).values_list('message_id', flat=True)) == [c['selected'].pk] + assert not TicketReadState.objects.filter(user=c['recipient']).exists() + grant.refresh_from_db() + assert grant.view_count == 1 + revoke_staff_share(grant, c['sender']) + assert client.post(url, {'message_ids': values}).status_code == 403 + assert client.get(reverse('staff_tickets:poll', args=[c['ticket'].public_id])).status_code == 403 + assert not MessageReadReceipt.objects.filter(message=c['reply'], user=c['recipient']).exists() + + +def test_staff_ticket_read_state_requires_acknowledging_the_latest_message(client, seen_case): + c = seen_case + client.force_login(c['sender']) + url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id]) + client.post(url, {'message_ids': [str(c['selected'].public_id)]}) + assert not TicketReadState.objects.filter(user=c['sender']).exists() + client.post(url, {'message_ids': [str(c['reply'].public_id)]}) + assert TicketReadState.objects.get(user=c['sender']).last_read_message_id == c['reply'].pk + assert not MessageReadReceipt.objects.filter(message=c['reply'], user=c['sender']).exists() + + +def test_another_colleagues_read_state_does_not_hide_my_unread_ticket(seen_case): + from apps.tickets.selectors import filter_staff_tickets, staff_ticket_rows + c = seen_case + share_staff_ticket(c['ticket'], c['sender'], c['recipient'], 'VIEW_ONLY') + TicketReadState.objects.create(ticket=c['ticket'], user=c['sender'], last_read_at=timezone.now(), last_read_message=c['reply']) + unread = filter_staff_tickets(staff_ticket_rows(c['recipient']), c['recipient'], {'unread': '1'}) + assert unread.filter(pk=c['ticket'].pk).exists() + own_state = TicketReadState.objects.create(ticket=c['ticket'], user=c['recipient'], last_read_at=timezone.now(), last_read_message=c['reply']) + assert not unread.filter(pk=c['ticket'].pk).exists() + own_state.last_read_at = c['ticket'].last_activity_at - timedelta(seconds=1) + own_state.save(update_fields=['last_read_at']) + assert unread.filter(pk=c['ticket'].pk).exists() + + +def test_customer_receipts_exclude_own_deleted_internal_and_other_ticket_messages(client, seen_case): + c = seen_case + deleted = Message.objects.create(ticket=c['ticket'], author=c['sender'], body='حذف‌شده', deleted_at=timezone.now()) + from apps.tickets.services import create_ticket + other = create_ticket(customer=c['customer'], category=c['ticket'].category, subject='تیکت دیگر', body='پیام تیکت دیگر') + foreign = Message.objects.create(ticket=other, author=c['sender'], body='پاسخ خصوصی تیکت دیگر') + client.force_login(c['customer']) + for route in ['detail', 'messages', 'poll']: + assert client.get(reverse('tickets:' + route, args=[c['ticket'].public_id])).status_code == 200 + assert not MessageReadReceipt.objects.filter(user=c['customer']).exists() + identifiers = [str(value) for value in c['ticket'].ticket_messages.values_list('public_id', flat=True)] + identifiers += [str(foreign.public_id), str(uuid.uuid4()), 'not-a-uuid', 42, None] + url = reverse('tickets:read', args=[c['ticket'].public_id]) + response = client.post(url, json.dumps({'message_ids': identifiers}), content_type='application/json') + assert response.status_code == 200 + assert list(MessageReadReceipt.objects.filter(user=c['customer']).values_list('message_id', flat=True)) == [c['reply'].pk] + assert not MessageReadReceipt.objects.filter(message=deleted).exists() + + +@pytest.mark.parametrize('payload', ['not json', 'null', '[]', '{"message_ids":"bad"}', '{"message_ids":{}}']) +@pytest.mark.parametrize('staff', [True, False]) +def test_malformed_read_payloads_return_400_without_recording_reads(client, seen_case, payload, staff): + c = seen_case + client.force_login(c['sender'] if staff else c['customer']) + url = reverse('staff_tickets:read_shares' if staff else 'tickets:read', args=[c['ticket'].public_id]) + assert client.post(url, payload, content_type='application/json').status_code == 400 + assert not MessageReadReceipt.objects.exists() + values = [str(c['reply'].public_id)] * 101 + assert client.post(url, json.dumps({'message_ids': values}), content_type='application/json').status_code == 400 + + +def test_read_acknowledgements_keep_csrf_and_magic_link_scope(seen_case): + c = seen_case + strict = Client(enforce_csrf_checks=True) + strict.force_login(c['sender']) + staff_url = reverse('staff_tickets:read_shares', args=[c['ticket'].public_id]) + assert strict.post(staff_url, {'message_ids': [str(c['selected'].public_id)]}).status_code == 403 + anonymous = Client() + token = TicketMagicLink.issue(c['ticket']) + assert anonymous.get(reverse('accounts:magic', args=[token])).status_code == 302 + url = reverse('tickets:read', args=[c['ticket'].public_id]) + assert anonymous.post(url, json.dumps({'message_ids': [str(c['reply'].public_id)]}), content_type='application/json').status_code == 200 + assert MessageReadReceipt.objects.filter(message=c['reply'], user=c['customer']).exists() + assert '_auth_user_id' not in anonymous.session + + +def test_polling_backlog_does_not_skip_messages_after_the_first_batch(client, seen_case): + c = seen_case + client.force_login(c['sender']) + url = reverse('staff_tickets:poll', args=[c['ticket'].public_id]) + initial = client.get(url).json() + batch = [Message.objects.create(ticket=c['ticket'], author=c['customer'], body=f'پیام صف {i}') for i in range(55)] + first = client.get(url, {'version': initial['version'], 'cursor': initial['cursor']}).json() + assert not first['replace'] and first['cursor'] == str(batch[49].public_id) + second = client.get(url, {'version': first['version'], 'cursor': first['cursor']}).json() + assert not second['replace'] and second['cursor'] == str(batch[-1].public_id) + assert batch[-1].body in second['html'] + assert not MessageReadReceipt.objects.exists() + + +def test_polling_detects_editing_an_older_message_even_with_a_new_reply(client, seen_case): + from apps.messaging.services import edit_message + c = seen_case + client.force_login(c['customer']) + url = reverse('tickets:poll', args=[c['ticket'].public_id]) + initial = client.get(url).json() + edit_message(c['selected'].pk, c['customer'], 'متن اصلاح‌شده پیام قبلی') + Message.objects.create(ticket=c['ticket'], author=c['sender'], body='پاسخ همزمان') + response = client.get(url, {'version': initial['version'], 'cursor': initial['cursor']}).json() + assert response['changed'] and response['replace'] + assert 'متن اصلاح‌شده پیام قبلی' in response['html'] and 'پاسخ همزمان' in response['html'] diff --git a/tests/test_payment_links.py b/tests/test_payment_links.py index d226c12..cae990a 100644 --- a/tests/test_payment_links.py +++ b/tests/test_payment_links.py @@ -1,5 +1,6 @@ """Permanent regressions for SMS payment capabilities; no real SMS is sent.""" import uuid +import re from datetime import timedelta from urllib.parse import urlsplit @@ -10,7 +11,7 @@ from django.test import Client from django.urls import reverse from django.utils import timezone -from apps.accounts.models import User +from apps.accounts.models import TicketMagicLink, User from apps.core.models import SiteBranding from apps.notifications.models import NotificationJob, SMSTemplate from apps.payments import services @@ -40,6 +41,9 @@ def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settin notice_sms = PaymentNotification.objects.get(job=job) url = notice_sms.context['payment_link'] token = urlsplit(url).path.rstrip('/').rsplit('/', 1)[-1] + assert urlsplit(url).path == f'/p/{token}/' + assert re.fullmatch(r'[a-z0-9]{10}', token) + assert url in notice_sms.rendered_body assert notice_sms.access_link.token_hash == token_digest(token) assert notice_sms.access_link.expires_at > timezone.now() + timedelta(days=2) first_body = notice_sms.rendered_body @@ -55,6 +59,52 @@ def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settin assert notice_sms.access_link.view_count == 2 +def test_short_payment_link_retries_collision_without_breaking_transaction(case, client, monkeypatch): + tokens = iter(['ac26i9raoz', 'ac26i9raoz', 'lfp694raup']) + monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: next(tokens)) + first, _ = issue_payment_link(notice=case['notice']) + second, url = issue_payment_link(notice=case['notice']) + assert first.pk != second.pk + assert urlsplit(url).path == '/p/lfp694raup/' + assert PaymentAccessLink.objects.count() == 2 + assert client.get('/p/lfp694raup/', follow=True).status_code == 200 + + +def test_payment_link_collision_retries_are_bounded(case, monkeypatch): + monkeypatch.setattr(TicketMagicLink, 'generate_secure_token', lambda length: 'ac26i9raoz') + issue_payment_link(notice=case['notice']) + with pytest.raises(RuntimeError, match='لینک یکتای پرداخت'): + issue_payment_link(notice=case['notice']) + assert PaymentAccessLink.objects.count() == 1 + + +def test_legacy_long_payment_link_keeps_scope_and_expiry(case, client): + token = 'x_NAEA8ptOXu5msNYgNNpCM8vyWh6_VAhELPs5q8C2U' + link = PaymentAccessLink.objects.create(customer=case['customer'], notice=case['notice'], + token_hash=token_digest(token), expires_at=timezone.now() + timedelta(days=1)) + path = reverse('payments:open_link', args=[token]) + response = client.get(path, follow=True) + assert response.status_code == 200 + assert client.session[SESSION_KEY] == link.pk + assert '_auth_user_id' not in client.session + PaymentAccessLink.objects.filter(pk=link.pk).update(expires_at=timezone.now() - timedelta(seconds=1)) + assert client.get(path).status_code == 410 + + +@pytest.mark.parametrize('via_link', [False, True]) +def test_receipt_picker_accepts_gallery_images_without_camera_capture(case, client, via_link): + if via_link: + open_notice(client, case) + else: + client.force_login(case['customer']) + response = client.get(reverse('payments:receipt_new'), {'notice': case['notice'].public_id}) + assert response.status_code == 200 + widget = response.context['form'].fields['receipt_image'].widget + assert widget.attrs['accept'] == 'image/*' + assert 'capture' not in widget.attrs + assert 'accept="image/*"' in response.content.decode() + + def test_public_notice_preserves_copy_warning_and_database_signature(case, client): branding = SiteBranding.load() branding.developer_credit_text = 'امضای توسعه‌دهنده از دیتابیس' diff --git a/tests/test_payments.py b/tests/test_payments.py index 54bfe1e..66b09bf 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -206,6 +206,7 @@ def test_missing_policy_stays_unapproved_until_manager_assigns(case): def test_sms_uses_database_template_and_attempt_history(case,monkeypatch): from apps.notifications.management.commands import process_notifications + from apps.payments.models import PaymentNotification template=SMSTemplate.objects.get(event_type='PAYMENT_NOTICE_CREATED',unit__isnull=True) template.body='متن سفارشی {notice_number} {payment_link}';template.save() captured=[] @@ -217,7 +218,10 @@ def test_sms_uses_database_template_and_attempt_history(case,monkeypatch): process_notifications._send_sms(job) template.body='جدید';template.save() process_notifications._send_sms(job) - assert captured[0] == captured[1] and 'متن سفارشی' in captured[0] and '/chaharsu/' in captured[0] + notification = PaymentNotification.objects.get(job=job) + assert captured[0] == captured[1] and 'متن سفارشی' in captured[0] + assert '/p/' in notification.context['payment_link'] + assert notification.context['payment_link'] in captured[0] assert PaymentNotificationAttempt.objects.filter(success=True).count() == 2 assert job.ticket is None diff --git a/tests/test_views.py b/tests/test_views.py index 0302d0e..2d251ac 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -490,7 +490,10 @@ def test_ticket_polling_javascript_keeps_background_sync_and_recovers_reliably() assert "conversationEnd?.scrollIntoView" not in script assert "scroller.scrollTo({top,behavior:'smooth'})" in script assert "data-message-id" in Path("templates/tickets/_messages.html").read_text(encoding="utf-8") - assert "acknowledgeVisibleMessages" in script and "document.visibilityState!=='visible'" in script + read_script=Path('static/js/conversation-read.js').read_text(encoding='utf-8') + assert "document.visibilityState!=='visible'" in read_script and 'getBoundingClientRect' in read_script and 'elementFromPoint' in read_script + for template in ('customer_detail.html', 'staff_detail.html'): + assert "{% static 'js/conversation-read.js' %}?v={{ static_asset_version }}" in Path('templates/tickets',template).read_text(encoding='utf-8') source_css=Path("static/src/tailwind.css").read_text(encoding="utf-8") assert ".staff-ticket-workspace .composer.is-collapsed{position:fixed" not in source_css assert ".staff-ticket-workspace .composer:not(.is-collapsed){position:fixed" not in source_css