using System.Security.Cryptography; using System.Text; using System.Text.Json; 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.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Profile; public sealed class ProfileService(TikuDbContext dbContext) : IProfileService { private static readonly HashSet AllowedAvatarPresets = new(StringComparer.OrdinalIgnoreCase) { "male", "female" }; public async Task 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 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(actor.TenantId, command.RegionId, "region_not_found", cancellationToken); await AssertReferenceAsync(actor.TenantId, command.SelectedSchoolId, "school_not_found", cancellationToken); await AssertReferenceAsync(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 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 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 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 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> 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 SubmitFeedbackAsync( ProfileActor actor, SubmitFeedbackCommand command, CancellationToken cancellationToken = default) { await EnsureProfileAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Description); await AssertReferenceAsync(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 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 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 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 == 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> 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 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( Guid tenantId, Guid? id, string code, CancellationToken cancellationToken) where TEntity : TenantEntity { if (!id.HasValue) return; var exists = await dbContext.Set().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(value, "invalid_notification_status"); } private static ReportType ParseReportType(string value) { return ParseEnum(value, "invalid_feedback_type"); } private static ReportStatus ParseReportStatus(string value) { return ParseEnum(value, "invalid_feedback_status"); } private static ReportPriority ParseReportPriority(string value) { return ParseEnum(value, "invalid_feedback_priority"); } private static TEnum ParseEnum(string value, string code) where TEnum : struct, Enum { var normalized = value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); if (Enum.TryParse(normalized, true, out var parsed)) return parsed; throw new ProfileException("Profile enum value is invalid.", code); } private static string ToWire(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 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(Encoding.UTF8.GetBytes(value)); Span guidBytes = stackalloc byte[16]; bytes.AsSpan(0, 16).CopyTo(guidBytes); return new Guid(guidBytes); } }