forked from gongxuegit/tiku-backend.net
feat: add referral management endpoints
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
|
|
||||||
@@ -101,3 +102,102 @@ public sealed class ReferralQrcodeDto
|
|||||||
return new ReferralQrcodeCommand(Page, Scene, Provider, QrcodeUrl, Metadata);
|
return new ReferralQrcodeCommand(Page, Scene, Provider, QrcodeUrl, Metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ReferralStatsQueryDto
|
||||||
|
{
|
||||||
|
public Guid? ReferrerUserId { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 500)]
|
||||||
|
public int? Limit { get; set; }
|
||||||
|
|
||||||
|
public ReferralStatsQuery ToQuery()
|
||||||
|
{
|
||||||
|
return new ReferralStatsQuery(ReferrerUserId, Limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReferralConversionQueryDto
|
||||||
|
{
|
||||||
|
public Guid? ReferrerUserId { get; set; }
|
||||||
|
|
||||||
|
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
|
||||||
|
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||||
|
public string? EndDate { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 365)]
|
||||||
|
public int? Days { get; set; }
|
||||||
|
|
||||||
|
[Range(1, 100)]
|
||||||
|
public int? Limit { get; set; }
|
||||||
|
|
||||||
|
public ReferralConversionQuery ToQuery()
|
||||||
|
{
|
||||||
|
return new ReferralConversionQuery(
|
||||||
|
ReferrerUserId,
|
||||||
|
ParseDate(StartDate),
|
||||||
|
ParseDate(EndDate),
|
||||||
|
Days,
|
||||||
|
Limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateOnly? ParseDate(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? null
|
||||||
|
: DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManualBindReferralDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid StudentUserId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public Guid ReferrerUserId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Source { get; set; }
|
||||||
|
|
||||||
|
public bool Force { get; set; }
|
||||||
|
|
||||||
|
public JsonElement? Metadata { get; set; }
|
||||||
|
|
||||||
|
public ManualBindReferralCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new ManualBindReferralCommand(StudentUserId, ReferrerUserId, Source, Force, Metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ReferralTeamQueryDto
|
||||||
|
{
|
||||||
|
public Guid? LeaderUserId { get; set; }
|
||||||
|
|
||||||
|
public ReferralTeamQuery ToQuery()
|
||||||
|
{
|
||||||
|
return new ReferralTeamQuery(LeaderUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpsertReferralTeamDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid MemberUserId { get; set; }
|
||||||
|
|
||||||
|
public Guid? LeaderUserId { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? RelationType { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? Status { get; set; }
|
||||||
|
|
||||||
|
public JsonElement? Metadata { get; set; }
|
||||||
|
|
||||||
|
public UpsertReferralTeamCommand ToCommand()
|
||||||
|
{
|
||||||
|
return new UpsertReferralTeamCommand(MemberUserId, LeaderUserId, RelationType, Status, Metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,6 +92,104 @@ public sealed class ReferralController(
|
|||||||
cancellationToken));
|
cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("stats")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("查询推荐人个人统计")]
|
||||||
|
[ProducesResponseType<ReferralStatsItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralStatsItem>> Stats(
|
||||||
|
[FromQuery] ReferralStatsQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.GetStatsAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("sales-stats")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("查询销售推荐统计排行")]
|
||||||
|
[ProducesResponseType<ReferralList<ReferralStatsItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralList<ReferralStatsItem>>> SalesStats(
|
||||||
|
[FromQuery] ReferralStatsQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.GetSalesStatsAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("conversion-report")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("查询推荐转化报告")]
|
||||||
|
[ProducesResponseType<ReferralConversionReport>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralConversionReport>> ConversionReport(
|
||||||
|
[FromQuery] ReferralConversionQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.GetConversionReportAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("sales-clients")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("查询推荐人名下客户")]
|
||||||
|
[ProducesResponseType<ReferralList<ReferralLeadItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralList<ReferralLeadItem>>> SalesClients(
|
||||||
|
[FromQuery] ReferralStatsQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.GetClientsAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("manual-bind")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("人工调整学生推荐归属")]
|
||||||
|
[ProducesResponseType<ReferralBindResult>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralBindResult>> ManualBind(
|
||||||
|
ManualBindReferralDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.ManualBindAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("team")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("查询推荐团队成员")]
|
||||||
|
[ProducesResponseType<ReferralList<ReferralTeamItem>>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralList<ReferralTeamItem>>> Team(
|
||||||
|
[FromQuery] ReferralTeamQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.GetTeamAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
query.ToQuery(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("team")]
|
||||||
|
[Authorize(Policy = TikuPolicies.TenantAdmin)]
|
||||||
|
[EndpointSummary("新增或更新推荐团队关系")]
|
||||||
|
[ProducesResponseType<ReferralTeamItem>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ReferralTeamItem>> UpsertTeam(
|
||||||
|
UpsertReferralTeamDto request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await referralService.UpsertTeamAsync(
|
||||||
|
ResolveAdminActor(),
|
||||||
|
request.ToCommand(),
|
||||||
|
cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
private ReferralActor ResolveUserActor()
|
private ReferralActor ResolveUserActor()
|
||||||
{
|
{
|
||||||
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
@@ -102,6 +200,16 @@ public sealed class ReferralController(
|
|||||||
return new ReferralActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
return new ReferralActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ReferralAdminActor ResolveAdminActor()
|
||||||
|
{
|
||||||
|
if (currentTenant.TenantId is null || currentUser.UserId is null)
|
||||||
|
{
|
||||||
|
throw new ReferralException("Referral admin actor was not resolved.", "referral_access_denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ReferralAdminActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (currentTenant.TenantId.HasValue)
|
if (currentTenant.TenantId.HasValue)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace Tiku.Application.Growth;
|
|||||||
|
|
||||||
public sealed record ReferralActor(Guid TenantId, Guid? UserId = null);
|
public sealed record ReferralActor(Guid TenantId, Guid? UserId = null);
|
||||||
|
|
||||||
|
public sealed record ReferralAdminActor(Guid TenantId, Guid UserId);
|
||||||
|
|
||||||
public sealed record ReferralInviteCommand(string? Channel = null, string? LandingPath = null);
|
public sealed record ReferralInviteCommand(string? Channel = null, string? LandingPath = null);
|
||||||
|
|
||||||
public sealed record ResolveReferralCommand(string? Code);
|
public sealed record ResolveReferralCommand(string? Code);
|
||||||
@@ -27,6 +29,26 @@ public sealed record ReferralQrcodeCommand(
|
|||||||
string? QrcodeUrl,
|
string? QrcodeUrl,
|
||||||
JsonElement? Metadata);
|
JsonElement? Metadata);
|
||||||
|
|
||||||
|
public sealed record ReferralStatsQuery(Guid? ReferrerUserId = null, int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record ReferralConversionQuery(Guid? ReferrerUserId = null, DateOnly? StartDate = null, DateOnly? EndDate = null, int? Days = null, int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record ManualBindReferralCommand(
|
||||||
|
Guid StudentUserId,
|
||||||
|
Guid ReferrerUserId,
|
||||||
|
string? Source,
|
||||||
|
bool Force,
|
||||||
|
JsonElement? Metadata);
|
||||||
|
|
||||||
|
public sealed record ReferralTeamQuery(Guid? LeaderUserId = null);
|
||||||
|
|
||||||
|
public sealed record UpsertReferralTeamCommand(
|
||||||
|
Guid MemberUserId,
|
||||||
|
Guid? LeaderUserId,
|
||||||
|
string? RelationType,
|
||||||
|
string? Status,
|
||||||
|
JsonElement? Metadata);
|
||||||
|
|
||||||
public sealed record ReferralInviteItem(string InviteCode);
|
public sealed record ReferralInviteItem(string InviteCode);
|
||||||
|
|
||||||
public sealed record ReferralResolutionItem(
|
public sealed record ReferralResolutionItem(
|
||||||
@@ -70,6 +92,37 @@ public sealed record ReferralBindResult(ReferralLeadItem Lead, CrmQueuePreviewIt
|
|||||||
|
|
||||||
public sealed record CrmQueuePreviewItem(Guid Id, string Status, string? Source);
|
public sealed record CrmQueuePreviewItem(Guid Id, string Status, string? Source);
|
||||||
|
|
||||||
|
public sealed record ReferralStatsItem(
|
||||||
|
Guid ReferrerUserId,
|
||||||
|
string? Name,
|
||||||
|
string Role,
|
||||||
|
string? InviteCode,
|
||||||
|
int LeadCount,
|
||||||
|
int PaidLeadCount,
|
||||||
|
int PaidAmountCents,
|
||||||
|
int TrackCount,
|
||||||
|
decimal ConversionRate);
|
||||||
|
|
||||||
|
public sealed record ReferralList<T>(IReadOnlyCollection<T> Items);
|
||||||
|
|
||||||
|
public sealed record ReferralConversionReport(
|
||||||
|
DateOnly StartDate,
|
||||||
|
DateOnly EndDate,
|
||||||
|
int LeadCount,
|
||||||
|
int PaidLeadCount,
|
||||||
|
int PaidAmountCents,
|
||||||
|
decimal ConversionRate,
|
||||||
|
IReadOnlyCollection<ReferralStatsItem> TopReferrers,
|
||||||
|
IReadOnlyCollection<ReferralLeadItem> RecentUnconvertedLeads);
|
||||||
|
|
||||||
|
public sealed record ReferralTeamItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid MemberUserId,
|
||||||
|
Guid? LeaderUserId,
|
||||||
|
string RelationType,
|
||||||
|
string Status,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
public interface IReferralService
|
public interface IReferralService
|
||||||
{
|
{
|
||||||
Task<ReferralInviteItem> GetOrCreateInviteCodeAsync(
|
Task<ReferralInviteItem> GetOrCreateInviteCodeAsync(
|
||||||
@@ -98,6 +151,41 @@ public interface IReferralService
|
|||||||
ReferralActor actor,
|
ReferralActor actor,
|
||||||
ReferralQrcodeCommand command,
|
ReferralQrcodeCommand command,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralStatsItem> GetStatsAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
ReferralStatsQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralList<ReferralStatsItem>> GetSalesStatsAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
ReferralStatsQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralConversionReport> GetConversionReportAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
ReferralConversionQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralList<ReferralLeadItem>> GetClientsAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
ReferralStatsQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralBindResult> ManualBindAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
ManualBindReferralCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralList<ReferralTeamItem>> GetTeamAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
ReferralTeamQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ReferralTeamItem> UpsertTeamAsync(
|
||||||
|
ReferralAdminActor actor,
|
||||||
|
UpsertReferralTeamCommand command,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReferralException(string message, string code) : Exception(message)
|
public sealed class ReferralException(string message, string code) : Exception(message)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Security.Cryptography;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Growth;
|
using Tiku.Domain.Growth;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
@@ -260,6 +261,247 @@ public sealed class ReferralService(TikuDbContext dbContext) : IReferralService
|
|||||||
return ToQrcodeItem(item);
|
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(
|
private async Task<ReferralLead> BindLeadCoreAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
Guid studentUserId,
|
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)
|
private async Task<string> GenerateUniqueCodeAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
for (var attempt = 0; attempt < 20; attempt++)
|
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);
|
: 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)
|
private static string? NormalizeOptional(string? value)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Growth;
|
using Tiku.Application.Growth;
|
||||||
|
using Tiku.Domain.Commerce;
|
||||||
using Tiku.Domain.Growth;
|
using Tiku.Domain.Growth;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -131,6 +132,77 @@ public sealed class ReferralEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Non_admin_cannot_access_referral_management()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var seed = await SeedReferralAsync(factory, includeCode: true);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed.Student);
|
||||||
|
|
||||||
|
var response = await client.GetAsync("/api/referral/stats");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_manage_referral_team_and_query_stats()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
var seed = await SeedReferralAsync(factory, includeCode: true);
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client, seed.Admin);
|
||||||
|
|
||||||
|
var manualBind = await client.PostAsJsonAsync(
|
||||||
|
"/api/referral/manual-bind",
|
||||||
|
new ManualBindReferralDto
|
||||||
|
{
|
||||||
|
StudentUserId = seed.Student.UserId,
|
||||||
|
ReferrerUserId = seed.Referrer.UserId,
|
||||||
|
Source = "manual",
|
||||||
|
Force = true
|
||||||
|
});
|
||||||
|
var team = await client.PutAsJsonAsync(
|
||||||
|
"/api/referral/team",
|
||||||
|
new UpsertReferralTeamDto
|
||||||
|
{
|
||||||
|
MemberUserId = seed.Referrer.UserId,
|
||||||
|
LeaderUserId = seed.Admin.UserId,
|
||||||
|
RelationType = "sales_team",
|
||||||
|
Status = "active"
|
||||||
|
});
|
||||||
|
await factory.SeedAsync(new Order
|
||||||
|
{
|
||||||
|
TenantId = seed.TenantId,
|
||||||
|
UserId = seed.Student.UserId,
|
||||||
|
OrderNo = "R202607260001",
|
||||||
|
Status = OrderStatus.Paid,
|
||||||
|
ProductType = "svip",
|
||||||
|
ProductName = "SVIP",
|
||||||
|
AmountCents = 9900,
|
||||||
|
PaidAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
var stats = await client.GetAsync($"/api/referral/stats?referrerUserId={seed.Referrer.UserId}");
|
||||||
|
var salesStats = await client.GetAsync("/api/referral/sales-stats");
|
||||||
|
var conversion = await client.GetAsync("/api/referral/conversion-report?days=30");
|
||||||
|
var clients = await client.GetAsync($"/api/referral/sales-clients?referrerUserId={seed.Referrer.UserId}");
|
||||||
|
var teamList = await client.GetAsync($"/api/referral/team?leaderUserId={seed.Admin.UserId}");
|
||||||
|
var statsItem = await stats.Content.ReadFromJsonAsync<ReferralStatsItem>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, manualBind.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, team.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, stats.StatusCode);
|
||||||
|
Assert.Equal(1, statsItem!.LeadCount);
|
||||||
|
Assert.Equal(1, statsItem.PaidLeadCount);
|
||||||
|
Assert.Equal(9900, statsItem.PaidAmountCents);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, salesStats.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, conversion.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, clients.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, teamList.StatusCode);
|
||||||
|
Assert.Contains(seed.Referrer.UserId.ToString(), await teamList.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<ReferralSeed> SeedReferralAsync(
|
private static async Task<ReferralSeed> SeedReferralAsync(
|
||||||
ApiTestFactory factory,
|
ApiTestFactory factory,
|
||||||
bool includeCode = false)
|
bool includeCode = false)
|
||||||
@@ -139,14 +211,17 @@ public sealed class ReferralEndpointTests
|
|||||||
var tenantCode = tenantId.ToString("N");
|
var tenantCode = tenantId.ToString("N");
|
||||||
var referrer = new LoginSeed(tenantId, Guid.NewGuid(), "13800001001");
|
var referrer = new LoginSeed(tenantId, Guid.NewGuid(), "13800001001");
|
||||||
var student = new LoginSeed(tenantId, Guid.NewGuid(), "13800001002");
|
var student = new LoginSeed(tenantId, Guid.NewGuid(), "13800001002");
|
||||||
|
var admin = new LoginSeed(tenantId, Guid.NewGuid(), "13800001003");
|
||||||
const string referralCode = "ABC12345";
|
const string referralCode = "ABC12345";
|
||||||
var entities = new List<object>
|
var entities = new List<object>
|
||||||
{
|
{
|
||||||
new Tenant { Id = tenantId, Slug = tenantCode, Name = "Referral Tenant" },
|
new Tenant { Id = tenantId, Slug = tenantCode, Name = "Referral Tenant" },
|
||||||
User(referrer.UserId, referrer.Phone, "Referral Teacher", "teacher"),
|
User(referrer.UserId, referrer.Phone, "Referral Teacher", "teacher"),
|
||||||
User(student.UserId, student.Phone, "Referral Student", "student"),
|
User(student.UserId, student.Phone, "Referral Student", "student"),
|
||||||
|
User(admin.UserId, admin.Phone, "Referral Admin", "tenant_admin"),
|
||||||
Identity(referrer),
|
Identity(referrer),
|
||||||
Identity(student),
|
Identity(student),
|
||||||
|
Identity(admin),
|
||||||
new TenantMembership
|
new TenantMembership
|
||||||
{
|
{
|
||||||
TenantId = tenantId,
|
TenantId = tenantId,
|
||||||
@@ -160,6 +235,13 @@ public sealed class ReferralEndpointTests
|
|||||||
UserId = student.UserId,
|
UserId = student.UserId,
|
||||||
Role = TenantRole.Student,
|
Role = TenantRole.Student,
|
||||||
Status = MembershipStatus.Active
|
Status = MembershipStatus.Active
|
||||||
|
},
|
||||||
|
new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = admin.UserId,
|
||||||
|
Role = TenantRole.TenantAdmin,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (includeCode)
|
if (includeCode)
|
||||||
@@ -174,7 +256,7 @@ public sealed class ReferralEndpointTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
await factory.SeedAsync(entities.ToArray());
|
await factory.SeedAsync(entities.ToArray());
|
||||||
return new ReferralSeed(tenantId, tenantCode, referrer, student, referralCode);
|
return new ReferralSeed(tenantId, tenantCode, referrer, student, admin, referralCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static User User(Guid userId, string phone, string name, string role)
|
private static User User(Guid userId, string phone, string name, string role)
|
||||||
@@ -233,5 +315,6 @@ public sealed class ReferralEndpointTests
|
|||||||
string TenantCode,
|
string TenantCode,
|
||||||
LoginSeed Referrer,
|
LoginSeed Referrer,
|
||||||
LoginSeed Student,
|
LoginSeed Student,
|
||||||
|
LoginSeed Admin,
|
||||||
string ReferralCode);
|
string ReferralCode);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user