Files
tiku-backend.net/Tiku.Infrastructure/Profile/ProfileService.cs

687 lines
27 KiB
C#

using System.Text.Json;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Profile;
using Tiku.Domain.Catalog;
using Tiku.Domain.Commerce;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Profile;
public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
{
private static readonly HashSet<string> AllowedAvatarPresets = new(StringComparer.OrdinalIgnoreCase)
{
"male",
"female"
};
public async Task<StudentProfileItem> GetMeAsync(
ProfileActor actor,
ProfileQuery query,
CancellationToken cancellationToken = default)
{
var profile = await EnsureProfileAsync(actor, cancellationToken);
return await BuildProfileItemAsync(actor, profile, query.RecentLimit, cancellationToken);
}
public async Task<StudentProfileItem> UpdateMeAsync(
ProfileActor actor,
UpdateProfileCommand command,
CancellationToken cancellationToken = default)
{
var profile = await EnsureProfileAsync(actor, cancellationToken);
var user = await dbContext.Users.FindAsync([actor.UserId], cancellationToken)
?? throw new ProfileException("Current user was not found.", "profile_user_not_found");
if (!string.IsNullOrWhiteSpace(command.Name))
{
user.Name = command.Name.Trim();
}
if (command.AvatarPreset is not null)
{
var avatarPreset = command.AvatarPreset.Trim();
if (!AllowedAvatarPresets.Contains(avatarPreset))
{
throw new ProfileException("Avatar preset must be male or female.", "invalid_avatar_preset");
}
profile.AvatarPreset = avatarPreset.ToLowerInvariant();
}
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
await AssertReferenceAsync<School>(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken);
await AssertReferenceAsync<Major>(actor.TenantId, command.SelectedMajorId, "major_not_found", cancellationToken);
profile.RegionId = command.RegionId ?? profile.RegionId;
profile.SelectedSchoolId = command.SelectedSchoolId ?? profile.SelectedSchoolId;
profile.SelectedMajorId = command.SelectedMajorId ?? profile.SelectedMajorId;
if (command.Stats.HasValue)
{
profile.Stats = JsonObjectOrDefault(command.Stats.Value);
}
if (command.Progress.HasValue)
{
profile.Progress = JsonObjectOrDefault(command.Progress.Value);
}
if (command.ModuleSelections.HasValue)
{
profile.ModuleSelections = JsonObjectOrDefault(command.ModuleSelections.Value);
}
if (command.RecentActivities.HasValue)
{
profile.RecentActivities = JsonArrayOrDefault(command.RecentActivities.Value);
}
await dbContext.SaveChangesAsync(cancellationToken);
return await BuildProfileItemAsync(actor, profile, null, cancellationToken);
}
public async Task<ExamCountdownList> GetExamCountdownsAsync(
ProfileActor actor,
ProfileQuery query,
CancellationToken cancellationToken = default)
{
var profile = await EnsureProfileAsync(actor, cancellationToken);
var limit = Math.Clamp(query.Limit ?? 5, 1, 20);
var items = await dbContext.ExamDates.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.IsActive &&
(profile.RegionId == null || item.RegionId == null || item.RegionId == profile.RegionId) &&
(profile.SelectedSchoolId == null || item.SchoolId == null || item.SchoolId == profile.SelectedSchoolId))
.OrderBy(item => item.ExamAt == null)
.ThenBy(item => item.ExamAt)
.ThenBy(item => item.SortOrder)
.Take(limit)
.ToArrayAsync(cancellationToken);
var today = DateTimeOffset.UtcNow.Date;
return new ExamCountdownList(
items.Select(item => new ExamCountdownItem(
item.Id,
item.LegacyId,
item.RegionId,
item.SchoolId,
item.ExamName,
item.ExamAt,
item.ExamType,
item.Description,
item.Metadata,
item.SortOrder,
item.IsActive,
item.ExamAt.HasValue ? (int)Math.Ceiling((item.ExamAt.Value.UtcDateTime.Date - today).TotalDays) : null))
.ToArray(),
profile.RegionId,
profile.SelectedSchoolId);
}
public async Task<ProfileNotificationList> GetNotificationsAsync(
ProfileActor actor,
ProfileNotificationQuery query,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
var notifications = dbContext.UserNotifications.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
notifications = notifications.Where(item => item.Status == ParseNotificationStatus(query.Status));
}
var limit = Math.Clamp(query.Limit ?? 50, 1, 100);
var items = await notifications
.OrderByDescending(item => item.CreatedAt)
.Take(limit)
.ToArrayAsync(cancellationToken);
return new ProfileNotificationList(
items.Select(ToNotificationItem).ToArray(),
await BuildNotificationSummaryAsync(actor, cancellationToken));
}
public async Task<ProfileNotificationList> UpdateNotificationStatusAsync(
ProfileActor actor,
UpdateNotificationStatusCommand command,
CancellationToken cancellationToken = default)
{
if (command.NotificationIds.Count is 0 or > 100)
{
throw new ProfileException("Notification ids must contain 1 to 100 items.", "invalid_notification_ids");
}
var status = ParseNotificationStatus(command.Status ?? "read");
if (status == NotificationStatus.Unread)
{
throw new ProfileException("Notification status cannot be set to unread.", "invalid_notification_status");
}
var notifications = await dbContext.UserNotifications
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
command.NotificationIds.Contains(item.Id))
.ToArrayAsync(cancellationToken);
foreach (var notification in notifications)
{
notification.Status = status;
notification.ReadAt = status == NotificationStatus.Read ? DateTimeOffset.UtcNow : notification.ReadAt;
}
await dbContext.SaveChangesAsync(cancellationToken);
return new ProfileNotificationList(
notifications.Select(ToNotificationItem).ToArray(),
await BuildNotificationSummaryAsync(actor, cancellationToken));
}
public async Task<BadgeList> GetBadgesAsync(
ProfileActor actor,
BadgeQuery query,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
var badgesQuery = dbContext.Badges.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.IsActive);
if (!string.IsNullOrWhiteSpace(query.Category))
{
var category = query.Category.Trim();
badgesQuery = badgesQuery.Where(item => item.Category == category);
}
var badges = await badgesQuery
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.Name)
.Take(Math.Clamp(query.Limit ?? 100, 1, 200))
.ToArrayAsync(cancellationToken);
var badgeIds = badges.Select(item => item.Id).ToArray();
var grants = await dbContext.UserBadges.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.BadgeId.HasValue &&
badgeIds.Contains(item.BadgeId.Value))
.ToDictionaryAsync(item => item.BadgeId!.Value, cancellationToken);
var items = badges
.Select(badge =>
{
grants.TryGetValue(badge.Id, out var grant);
return new BadgeItem(
badge.Id,
badge.Name,
badge.Description,
badge.Category,
badge.IconUrl,
badge.Level,
badge.UnlockType,
badge.ConditionExtra,
grant is not null,
grant?.GrantedAt,
grant?.Note,
badge.SortOrder);
})
.Where(item => query.IncludeLocked || item.IsUnlocked)
.ToArray();
return new BadgeList(items, badges.Length, grants.Count, query.IncludeLocked);
}
public async Task<IReadOnlyCollection<FeedbackItem>> GetFeedbacksAsync(
ProfileActor actor,
FeedbackQuery query,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
var feedbacks = dbContext.Reports.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(query.Status))
{
feedbacks = feedbacks.Where(item => item.Status == ParseReportStatus(query.Status));
}
if (!string.IsNullOrWhiteSpace(query.Type))
{
feedbacks = feedbacks.Where(item => item.Type == ParseReportType(query.Type));
}
return await feedbacks
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 100))
.Select(item => ToFeedbackItem(item))
.ToArrayAsync(cancellationToken);
}
public async Task<FeedbackItem> SubmitFeedbackAsync(
ProfileActor actor,
SubmitFeedbackCommand command,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Description);
await AssertReferenceAsync<Tiku.Domain.QuestionBanks.Question>(actor.TenantId, command.QuestionId, "question_not_found", cancellationToken);
var feedback = new Report
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = command.QuestionId,
Type = ParseReportType(command.Type ?? "other"),
Title = string.IsNullOrWhiteSpace(command.Title) ? null : command.Title.Trim(),
Category = string.IsNullOrWhiteSpace(command.Category) ? null : command.Category.Trim(),
Description = command.Description.Trim(),
Priority = ParseReportPriority(command.Priority ?? "normal"),
Contact = string.IsNullOrWhiteSpace(command.Contact) ? null : command.Contact.Trim(),
Attachments = JsonArrayOrDefault(command.Attachments),
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Reports.Add(feedback);
dbContext.ReportStatusEvents.Add(new ReportStatusEvent
{
TenantId = actor.TenantId,
ReportId = feedback.Id,
ToStatus = ReportStatus.Pending,
Note = "student submitted feedback",
ActorUserId = actor.UserId
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToFeedbackItem(feedback);
}
public async Task<CheckInResult> CheckInAsync(
ProfileActor actor,
CancellationToken cancellationToken = default)
{
var profile = await EnsureProfileAsync(actor, cancellationToken);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var sourceId = CreateDeterministicGuid($"{actor.TenantId:N}:{actor.UserId:N}:check-in:{today:yyyyMMdd}");
var idempotencyKey = $"check-in:{actor.TenantId:N}:{actor.UserId:N}:{today:yyyyMMdd}";
var existingEvent = await dbContext.UserScoreEvents.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingEvent is not null)
{
var existingClaim = await dbContext.PointActivityClaims.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.SourceType == "daily_check_in" &&
item.SourceId == sourceId,
cancellationToken);
return new CheckInResult(
today,
true,
existingEvent.Points,
existingEvent.BalanceAfter,
existingClaim?.Id,
existingEvent.Id);
}
var task = await dbContext.PointActivityTasks
.Where(item =>
item.TenantId == actor.TenantId &&
item.Status == PointActivityTaskStatus.Active &&
(item.TaskKey == "daily_check_in" || item.TaskKey == "daily_login"))
.OrderByDescending(item => item.TaskKey == "daily_check_in")
.ThenBy(item => item.SortOrder)
.FirstOrDefaultAsync(cancellationToken)
?? throw new ProfileException("Daily check-in point task was not configured.", "check_in_task_not_found");
var now = DateTimeOffset.UtcNow;
var claim = new PointActivityClaim
{
TenantId = actor.TenantId,
TaskId = task.Id,
TaskKey = task.TaskKey,
UserId = actor.UserId,
Points = task.Points,
Status = PointActivityClaimStatus.Claimed,
SourceType = "daily_check_in",
SourceId = sourceId,
ClaimedAt = now,
Metadata = JsonSerializer.SerializeToElement(new
{
checkInDate = today,
source = "profile_check_in"
})
};
dbContext.PointActivityClaims.Add(claim);
var balanceAfter = await CalculatePointBalanceAsync(actor, cancellationToken) + task.Points;
var scoreEvent = new UserScoreEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
EventType = UserScoreEventType.CheckIn,
Points = task.Points,
BalanceAfter = balanceAfter,
SourceType = "daily_check_in",
SourceId = sourceId,
IdempotencyKey = idempotencyKey,
Metadata = JsonSerializer.SerializeToElement(new
{
task.Id,
task.TaskKey,
checkInDate = today
}),
CreatedAt = now
};
dbContext.UserScoreEvents.Add(scoreEvent);
profile.LastCheckInDate = today;
await dbContext.SaveChangesAsync(cancellationToken);
return new CheckInResult(today, false, task.Points, balanceAfter, claim.Id, scoreEvent.Id);
}
public async Task<ProfileScoreEventList> GetScoreEventsAsync(
ProfileActor actor,
ProfileScoreEventQuery query,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
var events = dbContext.UserScoreEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(query.SourceType))
{
var sourceType = query.SourceType.Trim();
events = events.Where(item => item.SourceType == sourceType);
}
if (query.From.HasValue)
{
events = events.Where(item => item.CreatedAt >= query.From.Value);
}
if (query.To.HasValue)
{
events = events.Where(item => item.CreatedAt <= query.To.Value);
}
var items = await events
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.Select(item => new ProfileScoreEventItem(
item.Id,
ToWire(item.EventType),
item.Points,
item.BalanceAfter,
item.SourceType,
item.SourceId,
item.Metadata,
item.CreatedAt))
.ToArrayAsync(cancellationToken);
return new ProfileScoreEventList(items);
}
private async Task<StudentProfile> EnsureProfileAsync(
ProfileActor actor,
CancellationToken cancellationToken)
{
var profile = await dbContext.StudentProfiles
.SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId, cancellationToken);
if (profile is not null)
{
return profile;
}
var membershipExists = await dbContext.TenantMemberships.AnyAsync(
membership =>
membership.TenantId == actor.TenantId &&
membership.UserId == actor.UserId &&
membership.Status == Domain.Tenancy.MembershipStatus.Active,
cancellationToken);
if (!membershipExists)
{
throw new ProfileException("Current user is not a tenant member.", "profile_access_denied");
}
profile = new StudentProfile
{
TenantId = actor.TenantId,
UserId = actor.UserId,
AvatarPreset = "male"
};
dbContext.StudentProfiles.Add(profile);
await dbContext.SaveChangesAsync(cancellationToken);
return profile;
}
private async Task<IReadOnlyDictionary<string, int>> BuildNotificationSummaryAsync(
ProfileActor actor,
CancellationToken cancellationToken)
{
var items = await dbContext.UserNotifications.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId)
.GroupBy(item => item.Status)
.Select(group => new { Status = group.Key, Count = group.Count() })
.ToArrayAsync(cancellationToken);
return items.ToDictionary(item => ToWire(item.Status), item => item.Count, StringComparer.Ordinal);
}
private async Task<StudentProfileItem> BuildProfileItemAsync(
ProfileActor actor,
StudentProfile profile,
int? recentLimit,
CancellationToken cancellationToken)
{
var user = await dbContext.Users.AsNoTracking()
.SingleAsync(item => item.Id == actor.UserId, cancellationToken);
var regionName = profile.RegionId.HasValue
? await dbContext.Regions.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.RegionId.Value)
.Select(item => item.Name)
.SingleOrDefaultAsync(cancellationToken)
: null;
var schoolName = profile.SelectedSchoolId.HasValue
? await dbContext.Schools.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedSchoolId.Value)
.Select(item => item.Name)
.SingleOrDefaultAsync(cancellationToken)
: null;
var majorName = profile.SelectedMajorId.HasValue
? await dbContext.Majors.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Id == profile.SelectedMajorId.Value)
.Select(item => item.Name)
.SingleOrDefaultAsync(cancellationToken)
: null;
var limit = Math.Clamp(recentLimit ?? 8, 1, 50);
var recentPractices = await dbContext.RecentPractices.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId)
.OrderByDescending(item => item.LastPracticeAt)
.ThenByDescending(item => item.LastAccessAt)
.ThenByDescending(item => item.UpdatedAt)
.Take(limit)
.Select(item => new RecentPracticeItem(
item.Id,
item.PracticeType,
item.TargetName,
item.Progress,
item.LastAccessAt,
item.LastPracticeAt))
.ToArrayAsync(cancellationToken);
var entitlement = await dbContext.Entitlements.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.EntitlementType == "svip" &&
item.Status == EntitlementStatus.Active &&
item.StartsAt <= DateTimeOffset.UtcNow &&
(item.ExpiresAt == null || item.ExpiresAt > DateTimeOffset.UtcNow))
.OrderByDescending(item => item.ExpiresAt)
.FirstOrDefaultAsync(cancellationToken);
return new StudentProfileItem(
profile.Id,
user.Id,
user.UserName,
user.Phone,
user.Email,
user.Name,
profile.AvatarPreset,
$"/assets/avatars/default-{profile.AvatarPreset}.svg",
user.PrimaryRole,
user.Score,
profile.RegionId,
regionName,
profile.SelectedSchoolId,
schoolName,
profile.SelectedMajorId,
majorName,
profile.QuestionsAnsweredToday,
profile.MasteredWordsCount,
profile.LastCheckInDate,
profile.Stats,
profile.Progress,
profile.ModuleSelections,
profile.RecentActivities,
recentPractices,
new StudentMembershipItem(entitlement is not null, entitlement?.ExpiresAt));
}
private async Task AssertReferenceAsync<TEntity>(
Guid tenantId,
Guid? id,
string code,
CancellationToken cancellationToken)
where TEntity : Tiku.Domain.Common.TenantEntity
{
if (!id.HasValue)
{
return;
}
var exists = await dbContext.Set<TEntity>().AnyAsync(
entity => entity.TenantId == tenantId && entity.Id == id.Value,
cancellationToken);
if (!exists)
{
throw new ProfileException("Profile reference was not found.", code);
}
}
private static JsonElement JsonObjectOrDefault(JsonElement value)
{
return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object();
}
private static JsonElement JsonArrayOrDefault(JsonElement value)
{
return value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array();
}
private static ProfileNotificationItem ToNotificationItem(UserNotification notification)
{
return new ProfileNotificationItem(
notification.Id,
notification.NotificationType,
ToWire(notification.Status),
ToWire(notification.Severity),
notification.Title,
notification.Message,
notification.ActionLabel,
notification.ActionPath,
notification.SourceType,
notification.SourceId,
notification.Metadata,
notification.ReadAt,
notification.CreatedAt);
}
private static FeedbackItem ToFeedbackItem(Report report)
{
return new FeedbackItem(
report.Id,
report.QuestionId,
report.Type.HasValue ? ToWire(report.Type.Value) : null,
report.Title,
report.Category,
report.Description,
ToWire(report.Status),
ToWire(report.Priority),
report.Contact,
report.Attachments,
report.Metadata,
report.CreatedAt,
report.UpdatedAt);
}
private static NotificationStatus ParseNotificationStatus(string value)
{
return ParseEnum<NotificationStatus>(value, "invalid_notification_status");
}
private static ReportType ParseReportType(string value)
{
return ParseEnum<ReportType>(value, "invalid_feedback_type");
}
private static ReportStatus ParseReportStatus(string value)
{
return ParseEnum<ReportStatus>(value, "invalid_feedback_status");
}
private static ReportPriority ParseReportPriority(string value)
{
return ParseEnum<ReportPriority>(value, "invalid_feedback_priority");
}
private static TEnum ParseEnum<TEnum>(string value, string code)
where TEnum : struct, Enum
{
var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal);
if (Enum.TryParse<TEnum>(normalized, true, out var parsed))
{
return parsed;
}
throw new ProfileException("Profile enum value is invalid.", code);
}
private static string ToWire<TEnum>(TEnum value)
where TEnum : struct, Enum
{
var text = value.ToString();
return string.Concat(text.Select((ch, index) =>
index > 0 && char.IsUpper(ch)
? "_" + char.ToLowerInvariant(ch)
: char.ToLowerInvariant(ch).ToString()));
}
private async Task<int> CalculatePointBalanceAsync(ProfileActor actor, CancellationToken cancellationToken)
{
var earned = await dbContext.PointActivityClaims.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == PointActivityClaimStatus.Claimed)
.SumAsync(item => (int?)item.Points, cancellationToken) ?? 0;
var spent = await dbContext.PointExchangeOrders.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == PointExchangeOrderStatus.Completed)
.SumAsync(item => (int?)item.PointsCost, cancellationToken) ?? 0;
return earned - spent;
}
private static Guid CreateDeterministicGuid(string value)
{
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value));
Span<byte> guidBytes = stackalloc byte[16];
bytes.AsSpan(0, 16).CopyTo(guidBytes);
return new Guid(guidBytes);
}
}
public sealed class ProfileException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}