Files
docs/archive/01-BUSINESS/club-membership-contract-system.md
T
masoodafar-web 5965b98728 update
2026-01-03 18:27:49 +03:30

45 KiB

Club Membership Contract System - سیستم قرارداد باشگاه مشتریان

تاریخ ایجاد: 2024-12-16
وضعیت: پیاده‌سازی شده
اولویت: 🔴 بسیار بالا
مرتبط با: base-package-payment-system.md


📋 فهرست

  1. خلاصه سیستم
  2. Business Requirements
  3. Complete Flow
  4. CMS Layer
  5. BFF Layer
  6. Frontend Layer
  7. OTP SMS Format
  8. Token Refresh Pattern
  9. Testing Checklist

🎯 خلاصه سیستم

سیستم قرارداد باشگاه مشتریان یک مدال غیرقابل بسته شدن است که بعد از پرداخت موفق پکیج پایه، کاربر را ملزم به امضای قرارداد می‌کند تا بتواند:

  1. عضویت باشگاه مشتریان فعال شود
  2. لینک دعوت (Referral Link) نمایش داده شود
  3. به امکانات کامل باشگاه مشتریان دسترسی داشته باشد

ویژگی‌های کلیدی:

  • Modal غیرقابل بسته شدن (کاربر نمی‌تواند Escape یا Click بیرون را استفاده کند)
  • OTP Verification برای امنیت بالاتر
  • Automatic Token Refresh بعد از امضای موفق
  • ثبت قرارداد در دیتابیس با HTML content و SignGuid

📊 Business Requirements

شرایط نمایش Modal:

if (HasPurchasedPackage && !IsClubMemberActive)
{
    // نمایش Modal قرارداد
}
  • HasPurchasedPackage: PackagePurchaseMethod != None (پرداخت موفق انجام شده)
  • IsClubMemberActive: ClubMembership.IsActive = true (قرارداد امضا شده)

ContractType Enum:

public enum ContractType
{
    Main = 0,           // قرارداد ثبت‌نام اولیه
    ClubMembership = 1, // قرارداد باشگاه مشتریان
}

OTP Configuration:

  • Purpose: signClubContract
  • Expiry: 120 seconds (2 minutes)
  • Code Length: 6 digits
  • SMS Provider: Kavenegar

ClubMembership Activation Values:

ClubMembership {
    IsActive = true,
    ActivatedAt = DateTime.Now,
    InitialContribution = 56_000_000,  // مبلغ اولیه
    GiftValue = 25_200_000,            // ارزش هدیه (45% از 56M)
    PurchaseMethod = user.PackagePurchaseMethod
}

🔄 Complete Flow

sequenceDiagram
    participant User
    participant Frontend
    participant BFF
    participant CMS
    participant SMS as Kavenegar

    Note over User,SMS: 1️⃣ Payment Successful (قبلاً انجام شده)
    
    User->>Frontend: ورود به صفحه Profile
    Frontend->>Frontend: CheckAndShowClubContractModal()
    
    alt HasPurchasedPackage && !IsClubMemberActive
        Frontend->>User: نمایش Modal غیرقابل بسته شدن
        User->>User: مطالعه قرارداد (HTML Content)
        
        Note over User,SMS: 2️⃣ Request OTP
        User->>Frontend: کلیک "درخواست کد تایید"
        Frontend->>BFF: RequestClubContractOtp(SignGuid)
        BFF->>CMS: CreateNewOtpToken(mobile, "signClubContract")
        CMS-->>BFF: OTP Code (6 digits)
        BFF->>SMS: Send SMS(mobile, code, signGuid, fullName)
        SMS-->>User: پیامک با کد OTP
        BFF-->>Frontend: Success
        Frontend->>Frontend: شروع Timer (120 ثانیه)
        
        Note over User,SMS: 3️⃣ Accept Contract
        User->>Frontend: وارد کردن OTP Code
        Frontend->>BFF: AcceptClubMembershipContract(OtpCode, SignGuid, ContractHtml)
        BFF->>CMS: AcceptClubMembershipContract(UserId, OtpCode, SignGuid, ContractHtml)
        
        CMS->>CMS: VerifyOtpAsync(mobile, "signClubContract", OtpCode)
        alt OTP Invalid
            CMS-->>BFF: Error: "کد وارد شده اشتباه است"
            BFF-->>Frontend: Error
            Frontend->>User: پیام خطا
        else OTP Valid
            CMS->>CMS: ثبت Contract (اگر وجود نداشته باشد)
            CMS->>CMS: ثبت UserContract (SignGuid, ContractHtml)
            CMS->>CMS: فعالسازی ClubMembership (IsActive=true)
            CMS-->>BFF: Success
            
            Note over User,SMS: 4️⃣ Token Refresh
            BFF->>CMS: GetJwtToken(UserId)
            CMS-->>BFF: New JWT Token
            BFF-->>Frontend: Success + NewToken
            
            Frontend->>Frontend: ذخیره Token در localStorage
            Frontend->>Frontend: بستن Modal و Refresh صفحه
            Frontend->>User: نمایش پیام موفقیت + لینک دعوت
        end
    end

💻 CMS Layer

📂 File Structure:

