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.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); } 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 == 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> 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 : Tiku.Domain.Common.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())); } } public sealed class ProfileException(string message, string code) : Exception(message) { public string Code { get; } = code; }