331 lines
12 KiB
C#
331 lines
12 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Growth;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Commerce;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Growth;
|
|
using Tiku.Domain.Tenancy;
|
|
|
|
namespace Tiku.Infrastructure.Growth;
|
|
|
|
internal abstract partial class ReferralServiceBase
|
|
{
|
|
protected async Task<ReferralLead> BindLeadCoreAsync(
|
|
Guid tenantId,
|
|
Guid studentUserId,
|
|
Guid referrerUserId,
|
|
string refCode,
|
|
string source,
|
|
ReferralLeadBindType bindType,
|
|
bool force,
|
|
JsonElement? metadata,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await AssertActiveMemberAsync(tenantId, studentUserId, cancellationToken);
|
|
await AssertActiveMemberAsync(tenantId, referrerUserId, cancellationToken);
|
|
var now = DateTimeOffset.UtcNow;
|
|
var lead = await growthPersistence.ReferralLeads
|
|
.FirstOrDefaultAsync(item =>
|
|
item.TenantId == tenantId &&
|
|
item.StudentUserId == studentUserId,
|
|
cancellationToken);
|
|
if (lead is not null)
|
|
{
|
|
if (lead.ReferrerUserId == referrerUserId) return lead;
|
|
|
|
if (!force && lead.Status == ReferralLeadStatus.Protected &&
|
|
(lead.ProtectedUntil is null || lead.ProtectedUntil > now))
|
|
throw new ReferralException("Referral lead is protected and cannot be rebound.",
|
|
"referral_lead_protected");
|
|
}
|
|
else
|
|
{
|
|
lead = new ReferralLead
|
|
{
|
|
TenantId = tenantId,
|
|
StudentUserId = studentUserId
|
|
};
|
|
growthPersistence.ReferralLeads.Add(lead);
|
|
}
|
|
|
|
lead.ReferrerUserId = referrerUserId;
|
|
lead.RefCode = refCode;
|
|
lead.Source = source;
|
|
lead.BindType = bindType;
|
|
lead.Status = ReferralLeadStatus.Protected;
|
|
lead.ProtectedUntil = now.AddDays(30);
|
|
lead.BoundAt = now;
|
|
lead.Metadata = metadata ?? JsonDefaults.Object();
|
|
return lead;
|
|
}
|
|
|
|
protected async Task<CrmWebhookQueueItem?> EnqueueCrmIfEnabledAsync(
|
|
Guid tenantId,
|
|
ReferralLead lead,
|
|
string source,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var config = await growthPersistence.CrmConfigs
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.Enabled, cancellationToken);
|
|
if (config is null || string.IsNullOrWhiteSpace(config.Url)) return null;
|
|
|
|
var recordId = lead.Id.ToString("N", CultureInfo.InvariantCulture);
|
|
var idempotencyKey = $"{source}:{recordId}";
|
|
var existing = await growthPersistence.CrmWebhookQueue
|
|
.FirstOrDefaultAsync(item => item.TenantId == tenantId && item.IdempotencyKey == idempotencyKey,
|
|
cancellationToken);
|
|
if (existing is not null) return existing;
|
|
|
|
var queue = new CrmWebhookQueueItem
|
|
{
|
|
TenantId = tenantId,
|
|
RecordId = recordId,
|
|
LeadId = recordId,
|
|
Source = source,
|
|
Provider = "webhook",
|
|
Status = CrmWebhookQueueStatus.Pending,
|
|
ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(config.DelaySeconds ?? 0, 0)),
|
|
IdempotencyKey = idempotencyKey,
|
|
TargetUrl = config.Url,
|
|
Payload = JsonSerializer.SerializeToElement(new
|
|
{
|
|
tenantId,
|
|
leadId = lead.Id,
|
|
lead.StudentUserId,
|
|
lead.ReferrerUserId,
|
|
lead.RefCode,
|
|
lead.Source,
|
|
lead.BoundAt,
|
|
config.FormName,
|
|
config.ExamType
|
|
})
|
|
};
|
|
growthPersistence.CrmWebhookQueue.Add(queue);
|
|
return queue;
|
|
}
|
|
|
|
protected async Task<ReferralCode?> ResolveCodeCoreAsync(Guid tenantId, string code,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await growthPersistence.ReferralCodes
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(item =>
|
|
item.TenantId == tenantId &&
|
|
item.Code == code &&
|
|
item.Status == ReferralCodeStatus.Active,
|
|
cancellationToken);
|
|
}
|
|
|
|
protected async Task AssertActiveMemberAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await identityPersistence.TenantMemberships.AnyAsync(
|
|
item =>
|
|
item.TenantId == tenantId &&
|
|
item.UserId == userId &&
|
|
item.Status == MembershipStatus.Active,
|
|
cancellationToken);
|
|
if (!exists) throw new ReferralException("Tenant member was not found.", "tenant_access_denied");
|
|
}
|
|
|
|
protected async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
|
|
{
|
|
var access = await currentAccessContext.GetAsync(cancellationToken);
|
|
if (!access.IsCurrentTenantMember ||
|
|
access.TenantId != actor.TenantId ||
|
|
access.UserId != actor.UserId ||
|
|
!access.HasTenantPermission(BackendPermissions.TenantCrmManage))
|
|
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
|
|
}
|
|
|
|
protected async Task<ReferralStatsItem> BuildStatsAsync(
|
|
Guid tenantId,
|
|
Guid referrerUserId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await identityPersistence.Users.AsNoTracking()
|
|
.Where(item => item.Id == referrerUserId)
|
|
.Select(item => new { item.Name, item.UserName, item.Phone })
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
var membership = await identityPersistence.TenantMemberships.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && item.UserId == referrerUserId)
|
|
.Select(item => item.Role)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
var inviteCode = await growthPersistence.ReferralCodes.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == tenantId &&
|
|
item.UserId == referrerUserId &&
|
|
item.Status == ReferralCodeStatus.Active)
|
|
.Select(item => item.Code)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
var leads = await growthPersistence.ReferralLeads.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId)
|
|
.Select(item => item.StudentUserId)
|
|
.ToArrayAsync(cancellationToken);
|
|
var paid = leads.Length == 0
|
|
? []
|
|
: await commercePersistence.Orders.AsNoTracking()
|
|
.Where(item =>
|
|
item.TenantId == tenantId &&
|
|
item.Status == OrderStatus.Paid &&
|
|
item.UserId != null &&
|
|
leads.Contains(item.UserId.Value))
|
|
.GroupBy(item => item.UserId!.Value)
|
|
.Select(group => new
|
|
{
|
|
UserId = group.Key,
|
|
AmountCents = group.Sum(item => item.AmountCents)
|
|
})
|
|
.ToArrayAsync(cancellationToken);
|
|
var trackCount = await growthPersistence.ReferralTracks.AsNoTracking()
|
|
.CountAsync(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId, cancellationToken);
|
|
|
|
return new ReferralStatsItem(
|
|
referrerUserId,
|
|
FirstNonBlank(user?.Name, user?.UserName, user?.Phone),
|
|
membership.ToString(),
|
|
inviteCode,
|
|
leads.Length,
|
|
paid.Length,
|
|
paid.Sum(item => item.AmountCents),
|
|
trackCount,
|
|
leads.Length == 0 ? 0m : Math.Round(paid.Length * 100m / leads.Length, 2));
|
|
}
|
|
|
|
protected async Task<string> GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken)
|
|
{
|
|
for (var attempt = 0; attempt < 20; attempt++)
|
|
{
|
|
var code = GenerateCode();
|
|
var exists = await growthPersistence.ReferralCodes.AnyAsync(
|
|
item => item.TenantId == tenantId && item.Code == code,
|
|
cancellationToken);
|
|
if (!exists) return code;
|
|
}
|
|
|
|
throw new ReferralException("Could not generate referral code.", "referral_code_generation_failed");
|
|
}
|
|
|
|
protected static string GenerateCode()
|
|
{
|
|
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
Span<byte> bytes = stackalloc byte[8];
|
|
RandomNumberGenerator.Fill(bytes);
|
|
Span<char> chars = stackalloc char[8];
|
|
for (var index = 0; index < chars.Length; index++) chars[index] = alphabet[bytes[index] % alphabet.Length];
|
|
|
|
return new string(chars);
|
|
}
|
|
|
|
protected static Guid RequireUser(ReferralActor actor)
|
|
{
|
|
return actor.UserId ??
|
|
throw new ReferralException("Current referral actor was not resolved.", "referral_access_denied");
|
|
}
|
|
|
|
protected static string? NormalizeCode(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value)
|
|
? null
|
|
: value.Trim().ToUpperInvariant();
|
|
}
|
|
|
|
protected static string NormalizeChoice(
|
|
string? value,
|
|
HashSet<string> allowed,
|
|
string defaultValue,
|
|
string errorCode)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
|
|
|
var normalized = value.Trim().ToLowerInvariant();
|
|
return allowed.Contains(normalized)
|
|
? normalized
|
|
: throw new ReferralException("Referral value was invalid.", errorCode);
|
|
}
|
|
|
|
protected static TEnum ParseEnum<TEnum>(string? value, TEnum defaultValue, string errorCode)
|
|
where TEnum : struct, Enum
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return defaultValue;
|
|
|
|
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal);
|
|
foreach (var enumValue in Enum.GetValues<TEnum>())
|
|
if (string.Equals(enumValue.ToString(), normalized, StringComparison.OrdinalIgnoreCase))
|
|
return enumValue;
|
|
|
|
throw new ReferralException("Referral enum value was invalid.", errorCode);
|
|
}
|
|
|
|
protected static string? NormalizeOptional(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
protected static string? Truncate(string? value, int maxLength)
|
|
{
|
|
return value is null || value.Length <= maxLength ? value : value[..maxLength];
|
|
}
|
|
|
|
protected static string? FirstNonBlank(params string?[] values)
|
|
{
|
|
return values.Select(NormalizeOptional).FirstOrDefault(value => value is not null);
|
|
}
|
|
|
|
protected static ReferralTrackItem ToTrackItem(ReferralTrack item)
|
|
{
|
|
return new ReferralTrackItem(
|
|
item.Id,
|
|
item.EventType,
|
|
item.RefCode,
|
|
item.ReferrerUserId,
|
|
item.TargetUserId,
|
|
item.Source,
|
|
item.CreatedAt);
|
|
}
|
|
|
|
protected static ReferralLeadItem ToLeadItem(ReferralLead item, bool changed)
|
|
{
|
|
return new ReferralLeadItem(
|
|
item.Id,
|
|
item.StudentUserId,
|
|
item.ReferrerUserId,
|
|
item.RefCode,
|
|
item.Status.ToString(),
|
|
item.BoundAt,
|
|
changed);
|
|
}
|
|
|
|
protected static ReferralQrcodeItem ToQrcodeItem(ReferralQrcode item)
|
|
{
|
|
return new ReferralQrcodeItem(
|
|
item.Id,
|
|
item.RefCode,
|
|
item.Scene,
|
|
item.Page,
|
|
item.Provider,
|
|
item.QrcodeUrl,
|
|
item.Status.ToString(),
|
|
item.Metadata);
|
|
}
|
|
|
|
protected static CrmQueuePreviewItem? ToQueuePreview(CrmWebhookQueueItem? item)
|
|
{
|
|
return item is null ? null : new CrmQueuePreviewItem(item.Id, item.Status.ToString(), item.Source);
|
|
}
|
|
|
|
protected static ReferralTeamItem ToTeamItem(ReferralTeamEdge item)
|
|
{
|
|
return new ReferralTeamItem(
|
|
item.Id,
|
|
item.MemberUserId,
|
|
item.LeaderUserId,
|
|
item.RelationType.ToString(),
|
|
item.Status.ToString(),
|
|
item.Metadata);
|
|
}
|
|
}
|