CMS/
  src/CMSMicroservice.Domain/
    Enums/
      ContractType.cs                 ✅ Modified
    
  src/CMSMicroservice.Application/
    ClubMemberships/
      Commands/
        AcceptClubMembershipContract/
          AcceptClubMembershipContractCommand.cs           ✅ Created
          AcceptClubMembershipContractCommandValidator.cs  ✅ Created
          AcceptClubMembershipContractCommandHandler.cs    ✅ Created
    
    Profiles/
      ClubFeatureProfile.cs           ✅ Modified
  
  src/CMSMicroservice.Protobuf/
    Protos/
      clubmembership.proto            ✅ Modified
  
  src/CMSMicroservice.WebApi/
    Services/
      ClubMembershipService.cs        ✅ Modified

1️⃣ ContractType.cs

namespace CMSMicroservice.Domain.Enums;

/// <summary>
/// تعیین نوع قرارداد
/// </summary>
public enum ContractType
{
    /// <summary>
    /// قرارداد ثبت‌نام اولیه
    /// </summary>
    Main = 0,

    /// <summary>
    /// قرارداد باشگاه مشتریان
    /// </summary>
    ClubMembership = 1,
}

تغییرات: CMS = 1ClubMembership = 1


2️⃣ AcceptClubMembershipContractCommand.cs

namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;

/// <summary>
/// Command برای پذیرش و امضای قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand
{
    /// <summary>
    /// شناسه کاربر
    /// </summary>
    public required long UserId { get; init; }

    /// <summary>
    /// کد OTP ارسال شده به کاربر (6 رقمی)
    /// </summary>
    public required string OtpCode { get; init; }

    /// <summary>
    /// شناسه یکتای امضاء (GUID)
    /// </summary>
    public required string SignGuid { get; init; }

    /// <summary>
    /// محتوای HTML قرارداد برای ذخیره
    /// </summary>
    public required string ContractHtml { get; init; }
}

3️⃣ AcceptClubMembershipContractCommandValidator.cs

using FluentValidation;

namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;

public class AcceptClubMembershipContractCommandValidator 
    : AbstractValidator<AcceptClubMembershipContractCommand>
{
    public AcceptClubMembershipContractCommandValidator()
    {
        RuleFor(x => x.UserId)
            .GreaterThan(0)
            .WithMessage("شناسه کاربر نامعتبر است");

        RuleFor(x => x.OtpCode)
            .NotEmpty()
            .WithMessage("کد تایید الزامی است")
            .Length(6)
            .WithMessage("کد تایید باید 6 رقمی باشد")
            .Matches(@"^\d{6}$")
            .WithMessage("کد تایید فقط باید شامل اعداد باشد");

        RuleFor(x => x.SignGuid)
            .NotEmpty()
            .WithMessage("شناسه امضاء الزامی است")
            .Must(guid => Guid.TryParse(guid, out _))
            .WithMessage("شناسه امضاء نامعتبر است");

        RuleFor(x => x.ContractHtml)
            .NotEmpty()
            .WithMessage("محتوای قرارداد الزامی است")
            .MinimumLength(100)
            .WithMessage("محتوای قرارداد نامعتبر است");
    }
}

4️⃣ AcceptClubMembershipContractCommandHandler.cs

using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;

