feat: add student video and profile experience
This commit is contained in:
274
Tiku.Infrastructure/Assets/VideoPlaybackService.cs
Normal file
274
Tiku.Infrastructure/Assets/VideoPlaybackService.cs
Normal file
@@ -0,0 +1,274 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Assets;
|
||||
|
||||
public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlaybackService
|
||||
{
|
||||
public async Task<CatalogList<VideoExplanationCatalogItem>> SearchAsync(
|
||||
VideoPlaybackActor actor,
|
||||
VideoSearchQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var videos = dbContext.VideoExplanations.AsNoTracking()
|
||||
.Where(video => video.TenantId == actor.TenantId && video.IsActive);
|
||||
if (query.SubjectId.HasValue)
|
||||
{
|
||||
videos = videos.Where(video => video.SubjectId == query.SubjectId.Value || video.SubjectId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
||||
{
|
||||
var keyword = query.Keyword.Trim();
|
||||
videos = videos.Where(video =>
|
||||
video.Title.Contains(keyword) ||
|
||||
(video.Description != null && video.Description.Contains(keyword)));
|
||||
}
|
||||
|
||||
var items = await videos
|
||||
.OrderBy(video => video.SortOrder)
|
||||
.ThenBy(video => video.Title)
|
||||
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
|
||||
.Select(video => ToVideoItem(video))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<VideoExplanationCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<VideoPlaybackItem> PlayAsync(
|
||||
VideoPlaybackActor actor,
|
||||
VideoPlayCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
|
||||
if (command.QuestionId.HasValue)
|
||||
{
|
||||
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
|
||||
}
|
||||
|
||||
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
|
||||
progress.PlayCount++;
|
||||
progress.LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
ActorRole = AssetAccessActorRole.Student,
|
||||
AccessType = AssetAccessType.Preview,
|
||||
AssetType = ContentAssetType.Video.ToString(),
|
||||
Result = AssetAccessResult.Granted,
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
videoId = video.Id,
|
||||
command.QuestionId,
|
||||
source = "student_video_play"
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new VideoPlaybackItem(
|
||||
video.Id,
|
||||
command.QuestionId,
|
||||
video.Title,
|
||||
video.Description,
|
||||
video.VideoUrl,
|
||||
video.ThumbnailUrl,
|
||||
video.DurationSeconds,
|
||||
ToProgressItem(progress),
|
||||
video.Metadata);
|
||||
}
|
||||
|
||||
public async Task<VideoProgressItem> ReportProgressAsync(
|
||||
VideoPlaybackActor actor,
|
||||
VideoProgressCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
|
||||
if (command.QuestionId.HasValue)
|
||||
{
|
||||
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
|
||||
}
|
||||
|
||||
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
|
||||
var positionSeconds = Math.Max(command.PositionSeconds, 0);
|
||||
var durationSeconds = command.DurationSeconds ?? video.DurationSeconds;
|
||||
var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds);
|
||||
var completed = command.IsCompleted == true ||
|
||||
durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3);
|
||||
progress.PositionSeconds = positionSeconds;
|
||||
progress.DurationSeconds = durationSeconds;
|
||||
progress.WatchedSeconds = watchedSeconds;
|
||||
progress.IsCompleted = completed;
|
||||
progress.CompletedAt = completed ? progress.CompletedAt ?? DateTimeOffset.UtcNow : progress.CompletedAt;
|
||||
progress.LastPlayedAt = DateTimeOffset.UtcNow;
|
||||
progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToProgressItem(progress);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
|
||||
VideoPlaybackActor actor,
|
||||
QuestionVideoQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertActiveMemberAsync(actor, cancellationToken);
|
||||
var questionVideos = dbContext.QuestionVideos.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (query.QuestionId.HasValue)
|
||||
{
|
||||
questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value);
|
||||
}
|
||||
|
||||
if (query.QuestionIds is { Count: > 0 })
|
||||
{
|
||||
questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
|
||||
}
|
||||
|
||||
var videos = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId && item.IsActive);
|
||||
var items = await questionVideos
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.CreatedAt)
|
||||
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
|
||||
.GroupJoin(
|
||||
videos,
|
||||
questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId },
|
||||
video => new { video.TenantId, Id = (Guid?)video.Id },
|
||||
(questionVideo, matchedVideos) => new
|
||||
{
|
||||
QuestionVideo = questionVideo,
|
||||
Video = matchedVideos.FirstOrDefault()
|
||||
})
|
||||
.Select(row => new QuestionVideoCatalogItem(
|
||||
row.QuestionVideo.Id,
|
||||
row.QuestionVideo.LegacyId,
|
||||
row.QuestionVideo.QuestionId,
|
||||
row.QuestionVideo.VideoId,
|
||||
row.QuestionVideo.VideoType,
|
||||
row.QuestionVideo.SortOrder,
|
||||
row.QuestionVideo.Metadata,
|
||||
row.Video == null ? null : ToVideoItem(row.Video)))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new CatalogList<QuestionVideoCatalogItem>(items);
|
||||
}
|
||||
|
||||
private async Task<VideoPlaybackProgress> ResolveProgressAsync(
|
||||
VideoPlaybackActor actor,
|
||||
Guid videoId,
|
||||
Guid? questionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var progress = await dbContext.VideoPlaybackProgress.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.VideoId == videoId &&
|
||||
item.QuestionId == questionId,
|
||||
cancellationToken);
|
||||
if (progress is not null)
|
||||
{
|
||||
return progress;
|
||||
}
|
||||
|
||||
progress = new VideoPlaybackProgress
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
VideoId = videoId,
|
||||
QuestionId = questionId,
|
||||
LastPlayedAt = DateTimeOffset.UtcNow,
|
||||
Metadata = JsonDefaults.Object()
|
||||
};
|
||||
dbContext.VideoPlaybackProgress.Add(progress);
|
||||
return progress;
|
||||
}
|
||||
|
||||
private async Task<VideoExplanation> ResolveVideoAsync(
|
||||
Guid tenantId,
|
||||
Guid videoId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await dbContext.VideoExplanations.SingleOrDefaultAsync(
|
||||
video => video.TenantId == tenantId && video.Id == videoId && video.IsActive,
|
||||
cancellationToken)
|
||||
?? throw new VideoPlaybackException("Video was not found.", "video_not_found");
|
||||
}
|
||||
|
||||
private async Task AssertQuestionVideoAsync(
|
||||
Guid tenantId,
|
||||
Guid questionId,
|
||||
Guid videoId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.QuestionVideos.AnyAsync(
|
||||
item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.QuestionId == questionId &&
|
||||
item.VideoId == videoId,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.TenantMemberships.AnyAsync(
|
||||
membership =>
|
||||
membership.TenantId == actor.TenantId &&
|
||||
membership.UserId == actor.UserId &&
|
||||
membership.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!exists)
|
||||
{
|
||||
throw new VideoPlaybackException("Current user is not a member of the tenant.", "video_access_denied");
|
||||
}
|
||||
}
|
||||
|
||||
private static VideoExplanationCatalogItem ToVideoItem(VideoExplanation video)
|
||||
{
|
||||
return new VideoExplanationCatalogItem(
|
||||
video.Id,
|
||||
video.LegacyId,
|
||||
video.SubjectId,
|
||||
video.Title,
|
||||
video.Description,
|
||||
video.VideoUrl,
|
||||
video.ThumbnailUrl,
|
||||
video.DurationSeconds,
|
||||
video.KnowledgeTags,
|
||||
video.IsGeneral,
|
||||
video.Difficulty,
|
||||
video.SortOrder,
|
||||
video.IsActive,
|
||||
video.Metadata);
|
||||
}
|
||||
|
||||
private static VideoProgressItem ToProgressItem(VideoPlaybackProgress progress)
|
||||
{
|
||||
return new VideoProgressItem(
|
||||
progress.Id,
|
||||
progress.VideoId,
|
||||
progress.QuestionId,
|
||||
progress.PositionSeconds,
|
||||
progress.DurationSeconds,
|
||||
progress.WatchedSeconds,
|
||||
progress.IsCompleted,
|
||||
progress.CompletedAt,
|
||||
progress.LastPlayedAt,
|
||||
progress.PlayCount,
|
||||
progress.Metadata);
|
||||
}
|
||||
}
|
||||
@@ -1025,6 +1025,46 @@ public sealed class DirectContentService(
|
||||
return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ContentImportJobDetail> GetImportJobAsync(
|
||||
DirectContentActor actor,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = await dbContext.ContentImportJobs.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
|
||||
.Select(item => ToJobItem(item))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (job is null)
|
||||
{
|
||||
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
|
||||
}
|
||||
|
||||
var items = await dbContext.ContentImportItems.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
|
||||
.OrderBy(item => item.RowNo)
|
||||
.Take(MaxLimit)
|
||||
.Select(item => ToImportItem(item))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var issues = await dbContext.ContentImportIssues.AsNoTracking()
|
||||
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
|
||||
.OrderBy(issue => issue.RowNo)
|
||||
.ThenBy(issue => issue.CreatedAt)
|
||||
.Take(MaxLimit)
|
||||
.Select(issue => new ContentImportIssueModel(
|
||||
issue.Id,
|
||||
issue.JobId,
|
||||
issue.ItemId,
|
||||
issue.RowNo,
|
||||
issue.Severity,
|
||||
issue.Code,
|
||||
issue.FieldPath,
|
||||
issue.Message,
|
||||
issue.Details))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new ContentImportJobDetail(job, items, issues);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentImportIssueModel>> GetImportIssuesAsync(
|
||||
DirectContentActor actor,
|
||||
Guid jobId,
|
||||
|
||||
@@ -112,6 +112,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||
services.AddScoped<IAssetManagementService, AssetManagementService>();
|
||||
services.AddScoped<IVideoPlaybackService, VideoPlaybackService>();
|
||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
@@ -65,7 +66,7 @@ internal sealed class BackgroundJobService(
|
||||
var result = await tenantExecutionScope.ExecuteAsync(
|
||||
job.TenantId,
|
||||
$"Background job {job.JobType}",
|
||||
(_, token) => ProcessCoreAsync(job, token),
|
||||
(provider, token) => ProcessCoreAsync(provider, job, token),
|
||||
cancellationToken);
|
||||
job.Status = BackgroundJobStatus.Succeeded;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
@@ -114,13 +115,16 @@ internal sealed class BackgroundJobService(
|
||||
return jobs.Select(ToItem).ToArray();
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
|
||||
private async Task<JsonElement> ProcessCoreAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return job.JobType switch
|
||||
{
|
||||
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
|
||||
"content_import" => throw new NotSupportedException("content_import requires the module-specific importer before it can mutate content."),
|
||||
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
|
||||
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken),
|
||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
|
||||
@@ -129,6 +133,47 @@ internal sealed class BackgroundJobService(
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ProcessContentImportAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var directContentService = scopedProvider.GetRequiredService<IDirectContentService>();
|
||||
var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty;
|
||||
if (createdBy == Guid.Empty)
|
||||
{
|
||||
throw new InvalidOperationException("content_import job requires createdBy.");
|
||||
}
|
||||
|
||||
var command = new SimpleImportCommand(
|
||||
GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."),
|
||||
GetJsonString(job.Payload, "sourceFormat"),
|
||||
GetJsonString(job.Payload, "sourceName"),
|
||||
GetJsonGuid(job.Payload, "regionId"),
|
||||
GetJsonGuid(job.Payload, "entryId"),
|
||||
GetJsonGuid(job.Payload, "contentNodeId"),
|
||||
GetJsonGuid(job.Payload, "subjectId"),
|
||||
GetJsonGuid(job.Payload, "categoryId"),
|
||||
GetJsonGuid(job.Payload, "questionBankId"),
|
||||
GetJsonGuid(job.Payload, "collectionId"),
|
||||
GetJsonArray(job.Payload, "items"),
|
||||
false);
|
||||
var result = await directContentService.ExecuteImportAsync(
|
||||
new DirectContentActor(job.TenantId, createdBy),
|
||||
command,
|
||||
cancellationToken);
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
importJobId = result.Job.Id,
|
||||
result.Job.ImportType,
|
||||
result.Job.Status,
|
||||
result.Job.TotalCount,
|
||||
result.Job.InsertedCount,
|
||||
result.Job.UpdatedCount,
|
||||
result.Job.ErrorCount
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessContentExportAsync(
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -300,6 +345,31 @@ internal sealed class BackgroundJobService(
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Guid? GetJsonGuid(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IReadOnlyCollection<JsonElement> GetJsonArray(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object ||
|
||||
!element.TryGetProperty(propertyName, out var property) ||
|
||||
property.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return property.EnumerateArray().Select(item => item.Clone()).ToArray();
|
||||
}
|
||||
|
||||
private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = GetJsonString(element, propertyName);
|
||||
|
||||
@@ -310,3 +310,41 @@ internal sealed class QuestionVideoConfiguration : IEntityTypeConfiguration<Ques
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfiguration<VideoPlaybackProgress>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<VideoPlaybackProgress> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("video_playback_progress");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.Property(entity => entity.LastPlayedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId })
|
||||
.IsUnique()
|
||||
.HasFilter("question_id is null");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId, entity.QuestionId })
|
||||
.IsUnique()
|
||||
.HasFilter("question_id is not null");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastPlayedAt });
|
||||
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<VideoExplanation>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.VideoId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
|
||||
table.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
|
||||
table.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
|
||||
table.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
18103
Tiku.Infrastructure/Persistence/Migrations/20260728070357_AddVideoPlaybackProgress.Designer.cs
generated
Normal file
18103
Tiku.Infrastructure/Persistence/Migrations/20260728070357_AddVideoPlaybackProgress.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVideoPlaybackProgress : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "video_playback_progress",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
video_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
question_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
position_seconds = table.Column<int>(type: "integer", nullable: false),
|
||||
duration_seconds = table.Column<int>(type: "integer", nullable: true),
|
||||
watched_seconds = table.Column<int>(type: "integer", nullable: false),
|
||||
is_completed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
last_played_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
play_count = table.Column<int>(type: "integer", nullable: false),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_video_playback_progress", x => x.id);
|
||||
table.UniqueConstraint("ak_video_playback_progress_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
|
||||
table.CheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
|
||||
table.CheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
|
||||
table.CheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_questions_tenant_id_question_id",
|
||||
columns: x => new { x.tenant_id, x.question_id },
|
||||
principalTable: "questions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_video_explanations_tenant_id_video_~",
|
||||
columns: x => new { x.tenant_id, x.video_id },
|
||||
principalTable: "video_explanations",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_question_id",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "question_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_user_id_last_played_at",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "user_id", "last_played_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_user_id_video_id",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "user_id", "video_id" },
|
||||
unique: true,
|
||||
filter: "question_id is null");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_user_id_video_id_question~",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "user_id", "video_id", "question_id" },
|
||||
unique: true,
|
||||
filter: "question_id is not null");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_video_id",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "video_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_user_id",
|
||||
table: "video_playback_progress",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "video_playback_progress");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6579,6 +6579,118 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("video_explanations", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<int?>("DurationSeconds")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_completed");
|
||||
|
||||
b.Property<DateTimeOffset>("LastPlayedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_played_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("metadata")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<int>("PlayCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("play_count");
|
||||
|
||||
b.Property<int>("PositionSeconds")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("position_seconds");
|
||||
|
||||
b.Property<Guid?>("QuestionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("question_id");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<Guid>("VideoId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("video_id");
|
||||
|
||||
b.Property<int>("WatchedSeconds")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("watched_seconds");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_video_playback_progress");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_video_playback_progress_tenant_id_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_video_playback_progress_user_id");
|
||||
|
||||
b.HasIndex("TenantId", "QuestionId")
|
||||
.HasDatabaseName("ix_video_playback_progress_tenant_id_question_id");
|
||||
|
||||
b.HasIndex("TenantId", "VideoId")
|
||||
.HasDatabaseName("ix_video_playback_progress_tenant_id_video_id");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "LastPlayedAt")
|
||||
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_last_played_at");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "VideoId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id")
|
||||
.HasFilter("question_id is null");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "VideoId", "QuestionId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id_question~")
|
||||
.HasFilter("question_id is not null");
|
||||
|
||||
b.ToTable("video_playback_progress", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
|
||||
|
||||
t.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
|
||||
|
||||
t.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
|
||||
|
||||
t.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -16197,6 +16309,38 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_video_explanations_subjects_tenant_id_subject_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_video_playback_progress_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_video_playback_progress_users_user_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.QuestionBanks.Question", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "QuestionId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.HasConstraintName("fk_video_playback_progress_questions_tenant_id_question_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Content.VideoExplanation", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "VideoId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_video_playback_progress_video_explanations_tenant_id_video_~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class TikuDbContext(
|
||||
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
|
||||
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
|
||||
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
|
||||
public DbSet<VideoPlaybackProgress> VideoPlaybackProgress => Set<VideoPlaybackProgress>();
|
||||
public DbSet<TenantQuestionBankPreference> TenantQuestionBankPreferences => Set<TenantQuestionBankPreference>();
|
||||
public DbSet<TenantQuestionReference> TenantQuestionReferences => Set<TenantQuestionReference>();
|
||||
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -125,6 +126,25 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService
|
||||
Metadata = JsonSerializer.SerializeToElement(new { source = "student_points" })
|
||||
};
|
||||
dbContext.PointActivityClaims.Add(claim);
|
||||
var balanceAfter = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints + task.Points;
|
||||
dbContext.UserScoreEvents.Add(new UserScoreEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
EventType = task.TaskType == PointActivityTaskType.DailyLogin
|
||||
? UserScoreEventType.CheckIn
|
||||
: UserScoreEventType.ActivityReward,
|
||||
Points = task.Points,
|
||||
BalanceAfter = balanceAfter,
|
||||
SourceType = NormalizeOptional(command.SourceType) ?? "point_task",
|
||||
SourceId = command.SourceId ?? claim.Id,
|
||||
IdempotencyKey = $"point-claim:{actor.TenantId:N}:{claim.Id:N}",
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
task.Id,
|
||||
task.TaskKey
|
||||
})
|
||||
});
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToClaimItem(claim);
|
||||
}
|
||||
@@ -212,6 +232,23 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService
|
||||
Metadata = JsonSerializer.SerializeToElement(new { source = "student_points_exchange" })
|
||||
};
|
||||
dbContext.PointExchangeOrders.Add(order);
|
||||
dbContext.UserScoreEvents.Add(new UserScoreEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
EventType = UserScoreEventType.RedeemCost,
|
||||
Points = -item.PointsCost,
|
||||
BalanceAfter = balance - item.PointsCost,
|
||||
SourceType = "point_exchange_order",
|
||||
SourceId = order.Id,
|
||||
IdempotencyKey = $"point-exchange:{actor.TenantId:N}:{order.Id:N}",
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
item.Id,
|
||||
item.ItemKey,
|
||||
item.Name
|
||||
})
|
||||
});
|
||||
if (item.ItemType == PointExchangeItemType.Entitlement)
|
||||
{
|
||||
await GrantEntitlementAsync(actor, item, order, now, cancellationToken);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Domain.Catalog;
|
||||
@@ -292,6 +293,133 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
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)
|
||||
@@ -525,6 +653,31 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
|
||||
? "_" + 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(System.Text.Encoding.UTF8.GetBytes(value));
|
||||
Span<byte> guidBytes = stackalloc byte[16];
|
||||
bytes.AsSpan(0, 16).CopyTo(guidBytes);
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProfileException(string message, string code) : Exception(message)
|
||||
|
||||
Reference in New Issue
Block a user