648 lines
26 KiB
C#
648 lines
26 KiB
C#
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<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<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 == 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 : 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(Encoding.UTF8.GetBytes(value));
|
|
Span<byte> guidBytes = stackalloc byte[16];
|
|
bytes.AsSpan(0, 16).CopyTo(guidBytes);
|
|
return new Guid(guidBytes);
|
|
}
|
|
} |