public class AcceptClubMembershipContractCommandHandler 
    : IRequestHandler<AcceptClubMembershipContractCommand, bool>
{
    private readonly IApplicationDbContext _context;

    public AcceptClubMembershipContractCommandHandler(IApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<bool> Handle(
        AcceptClubMembershipContractCommand request, 
        CancellationToken cancellationToken)
    {
        // 1️⃣ دریافت کاربر با ClubMembership
        var user = await _context.Users
            .Include(u => u.ClubMembership)
            .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);

        if (user == null)
            throw new Exception("کاربر یافت نشد");

        // 2️⃣ بررسی پیش‌نیازها
        if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
            throw new Exception("برای فعالسازی باشگاه مشتریان ابتدا باید پکیج پایه را خریداری کنید");

        if (user.ClubMembership?.IsActive == true)
            throw new Exception("باشگاه مشتریان شما قبلاً فعال شده است");

        // 3️⃣ تایید OTP
        var isOtpValid = await VerifyOtpAsync(
            user.MobileNumber, 
            "signClubContract", 
            request.OtpCode, 
            cancellationToken);

        if (!isOtpValid)
            throw new Exception("کد وارد شده اشتباه است یا منقضی شده است");

        // 4️⃣ ایجاد/دریافت Contract
        var contract = await _context.Contracts
            .FirstOrDefaultAsync(c => c.Type == ContractType.ClubMembership, cancellationToken);

        if (contract == null)
        {
            // اگر Contract وجود نداشته باشد، ایجاد می‌کنیم
            contract = new Contract
            {
                Type = ContractType.ClubMembership,
                Title = "قرارداد باشگاه مشتریان کارابازار",
                Description = "شرایط و ضوابط عضویت در باشگاه مشتریان",
                IsActive = true,
                CreatedAt = DateTime.Now
            };
            _context.Contracts.Add(contract);
            await _context.SaveChangesAsync(cancellationToken);
        }

        // 5️⃣ ثبت UserContract (امضای کاربر)
        var userContract = new UserContract
        {
            UserId = user.Id,
            ContractId = contract.Id,
            SignGuid = request.SignGuid,
            SignedPdfFile = request.ContractHtml, // HTML content ذخیره می‌شود
            IsAccepted = true,
            SignedAt = DateTime.Now
        };
        _context.UserContracts.Add(userContract);

        // 6️⃣ فعالسازی ClubMembership
        if (user.ClubMembership == null)
        {
            user.ClubMembership = new ClubMembership
            {
                UserId = user.Id,
                IsActive = true,
                ActivatedAt = DateTime.Now,
                InitialContribution = 56_000_000,  // مبلغ پکیج پایه
                GiftValue = 25_200_000,            // 45% هدیه
                PurchaseMethod = user.PackagePurchaseMethod
            };
            _context.ClubMemberships.Add(user.ClubMembership);
        }
        else
        {
            user.ClubMembership.IsActive = true;
            user.ClubMembership.ActivatedAt = DateTime.Now;
            user.ClubMembership.InitialContribution = 56_000_000;
            user.ClubMembership.GiftValue = 25_200_000;
            user.ClubMembership.PurchaseMethod = user.PackagePurchaseMethod;
        }

        await _context.SaveChangesAsync(cancellationToken);
        return true;
    }

    /// <summary>
    /// تایید کد OTP
    /// </summary>
    private async Task<bool> VerifyOtpAsync(
        string mobile, 
        string purpose, 
        string code, 
        CancellationToken cancellationToken)
    {
        var otpToken = await _context.OtpTokens
            .Where(o => o.Mobile == mobile 
                     && o.Purpose == purpose 
                     && o.Code == code 
                     && !o.IsUsed)
            .OrderByDescending(o => o.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);

        if (otpToken == null)
            return false;

        // بررسی انقضا (120 ثانیه)
        if ((DateTime.Now - otpToken.CreatedAt).TotalSeconds > 120)
            return false;

        // علامت‌گذاری به عنوان استفاده شده
        otpToken.IsUsed = true;
        await _context.SaveChangesAsync(cancellationToken);

        return true;
    }
}

نکات کلیدی:

  • بررسی PackagePurchaseMethod != None (باید پکیج خریداری شده باشد)
  • جلوگیری از امضای مجدد (ClubMembership.IsActive == true)
  • تایید OTP با VerifyOtpAsync method
  • ایجاد Contract اگر وجود نداشته باشد
  • ثبت UserContract با SignGuid و HTML content
  • فعالسازی ClubMembership با مقادیر مشخص شده

5️⃣ clubmembership.proto

syntax = "proto3";

option csharp_namespace = "CMSMicroservice.Protobuf";

package clubmembership;

service ClubMembershipContract {
  // ... other RPCs ...
  
  rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest) 
      returns (AcceptClubMembershipContractResponse);
}

message AcceptClubMembershipContractRequest {
  int64 user_id = 1;
  string otp_code = 2;
  string sign_guid = 3;
  string contract_html = 4;
}

message AcceptClubMembershipContractResponse {
  bool success = 1;
  string message = 2;
}

6️⃣ ClubMembershipService.cs

public override async Task<AcceptClubMembershipContractResponse> AcceptClubMembershipContract(
    AcceptClubMembershipContractRequest request,
    ServerCallContext context)
{
    try
    {
        var command = _mapper.Map<AcceptClubMembershipContractCommand>(request);
        var result = await _mediator.Send(command);

        return new AcceptClubMembershipContractResponse
        {
            Success = result,
            Message = result ? "قرارداد با موفقیت امضا شد" : "خطا در امضای قرارداد"
        };
    }
    catch (Exception ex)
    {
        return new AcceptClubMembershipContractResponse
        {
            Success = false,
            Message = ex.Message
        };
    }
}

7️⃣ ClubFeatureProfile.cs

using CMSMicroservice.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
using CMSMicroservice.Protobuf;
using Mapster;

namespace CMSMicroservice.Application.Profiles;

public class ClubFeatureProfile : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        // ... other mappings ...

        config.NewConfig<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand>()
            .Map(dest => dest.UserId, src => src.UserId)
            .Map(dest => dest.OtpCode, src => src.OtpCode)
            .Map(dest => dest.SignGuid, src => src.SignGuid)
            .Map(dest => dest.ContractHtml, src => src.ContractHtml);
    }
}

🔌 BFF Layer

📂 File Structure:

FrontOffice.BFF/
  src/FrontOffice.BFF.Domain/
    (No changes - using CMS entities)
  
  src/FrontOffice.BFF.Application/
    ClubMemberships/
      Commands/
        RequestClubContractOtp/
          RequestClubContractOtpCommand.cs           ✅ Created
          RequestClubContractOtpCommandValidator.cs  ✅ Created
          RequestClubContractOtpCommandHandler.cs    ✅ Created (با IKavenegarService)
        
        AcceptClubMembershipContract/
          AcceptClubMembershipContractCommand.cs           ✅ Created
          AcceptClubMembershipContractCommandValidator.cs  ✅ Created
          AcceptClubMembershipContractCommandHandler.cs    ✅ Created
    
    Profiles/
      ClubMembershipProfile.cs        ✅ Modified
  
  src/Protobufs/
    clubmembership.proto              ✅ Modified
  
  src/FrontOffice.BFF.WebApi/
    Services/
      ClubMembershipGrpcService.cs    ✅ Modified

