feat: add referral management endpoints
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Identity;
|
||||
@@ -260,6 +261,247 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService
|
||||
return ToQrcodeItem(item);
|
||||
}
|
||||
|
||||
public async Task<ReferralStatsItem> GetStatsAsync(
|
||||
ReferralAdminActor actor,
|
||||
ReferralStatsQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var referrerUserId = query.ReferrerUserId ?? actor.UserId;
|
||||
await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken);
|
||||
return await BuildStatsAsync(actor.TenantId, referrerUserId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ReferralList<ReferralStatsItem>> GetSalesStatsAsync(
|
||||
ReferralAdminActor actor,
|
||||
ReferralStatsQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var limit = Math.Clamp(query.Limit ?? 100, 1, 500);
|
||||
var referrerIdsQuery = dbContext.ReferralLeads
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.ReferrerUserId != null);
|
||||
if (query.ReferrerUserId.HasValue)
|
||||
{
|
||||
referrerIdsQuery = referrerIdsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value);
|
||||
}
|
||||
|
||||
var referrerIds = await referrerIdsQuery
|
||||
.Select(item => item.ReferrerUserId!.Value)
|
||||
.Distinct()
|
||||
.Take(limit)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var stats = new List<ReferralStatsItem>(referrerIds.Length);
|
||||
foreach (var referrerId in referrerIds)
|
||||
{
|
||||
stats.Add(await BuildStatsAsync(actor.TenantId, referrerId, cancellationToken));
|
||||
}
|
||||
|
||||
return new ReferralList<ReferralStatsItem>(
|
||||
stats
|
||||
.OrderByDescending(item => item.PaidAmountCents)
|
||||
.ThenByDescending(item => item.LeadCount)
|
||||
.Take(limit)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<ReferralConversionReport> GetConversionReportAsync(
|
||||
ReferralAdminActor actor,
|
||||
ReferralConversionQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var endDate = query.EndDate ?? today;
|
||||
var startDate = query.StartDate ?? endDate.AddDays(-Math.Clamp(query.Days ?? 30, 1, 365) + 1);
|
||||
if (endDate < startDate)
|
||||
{
|
||||
throw new ReferralException("Referral date range was invalid.", "invalid_date_range");
|
||||
}
|
||||
|
||||
var start = startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var endExclusive = endDate.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var leadsQuery = dbContext.ReferralLeads
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.BoundAt >= start &&
|
||||
item.BoundAt < endExclusive);
|
||||
if (query.ReferrerUserId.HasValue)
|
||||
{
|
||||
leadsQuery = leadsQuery.Where(item => item.ReferrerUserId == query.ReferrerUserId.Value);
|
||||
}
|
||||
|
||||
var leads = await leadsQuery
|
||||
.OrderByDescending(item => item.BoundAt)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var paidStudentIds = await dbContext.Orders.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Status == OrderStatus.Paid &&
|
||||
item.UserId != null &&
|
||||
item.PaidAt != null &&
|
||||
item.PaidAt >= start &&
|
||||
item.PaidAt < endExclusive)
|
||||
.GroupBy(item => item.UserId!.Value)
|
||||
.Select(group => new
|
||||
{
|
||||
UserId = group.Key,
|
||||
AmountCents = group.Sum(item => item.AmountCents)
|
||||
})
|
||||
.ToDictionaryAsync(item => item.UserId, item => item.AmountCents, cancellationToken);
|
||||
var paidLeadCount = leads.Count(item => paidStudentIds.ContainsKey(item.StudentUserId));
|
||||
var paidAmountCents = leads.Sum(item => paidStudentIds.GetValueOrDefault(item.StudentUserId));
|
||||
var top = await GetSalesStatsAsync(
|
||||
actor,
|
||||
new ReferralStatsQuery(query.ReferrerUserId, query.Limit ?? 20),
|
||||
cancellationToken);
|
||||
|
||||
return new ReferralConversionReport(
|
||||
startDate,
|
||||
endDate,
|
||||
leads.Length,
|
||||
paidLeadCount,
|
||||
paidAmountCents,
|
||||
leads.Length == 0 ? 0m : Math.Round(paidLeadCount * 100m / leads.Length, 2),
|
||||
top.Items,
|
||||
leads
|
||||
.Where(item => !paidStudentIds.ContainsKey(item.StudentUserId))
|
||||
.Take(Math.Clamp(query.Limit ?? 20, 1, 100))
|
||||
.Select(item => ToLeadItem(item, false))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<ReferralList<ReferralLeadItem>> GetClientsAsync(
|
||||
ReferralAdminActor actor,
|
||||
ReferralStatsQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var referrerUserId = query.ReferrerUserId ?? actor.UserId;
|
||||
await AssertActiveMemberAsync(actor.TenantId, referrerUserId, cancellationToken);
|
||||
var leads = await dbContext.ReferralLeads
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.ReferrerUserId == referrerUserId)
|
||||
.OrderByDescending(item => item.BoundAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new ReferralList<ReferralLeadItem>(leads.Select(item => ToLeadItem(item, false)).ToArray());
|
||||
}
|
||||
|
||||
public async Task<ReferralBindResult> ManualBindAsync(
|
||||
ReferralAdminActor actor,
|
||||
ManualBindReferralCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
if (command.StudentUserId == command.ReferrerUserId)
|
||||
{
|
||||
throw new ReferralException("User cannot bind to own referral code.", "self_referral_not_allowed");
|
||||
}
|
||||
|
||||
var refCode = await dbContext.ReferralCodes
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == command.ReferrerUserId &&
|
||||
item.Status == ReferralCodeStatus.Active)
|
||||
.Select(item => item.Code)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (refCode is null)
|
||||
{
|
||||
refCode = (await GetOrCreateInviteCodeAsync(
|
||||
new ReferralActor(actor.TenantId, command.ReferrerUserId),
|
||||
new ReferralInviteCommand("manual"),
|
||||
cancellationToken)).InviteCode;
|
||||
}
|
||||
|
||||
var before = await dbContext.ReferralLeads
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.StudentUserId == command.StudentUserId,
|
||||
cancellationToken);
|
||||
var lead = await BindLeadCoreAsync(
|
||||
actor.TenantId,
|
||||
command.StudentUserId,
|
||||
command.ReferrerUserId,
|
||||
refCode,
|
||||
NormalizeChoice(command.Source, AllowedSources, "manual", "invalid_referral_source"),
|
||||
ReferralLeadBindType.Manual,
|
||||
command.Force,
|
||||
command.Metadata,
|
||||
cancellationToken);
|
||||
lead.AssignedBy = actor.UserId;
|
||||
lead.AssignedAt = DateTimeOffset.UtcNow;
|
||||
var crmQueue = before?.ReferrerUserId == lead.ReferrerUserId
|
||||
? null
|
||||
: await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.manual_bind", cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ReferralBindResult(ToLeadItem(lead, before?.ReferrerUserId != lead.ReferrerUserId), ToQueuePreview(crmQueue));
|
||||
}
|
||||
|
||||
public async Task<ReferralList<ReferralTeamItem>> GetTeamAsync(
|
||||
ReferralAdminActor actor,
|
||||
ReferralTeamQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
var edges = dbContext.ReferralTeamEdges
|
||||
.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.LeaderUserId.HasValue)
|
||||
{
|
||||
edges = edges.Where(item => item.LeaderUserId == query.LeaderUserId.Value);
|
||||
}
|
||||
|
||||
var items = await edges
|
||||
.OrderBy(item => item.RelationType)
|
||||
.ThenBy(item => item.MemberUserId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new ReferralList<ReferralTeamItem>(items.Select(ToTeamItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<ReferralTeamItem> UpsertTeamAsync(
|
||||
ReferralAdminActor actor,
|
||||
UpsertReferralTeamCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertAdminAsync(actor, cancellationToken);
|
||||
await AssertActiveMemberAsync(actor.TenantId, command.MemberUserId, cancellationToken);
|
||||
if (command.LeaderUserId.HasValue)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor.TenantId, command.LeaderUserId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
var relationType = ParseEnum(command.RelationType, ReferralTeamRelationType.SalesTeam, "invalid_referral_team_relation");
|
||||
var status = ParseEnum(command.Status, ReferralTeamEdgeStatus.Active, "invalid_referral_team_status");
|
||||
var edge = await dbContext.ReferralTeamEdges
|
||||
.FirstOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.MemberUserId == command.MemberUserId &&
|
||||
item.RelationType == relationType,
|
||||
cancellationToken);
|
||||
if (edge is null)
|
||||
{
|
||||
edge = new ReferralTeamEdge
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
MemberUserId = command.MemberUserId,
|
||||
RelationType = relationType
|
||||
};
|
||||
dbContext.ReferralTeamEdges.Add(edge);
|
||||
}
|
||||
|
||||
edge.LeaderUserId = command.LeaderUserId;
|
||||
edge.Status = status;
|
||||
edge.Metadata = command.Metadata ?? JsonDefaults.Object();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToTeamItem(edge);
|
||||
}
|
||||
|
||||
private async Task<ReferralLead> BindLeadCoreAsync(
|
||||
Guid tenantId,
|
||||
Guid studentUserId,
|
||||
@@ -388,6 +630,77 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(ReferralAdminActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var isAdmin = await dbContext.TenantMemberships.AnyAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Status == MembershipStatus.Active &&
|
||||
(item.Role == TenantRole.PlatformAdmin ||
|
||||
item.Role == TenantRole.TenantOwner ||
|
||||
item.Role == TenantRole.TenantAdmin),
|
||||
cancellationToken);
|
||||
if (!isAdmin)
|
||||
{
|
||||
throw new ReferralException("Referral admin access was denied.", "referral_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ReferralStatsItem> BuildStatsAsync(
|
||||
Guid tenantId,
|
||||
Guid referrerUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await dbContext.Users.AsNoTracking()
|
||||
.Where(item => item.Id == referrerUserId)
|
||||
.Select(item => new { item.Name, item.Username, item.Phone })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var membership = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.UserId == referrerUserId)
|
||||
.Select(item => item.Role)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var inviteCode = await dbContext.ReferralCodes.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.UserId == referrerUserId &&
|
||||
item.Status == ReferralCodeStatus.Active)
|
||||
.Select(item => item.Code)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var leads = await dbContext.ReferralLeads.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.ReferrerUserId == referrerUserId)
|
||||
.Select(item => item.StudentUserId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var paid = leads.Length == 0
|
||||
? []
|
||||
: await dbContext.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 dbContext.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));
|
||||
}
|
||||
|
||||
private async Task<string> GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 0; attempt < 20; attempt++)
|
||||
@@ -448,6 +761,26 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService
|
||||
: throw new ReferralException("Referral value was invalid.", errorCode);
|
||||
}
|
||||
|
||||
private 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);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
@@ -504,4 +837,15 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService
|
||||
{
|
||||
return item is null ? null : new CrmQueuePreviewItem(item.Id, item.Status.ToString(), item.Source);
|
||||
}
|
||||
|
||||
private static ReferralTeamItem ToTeamItem(ReferralTeamEdge item)
|
||||
{
|
||||
return new ReferralTeamItem(
|
||||
item.Id,
|
||||
item.MemberUserId,
|
||||
item.LeaderUserId,
|
||||
item.RelationType.ToString(),
|
||||
item.Status.ToString(),
|
||||
item.Metadata);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user