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