1️⃣ RequestClubContractOtpCommand.cs

using MediatR;

namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;

/// <summary>
/// Command برای درخواست OTP برای امضای قرارداد باشگاه مشتریان
/// </summary>
public record RequestClubContractOtpCommand : IRequest<bool>
{
    /// <summary>
    /// شناسه یکتای امضاء (GUID) - برای ارسال در پیامک
    /// </summary>
    public required string SignGuid { get; init; }
}

2️⃣ RequestClubContractOtpCommandValidator.cs

using FluentValidation;

namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;

public class RequestClubContractOtpCommandValidator 
    : AbstractValidator<RequestClubContractOtpCommand>
{
    public RequestClubContractOtpCommandValidator()
    {
        RuleFor(x => x.SignGuid)
            .NotEmpty()
            .WithMessage("شناسه امضاء الزامی است")
            .Must(guid => Guid.TryParse(guid, out _))
            .WithMessage("شناسه امضاء نامعتبر است");
    }
}

3️⃣ RequestClubContractOtpCommandHandler.cs

using System.Text;
using FrontOffice.BFF.Application.Common.Interfaces;
using MediatR;
using OtpService.Protobuf;

namespace FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;

public class RequestClubContractOtpCommandHandler 
    : IRequestHandler<RequestClubContractOtpCommand, bool>
{
    private readonly IApplicationContractContext _context;
    private readonly IKavenegarService _kavenegarService;
    private readonly ICurrentUserService _currentUserService;

    public RequestClubContractOtpCommandHandler(
        IApplicationContractContext context,
        IKavenegarService kavenegarService,
        ICurrentUserService currentUserService)
    {
        _context = context;
        _kavenegarService = kavenegarService;
        _currentUserService = currentUserService;
    }

    public async Task<bool> Handle(
        RequestClubContractOtpCommand request, 
        CancellationToken cancellationToken)
    {
        // 1️⃣ دریافت شماره موبایل از CurrentUserService
        var mobileNumber = _currentUserService.MobileNumber;
        
        if (string.IsNullOrEmpty(mobileNumber))
            throw new Exception("شماره موبایل کاربر یافت نشد");

        // 2️⃣ فراخوانی CMS برای ایجاد OTP
        var otpResponse = await _context.OtpToken.CreateNewOtpTokenAsync(
            new CreateNewOtpTokenRequest
            {
                Mobile = mobileNumber,
                Purpose = "signClubContract"
            },
            cancellationToken: cancellationToken);

        if (!otpResponse.Success || string.IsNullOrWhiteSpace(otpResponse.Code))
            throw new Exception("خطا در ارسال کد تایید");

        // 3️⃣ ارسال پیامک با Kavenegar
        var fullName = $"{_currentUserService.FirstName} {_currentUserService.LastName}".Trim();
        
        await _kavenegarService.Send(
            mobile: mobileNumber,
            new StringBuilder("سلام ")
                .Append(fullName)
                .AppendLine(" عزیز")
                .Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ")
                .AppendLine(otpResponse.Code)
                .AppendLine("شناسه امضاء: ")
                .AppendLine(request.SignGuid)
                .AppendLine("کارابازار")
                .ToString());

        return true;
    }
}

نکات کلیدی:

  • استفاده از IKavenegarService برای ارسال پیامک (مشابه CreateNewOtpTokenCommandHandler)
  • دریافت MobileNumber از ICurrentUserService (از JWT Token)
  • Purpose: signClubContract
  • ارسال SignGuid در پیامک برای ردیابی
  • Format پیامک شامل: نام کاربر، کد OTP، شناسه امضا، نام شرکت

SMS Format Example:

سلام علی عزیز
کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: 123456
شناسه امضاء: a1b2c3d4-e5f6-7890-abcd-ef1234567890
کارابازار

4️⃣ AcceptClubMembershipContractCommand.cs

using MediatR;

namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;

/// <summary>
/// Command برای پذیرش و امضای قرارداد باشگاه مشتریان
/// </summary>
public record AcceptClubMembershipContractCommand : IRequest<string?>
{
    /// <summary>
    /// کد OTP ارسال شده به کاربر (6 رقمی)
    /// </summary>
    public required string OtpCode { get; init; }

    /// <summary>
    /// شناسه یکتای امضاء (GUID)
    /// </summary>
    public required string SignGuid { get; init; }

    /// <summary>
    /// محتوای HTML قرارداد برای ذخیره
    /// </summary>
    public required string ContractHtml { get; init; }
}

Return Type: string? - JWT Token جدید (اگر موفق بود)


5️⃣ AcceptClubMembershipContractCommandValidator.cs

using FluentValidation;

namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;

