forked from xiongyuxing/tiku-backend.net
feat: add student engagement endpoints
This commit is contained in:
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -122,6 +123,175 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task<StudentProfile> EnsureProfileAsync(
|
||||
ProfileActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -155,6 +325,18 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
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,
|
||||
@@ -265,6 +447,84 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
{
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||
|
||||
Reference in New Issue
Block a user