public class AcceptClubMembershipContractCommandValidator 
    : AbstractValidator<AcceptClubMembershipContractCommand>
{
    public AcceptClubMembershipContractCommandValidator()
    {
        RuleFor(x => x.OtpCode)
            .NotEmpty()
            .WithMessage("کد تایید الزامی است")
            .Length(6)
            .WithMessage("کد تایید باید 6 رقمی باشد")
            .Matches(@"^\d{6}$")
            .WithMessage("کد تایید فقط باید شامل اعداد باشد");

        RuleFor(x => x.SignGuid)
            .NotEmpty()
            .WithMessage("شناسه امضاء الزامی است")
            .Must(guid => Guid.TryParse(guid, out _))
            .WithMessage("شناسه امضاء نامعتبر است");

        RuleFor(x => x.ContractHtml)
            .NotEmpty()
            .WithMessage("محتوای قرارداد الزامی است")
            .MinimumLength(100)
            .WithMessage("محتوای قرارداد نامعتبر است");
    }
}

6️⃣ AcceptClubMembershipContractCommandHandler.cs

using CMSMicroservice.Protobuf;
using FrontOffice.BFF.Application.Common.Interfaces;
using MediatR;
using User.Protobuf;

namespace FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;

public class AcceptClubMembershipContractCommandHandler 
    : IRequestHandler<AcceptClubMembershipContractCommand, string?>
{
    private readonly IApplicationContractContext _context;
    private readonly ICurrentUserService _currentUserService;

    public AcceptClubMembershipContractCommandHandler(
        IApplicationContractContext context,
        ICurrentUserService currentUserService)
    {
        _context = context;
        _currentUserService = currentUserService;
    }

    public async Task<string?> Handle(
        AcceptClubMembershipContractCommand request, 
        CancellationToken cancellationToken)
    {
        var userId = _currentUserService.UserId 
            ?? throw new Exception("کاربر احراز هویت نشده است");

        // 1️⃣ فراخوانی CMS برای امضای قرارداد
        var cmsResponse = await _context.ClubMemberships.AcceptClubMembershipContractAsync(
            new AcceptClubMembershipContractRequest
            {
                UserId = userId,
                OtpCode = request.OtpCode,
                SignGuid = request.SignGuid,
                ContractHtml = request.ContractHtml
            },
            cancellationToken: cancellationToken);

        if (!cmsResponse.Success)
            throw new Exception(cmsResponse.Message ?? "خطا در امضای قرارداد");

        // 2️⃣ دریافت JWT Token جدید
        var tokenResponse = await _context.User.GetJwtTokenAsync(
            new GetJwtTokenRequest { Id = userId },
            cancellationToken: cancellationToken);

        return tokenResponse?.Token;
    }
}

نکات کلیدی:

  • دریافت UserId از ICurrentUserService
  • فراخوانی CMS.AcceptClubMembershipContract
  • Automatic Token Refresh بعد از موفقیت
  • Return کردن token جدید به Frontend

7️⃣ clubmembership.proto (BFF)

syntax = "proto3";

option csharp_namespace = "FrontOffice.BFF.ClubMembership.Protobuf";

package clubmembership;

service ClubMembership {
  // ... other RPCs ...
  
  rpc RequestClubContractOtp(RequestClubContractOtpRequest) 
      returns (RequestClubContractOtpResponse);
  
  rpc AcceptClubMembershipContract(AcceptClubMembershipContractRequest) 
      returns (AcceptClubMembershipContractResponse);
}

message RequestClubContractOtpRequest {
  string sign_guid = 1;
}

message RequestClubContractOtpResponse {
  bool success = 1;
  string message = 2;
}

message AcceptClubMembershipContractRequest {
  string otp_code = 1;
  string sign_guid = 2;
  string contract_html = 3;
}

message AcceptClubMembershipContractResponse {
  bool success = 1;
  string message = 2;
  string new_token = 3;  // JWT Token جدید
}

8️⃣ ClubMembershipGrpcService.cs

public override async Task<RequestClubContractOtpResponse> RequestClubContractOtp(
    RequestClubContractOtpRequest request,
    ServerCallContext context)
{
    try
    {
        var command = _mapper.Map<RequestClubContractOtpCommand>(request);
        var result = await _mediator.Send(command);

        return new RequestClubContractOtpResponse
        {
            Success = result,
            Message = result ? "کد تایید ارسال شد" : "خطا در ارسال کد تایید"
        };
    }
    catch (Exception ex)
    {
        return new RequestClubContractOtpResponse
        {
            Success = false,
            Message = ex.Message
        };
    }
}

public override async Task<AcceptClubMembershipContractResponse> AcceptClubMembershipContract(
    AcceptClubMembershipContractRequest request,
    ServerCallContext context)
{
    try
    {
        var command = _mapper.Map<AcceptClubMembershipContractCommand>(request);
        var newToken = await _mediator.Send(command);

        return new AcceptClubMembershipContractResponse
        {
            Success = true,
            Message = "قرارداد با موفقیت امضا شد",
            NewToken = newToken ?? string.Empty
        };
    }
    catch (Exception ex)
    {
        return new AcceptClubMembershipContractResponse
        {
            Success = false,
            Message = ex.Message,
            NewToken = string.Empty
        };
    }
}

9️⃣ ClubMembershipProfile.cs

using FrontOffice.BFF.Application.ClubMemberships.Commands.AcceptClubMembershipContract;
using FrontOffice.BFF.Application.ClubMemberships.Commands.RequestClubContractOtp;
using FrontOffice.BFF.ClubMembership.Protobuf;
using Mapster;

namespace FrontOffice.BFF.Application.Profiles;

public class ClubMembershipProfile : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        // ... other mappings ...

        config.NewConfig<RequestClubContractOtpRequest, RequestClubContractOtpCommand>()
            .Map(dest => dest.SignGuid, src => src.SignGuid);

        config.NewConfig<AcceptClubMembershipContractRequest, AcceptClubMembershipContractCommand>()
            .Map(dest => dest.OtpCode, src => src.OtpCode)
            .Map(dest => dest.SignGuid, src => src.SignGuid)
            .Map(dest => dest.ContractHtml, src => src.ContractHtml);
    }
}

🎨 Frontend Layer

📂 File Structure:

FrontOffice/
  src/FrontOffice.Main/
    Pages/Profile/
      Index.razor.cs                         ✅ Modified
    
    Components/Dialogs/
      ClubMembershipContractDialog.razor     ✅ Created

1️⃣ ClubMembershipContractDialog.razor

@using FrontOffice.BFF.ClubMembership.Protobuf
@inject ClubMembership.ClubMembershipClient ClubMembershipClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject ILocalStorageService LocalStorage
@implements IDisposable

<MudDialog>
    <DialogContent>
        <MudContainer MaxWidth="MaxWidth.Medium" Class="pa-4">
            @if (_currentStep == ContractStep.ReadContract)
            {
                <MudText Typo="Typo.h6" Class="mb-4">📜 قرارداد باشگاه مشتریان کارابازار</MudText>
                
                <MudPaper Class="pa-4 mb-4" Style="max-height: 400px; overflow-y: auto;">
                    @((MarkupString)GetClubContractHtml())
                </MudPaper>

                <MudAlert Severity="Severity.Info" Class="mb-4">
                    ⚠️ توجه: برای استفاده از امکانات باشگاه مشتریان و فعالسازی لینک دعوت، باید این قرارداد را امضا کنید.
                </MudAlert>

                <MudButton 
                    Variant="Variant.Filled" 
                    Color="Color.Primary" 
                    FullWidth="true"
                    OnClick="RequestOtp"
                    Disabled="_isLoading">
                    @if (_isLoading)
                    {
                        <MudProgressCircular Size="Size.Small" Indeterminate="true" />
                        <span class="ms-2">در حال ارسال...</span>
                    }
                    else
                    {
                        <span>✅ مطالعه کردم، درخواست کد تایید</span>
                    }
                </MudButton>
            }
            else if (_currentStep == ContractStep.EnterOtp)
            {
                <MudText Typo="Typo.h6" Class="mb-4">🔐 تایید امضای قرارداد</MudText>
                
                <MudAlert Severity="Severity.Success" Class="mb-4">
                    ✅ کد تایید به شماره موبایل شما ارسال شد.
                </MudAlert>

                <MudTextField 
                    @bind-Value="_otpCode"
                    Label="کد تایید (6 رقمی)"
                    Variant="Variant.Outlined"
                    MaxLength="6"
                    InputMode="InputMode.numeric"
                    HelperText="@($"زمان باقی‌مانده: {_remainingSeconds} ثانیه")"
                    Class="mb-4" />

                <MudButton 
                    Variant="Variant.Filled" 
                    Color="Color.Primary" 
                    FullWidth="true"
                    OnClick="AcceptContract"
                    Disabled="_isLoading || string.IsNullOrWhiteSpace(_otpCode) || _otpCode.Length != 6">
                    @if (_isLoading)
                    {
                        <MudProgressCircular Size="Size.Small" Indeterminate="true" />
                        <span class="ms-2">در حال تایید...</span>
                    }
                    else
                    {
                        <span>✍️ امضای قرارداد</span>
                    }
                </MudButton>

                <MudButton 
                    Variant="Variant.Text" 
                    Color="Color.Secondary" 
                    FullWidth="true"
                    Class="mt-2"
                    OnClick="RequestOtp"
                    Disabled="_isLoading || _remainingSeconds > 0">
                    🔄 ارسال مجدد کد
                </MudButton>
            }
            else if (_currentStep == ContractStep.Success)
            {
                <MudText Typo="Typo.h6" Class="mb-4 text-center">🎉 تبریک!</MudText>
                
                <MudAlert Severity="Severity.Success" Class="mb-4">
                    ✅ قرارداد با موفقیت امضا شد و باشگاه مشتریان شما فعال شد.
                    اکنون می‌توانید از لینک دعوت استفاده کنید.
                </MudAlert>

                <MudButton 
                    Variant="Variant.Filled" 
                    Color="Color.Primary" 
                    FullWidth="true"
                    OnClick="CloseAndRefresh">
                    ✅ متوجه شدم
                </MudButton>
            }
        </MudContainer>
    </DialogContent>
</MudDialog>

@code {
    [CascadingParameter] 
    private IMudDialogInstance MudDialog { get; set; } = null!;

    private enum ContractStep
    {
        ReadContract,
        EnterOtp,
        Success
    }

    private ContractStep _currentStep = ContractStep.ReadContract;
    private bool _isLoading;
    private string _signGuid = Guid.NewGuid().ToString();
    private string _otpCode = string.Empty;
    private int _remainingSeconds = 120;
    private System.Threading.Timer? _timer;

    private async Task RequestOtp()
    {
        _isLoading = true;
        try
        {
            var response = await ClubMembershipClient.RequestClubContractOtpAsync(
                new RequestClubContractOtpRequest { SignGuid = _signGuid });

            if (response.Success)
            {
                _currentStep = ContractStep.EnterOtp;
                _remainingSeconds = 120;
                StartTimer();
                Snackbar.Add("کد تایید ارسال شد", Severity.Success);
            }
            else
            {
                Snackbar.Add(response.Message ?? "خطا در ارسال کد تایید", Severity.Error);
            }
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _isLoading = false;
        }
    }

    private async Task AcceptContract()
    {
        _isLoading = true;
        try
        {
            var response = await ClubMembershipClient.AcceptClubMembershipContractAsync(
                new AcceptClubMembershipContractRequest
                {
                    OtpCode = _otpCode,
                    SignGuid = _signGuid,
                    ContractHtml = GetClubContractHtml()
                });

            if (response.Success)
            {
                // ذخیره token جدید
                if (!string.IsNullOrEmpty(response.NewToken))
                {
                    await LocalStorage.SetItemAsStringAsync("token", response.NewToken);
                }

                _currentStep = ContractStep.Success;
                StopTimer();
                Snackbar.Add("قرارداد با موفقیت امضا شد", Severity.Success);
            }
            else
            {
                Snackbar.Add(response.Message ?? "خطا در امضای قرارداد", Severity.Error);
            }
        }
        catch (Exception ex)
        {
            Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
        }
        finally
        {
            _isLoading = false;
        }
    }

    private void CloseAndRefresh()
    {
        // بستن modal و refresh صفحه برای نمایش لینک دعوت
        Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
    }

    private void StartTimer()
    {
        _timer = new System.Threading.Timer(_ =>
        {
            if (_remainingSeconds > 0)
            {
                _remainingSeconds--;
                InvokeAsync(StateHasChanged);
            }
            else
            {
                StopTimer();
            }
        }, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
    }

    private void StopTimer()
    {
        _timer?.Dispose();
        _timer = null;
    }

    public void Dispose()
    {
        StopTimer();
    }

    private string GetClubContractHtml()
    {
        return @"
            <div style='font-family: IRANSans, Tahoma; line-height: 1.8;'>
                <h3 style='text-align: center;'>قرارداد عضویت در باشگاه مشتریان کارابازار</h3>
                
                <p>این قرارداد بین کاربر محترم (عضو باشگاه) و شرکت کارابازار منعقد می‌گردد.</p>
                
                <h4>ماده 1: تعهدات شرکت</h4>
                <ul>
                    <li>ارائه خدمات باشگاه مشتریان طبق شرایط اعلام شده</li>
                    <li>امکان دعوت سایر کاربران از طریق لینک دعوت اختصاصی</li>
                    <li>دریافت کمیسیون از خریدهای زیرمجموعه‌ها</li>
                </ul>

                <h4>ماده 2: تعهدات کاربر</h4>
                <ul>
                    <li>رعایت قوانین و مقررات باشگاه مشتریان</li>
                    <li>عدم سوء استفاده از لینک دعوت</li>
                    <li>رعایت اصول اخلاقی در معرفی افراد</li>
                </ul>

                <h4>ماده 3: جزئیات مالی</h4>
                <ul>
                    <li>مبلغ پرداختی: 56,000,000 تومان</li>
                    <li>ارزش هدیه: 25,200,000 تومان (45% مبلغ پرداختی)</li>
                    <li>کل شارژ کیف پول: 56,000,000 تومان</li>
                </ul>

                <p style='margin-top: 20px;'>
                    <strong>با امضای این قرارداد، شما تمامی شرایط و ضوابط فوق را می‌پذیرید.</strong>
                </p>
            </div>
        ";
    }
}

نکات کلیدی:

  • استفاده از IMudDialogInstance (نه MudDialogInstance)
  • سه مرحله: ReadContract → EnterOtp → Success
  • Timer countdown برای OTP (120 ثانیه)
  • ذخیره token جدید در localStorage
  • Refresh صفحه بعد از موفقیت
  • HTML contract content در GetClubContractHtml()

2️⃣ Index.razor.cs (Profile Page)

private bool _hasPurchasedPackage;
private bool _isClubMemberActive;

private bool CanShowReferralLink => _hasPurchasedPackage && _isClubMemberActive;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender)
    {
        await LoadUserData();
        await CheckAndShowClubContractModal();
        StateHasChanged();
    }
}

private async Task CheckAndShowClubContractModal()
{
    // اگر کاربر پکیج خریده ولی قرارداد امضا نکرده
    if (_hasPurchasedPackage && !_isClubMemberActive)
    {
        var options = new DialogOptions
        {
            BackdropClick = false,      // غیرقابل بسته شدن با کلیک بیرون
            CloseOnEscapeKey = false,   // غیرقابل بسته شدن با Escape
            CloseButton = false,        // بدون دکمه Close
            MaxWidth = MaxWidth.Medium,
            FullWidth = true
        };

        await DialogService.ShowAsync<ClubMembershipContractDialog>("", options);
    }
}

نکات کلیدی:

  • BackdropClick = false (نه DisableBackdropClick)
  • CloseOnEscapeKey = false
  • CloseButton = false
  • فراخوانی در OnAfterRenderAsync

📱 OTP SMS Format

Message Template:

سلام {نام کاربر} عزیز
کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: {کد 6 رقمی}
شناسه امضاء: {GUID}
کارابازار

Real Example:

سلام علی احمدی عزیز
کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: 123456
شناسه امضاء: a1b2c3d4-e5f6-7890-abcd-ef1234567890
کارابازار

Code Implementation:

await _kavenegarService.Send(
    mobile: mobileNumber,
    new StringBuilder("سلام ")
        .Append(fullName)
        .AppendLine(" عزیز")
        .Append("کد یک بار مصرف برای تایید قرارداد باشگاه مشتریان: ")
        .AppendLine(otpResponse.Code)
        .AppendLine("شناسه امضاء: ")
        .AppendLine(request.SignGuid)
        .AppendLine("کارابازار")
        .ToString());

🔄 Token Refresh Pattern

چرا Token Refresh؟

بعد از امضای قرارداد، وضعیت کاربر تغییر می‌کند:

  • ClubMembership.IsActive از false به true تغییر می‌کند
  • JWT Token فعلی claim‌های قدیمی دارد
  • برای نمایش لینک دعوت، نیاز به token جدید با claim‌های به‌روز شده داریم

Flow:

1. AcceptContract موفق شد
   ↓
2. BFF.AcceptClubMembershipContractCommandHandler
   ├─ فراخوانی CMS.AcceptClubMembershipContract
   └─ فراخوانی CMS.GetJwtToken(userId) → token جدید
   ↓
3. Frontend دریافت token جدید
   └─ ذخیره در localStorage
   ↓
4. Refresh صفحه
   └─ لینک دعوت نمایش داده می‌شود

Code:

// BFF Handler
var tokenResponse = await _context.User.GetJwtTokenAsync(
    new GetJwtTokenRequest { Id = userId },
    cancellationToken: cancellationToken);

return tokenResponse?.Token;
// Frontend
if (!string.IsNullOrEmpty(response.NewToken))
{
    await LocalStorage.SetItemAsStringAsync("token", response.NewToken);
}

Navigation.NavigateTo(Navigation.Uri, forceLoad: true);

Testing Checklist

1️⃣ CMS Layer Tests:

  • AcceptClubMembershipContractCommandValidator validation rules
  • AcceptClubMembershipContractCommandHandler:
    • کاربر یافت نمی‌شود → Exception
    • PackagePurchaseMethod = None → Exception
    • ClubMembership.IsActive = true → Exception (جلوگیری از امضای مجدد)
    • OTP نامعتبر → Exception
    • OTP منقضی شده → Exception
    • امضای موفق → ClubMembership.IsActive = true
    • مقادیر صحیح: InitialContribution, GiftValue, ActivatedAt

2️⃣ BFF Layer Tests:

  • RequestClubContractOtpCommandHandler:
    • MobileNumber از CurrentUserService دریافت می‌شود
    • OTP از CMS دریافت می‌شود
    • پیامک با IKavenegarService ارسال می‌شود
    • SignGuid در پیامک موجود است
  • AcceptClubMembershipContractCommandHandler:
    • فراخوانی CMS موفق
    • Token جدید دریافت و return می‌شود

3️⃣ Frontend Tests:

  • Modal نمایش داده می‌شود وقتی HasPurchasedPackage && !IsClubMemberActive
  • Modal غیرقابل بسته شدن است (Escape, Backdrop Click, Close Button)
  • درخواست OTP موفق → مرحله EnterOtp
  • Timer countdown کار می‌کند (120 ثانیه)
  • امضای موفق → مرحله Success
  • Token refresh و reload صفحه
  • لینک دعوت نمایش داده می‌شود

4️⃣ Integration Tests:

  • End-to-End Flow: Payment → Modal → OTP → Sign → Refresh → Referral Link
  • پیامک واقعی ارسال می‌شود
  • Contract و UserContract در دیتابیس ثبت می‌شود
  • ClubMembership فعال می‌شود


📝 Notes

تغییرات مهم:

  1. ContractType.ClubMembership - نام تغییر کرد از CMS به ClubMembership
  2. IKavenegarService - الزامی برای ارسال پیامک در BFF
  3. IMudDialogInstance - type صحیح برای MudDialog
  4. BackdropClick - جایگزین DisableBackdropClick

نکات امنیتی:

  • OTP Verification قبل از امضا
  • جلوگیری از امضای مجدد (ClubMembership.IsActive check)
  • بررسی PackagePurchaseMethod (کاربر باید پکیج خریده باشد)
  • Token Refresh برای claims جدید

Known Issues:

  • هیچ موردی گزارش نشده

آخرین به‌روزرسانی: 2024-12-16
مستندساز: GitHub Copilot
وضعیت Build: All Green (CMS, BFF, Frontend)