forked from xiongyuxing/tiku-backend.net
feat: establish dotnet engineering foundation
This commit is contained in:
@@ -10,7 +10,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdminService
|
||||
internal sealed class CommerceAdminService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector) : ICommerceAdminService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<TenantPaymentAccountItem>> GetPaymentAccountsAsync(
|
||||
CommerceAdminActor actor,
|
||||
@@ -88,11 +90,19 @@ public sealed class CommerceAdminService(TikuDbContext dbContext) : ICommerceAdm
|
||||
secret.RotatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var protectedPayload = tenantSecretProtector.Protect(
|
||||
actor.TenantId,
|
||||
secretRef,
|
||||
JsonObjectOrDefault(command.SecretPayload));
|
||||
|
||||
secret.Purpose = command.Purpose.Trim();
|
||||
secret.Provider = provider;
|
||||
secret.SecretKey = command.SecretKey.Trim();
|
||||
secret.Status = command.Status;
|
||||
secret.SecretPayload = JsonObjectOrDefault(command.SecretPayload);
|
||||
secret.EncryptionKeyId = protectedPayload.KeyId;
|
||||
secret.EncryptedPayload = protectedPayload.Ciphertext;
|
||||
secret.EncryptionNonce = protectedPayload.Nonce;
|
||||
secret.EncryptionTag = protectedPayload.Tag;
|
||||
secret.ExpiresAt = command.ExpiresAt;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
116
Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs
Normal file
116
Tiku.Infrastructure/Commerce/TenantSecretEncryption.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
public sealed class TenantSecretEncryptionOptions
|
||||
{
|
||||
public const string SectionName = "Security:TenantSecrets";
|
||||
public const string DevelopmentMasterKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string MasterKey { get; set; } = string.Empty;
|
||||
|
||||
public static bool BeValid(TenantSecretEncryptionOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.KeyId) || string.IsNullOrWhiteSpace(options.MasterKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(options.MasterKey).Length == 32;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsDevelopmentDefault(TenantSecretEncryptionOptions options) =>
|
||||
string.Equals(options.MasterKey, DevelopmentMasterKey, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
internal interface ITenantSecretProtector
|
||||
{
|
||||
ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload);
|
||||
|
||||
JsonElement Unprotect(
|
||||
Guid tenantId,
|
||||
string secretRef,
|
||||
string keyId,
|
||||
byte[] ciphertext,
|
||||
byte[] nonce,
|
||||
byte[] tag);
|
||||
}
|
||||
|
||||
internal sealed record ProtectedTenantSecret(
|
||||
string KeyId,
|
||||
byte[] Ciphertext,
|
||||
byte[] Nonce,
|
||||
byte[] Tag);
|
||||
|
||||
internal sealed class TenantSecretProtector(
|
||||
Microsoft.Extensions.Options.IOptions<TenantSecretEncryptionOptions> options) : ITenantSecretProtector
|
||||
{
|
||||
private readonly TenantSecretEncryptionOptions options = options.Value;
|
||||
|
||||
public ProtectedTenantSecret Protect(Guid tenantId, string secretRef, JsonElement payload)
|
||||
{
|
||||
var key = Convert.FromBase64String(options.MasterKey);
|
||||
var plaintext = JsonSerializer.SerializeToUtf8Bytes(payload);
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
var associatedData = GetAssociatedData(tenantId, secretRef, options.KeyId);
|
||||
|
||||
try
|
||||
{
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag, associatedData);
|
||||
return new ProtectedTenantSecret(options.KeyId, ciphertext, nonce, tag);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
public JsonElement Unprotect(
|
||||
Guid tenantId,
|
||||
string secretRef,
|
||||
string keyId,
|
||||
byte[] ciphertext,
|
||||
byte[] nonce,
|
||||
byte[] tag)
|
||||
{
|
||||
if (!string.Equals(keyId, options.KeyId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant secret uses unknown encryption key '{keyId}'.");
|
||||
}
|
||||
|
||||
var key = Convert.FromBase64String(options.MasterKey);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
var associatedData = GetAssociatedData(tenantId, secretRef, keyId);
|
||||
try
|
||||
{
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData);
|
||||
using var document = JsonDocument.Parse(plaintext);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
CryptographicOperations.ZeroMemory(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] GetAssociatedData(Guid tenantId, string secretRef, string keyId) =>
|
||||
Encoding.UTF8.GetBytes($"{tenantId:N}\n{secretRef}\n{keyId}");
|
||||
}
|
||||
@@ -6,7 +6,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Commerce;
|
||||
|
||||
internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecretService
|
||||
internal sealed class TenantSecretService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector) : ITenantSecretService
|
||||
{
|
||||
public async Task<JsonElement> GetActiveSecretPayloadAsync(
|
||||
Guid tenantId,
|
||||
@@ -28,16 +30,28 @@ internal sealed class TenantSecretService(TikuDbContext dbContext) : ITenantSecr
|
||||
item.SecretRef == secretRef &&
|
||||
item.Status == TenantSecretStatus.Active &&
|
||||
(item.ExpiresAt == null || item.ExpiresAt > now))
|
||||
.Select(item => item.SecretPayload)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (secret.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
if (secret is null)
|
||||
{
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
|
||||
return secret;
|
||||
if (secret.EncryptedPayload.Length > 0)
|
||||
{
|
||||
return tenantSecretProtector.Unprotect(
|
||||
tenantId,
|
||||
secretRef,
|
||||
secret.EncryptionKeyId,
|
||||
secret.EncryptedPayload,
|
||||
secret.EncryptionNonce,
|
||||
secret.EncryptionTag);
|
||||
}
|
||||
|
||||
throw new PaymentProviderException(
|
||||
"Payment provider secret is not configured.",
|
||||
"payment_secret_not_configured");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -279,6 +280,9 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
throw new ContentNavigationNotFoundException("Question collection was not found.");
|
||||
}
|
||||
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
var emptySubQuestions = JsonDefaults.Array();
|
||||
var query =
|
||||
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
||||
join question in dbContext.Questions.AsNoTracking()
|
||||
@@ -313,12 +317,12 @@ public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : ICo
|
||||
item.SortOrder,
|
||||
version == null ? null : version.Id,
|
||||
version == null ? null : version.Content,
|
||||
version == null ? default : version.Options,
|
||||
version == null ? emptyOptions : version.Options,
|
||||
version == null ? null : version.CorrectOptionIndex,
|
||||
version == null ? default : version.CorrectOptionIndices,
|
||||
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
|
||||
version == null ? null : version.AnswerText,
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? default : version.SubQuestions,
|
||||
version == null ? emptySubQuestions : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
var question = new Question
|
||||
{
|
||||
@@ -42,6 +43,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
};
|
||||
ApplyQuestion(question, command);
|
||||
dbContext.Questions.Add(question);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var version = BuildQuestionVersion(actor, question.Id, 1, command);
|
||||
dbContext.QuestionVersions.Add(version);
|
||||
@@ -49,6 +51,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IReferralService, ReferralService>();
|
||||
services.AddScoped<ICrmService, CrmService>();
|
||||
services.AddScoped<ICommissionService, CommissionService>();
|
||||
services.AddSingleton<ITenantSecretProtector, TenantSecretProtector>();
|
||||
services.AddScoped<ITenantSecretService, TenantSecretService>();
|
||||
services.AddScoped<IPaymentProviderConfigService, PaymentProviderConfigService>();
|
||||
services.AddScoped<IPaymentProviderGateway, PaymentProviderGateway>();
|
||||
|
||||
@@ -4,11 +4,14 @@ using Tiku.Application.Growth;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Growth;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Commerce;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Growth;
|
||||
|
||||
public sealed class CrmService(TikuDbContext dbContext) : ICrmService
|
||||
internal sealed class CrmService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantSecretProtector tenantSecretProtector) : ICrmService
|
||||
{
|
||||
private static readonly HashSet<string> SensitiveKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -254,8 +257,15 @@ public sealed class CrmService(TikuDbContext dbContext) : ICrmService
|
||||
item.RotatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
var protectedPayload = tenantSecretProtector.Protect(
|
||||
tenantId,
|
||||
secretRef,
|
||||
JsonSerializer.SerializeToElement(new { webhookSecret = secret }));
|
||||
item.Status = TenantSecretStatus.Active;
|
||||
item.SecretPayload = JsonSerializer.SerializeToElement(new { webhookSecret = secret });
|
||||
item.EncryptionKeyId = protectedPayload.KeyId;
|
||||
item.EncryptedPayload = protectedPayload.Ciphertext;
|
||||
item.EncryptionNonce = protectedPayload.Nonce;
|
||||
item.EncryptionTag = protectedPayload.Tag;
|
||||
}
|
||||
|
||||
private async Task AssertAdminAsync(CrmAdminActor actor, CancellationToken cancellationToken)
|
||||
|
||||
@@ -136,6 +136,7 @@ public sealed class ReferralService(
|
||||
var eventType = NormalizeChoice(command.EventType, AllowedEventTypes, "enter", "invalid_referral_event_type");
|
||||
var source = NormalizeChoice(command.Source, AllowedSources, "unknown", "invalid_referral_source");
|
||||
var resolution = await ResolveCodeCoreAsync(actor.TenantId, code, cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var track = new ReferralTrack
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -148,7 +149,6 @@ public sealed class ReferralService(
|
||||
UserAgent = Truncate(NormalizeOptional(userAgent), 1024),
|
||||
Metadata = command.Metadata ?? JsonDefaults.Object()
|
||||
};
|
||||
dbContext.ReferralTracks.Add(track);
|
||||
|
||||
ReferralLead? lead = null;
|
||||
CrmWebhookQueueItem? crmQueue = null;
|
||||
@@ -164,16 +164,29 @@ public sealed class ReferralService(
|
||||
false,
|
||||
command.Metadata,
|
||||
cancellationToken);
|
||||
if (lead.FirstTrackId is null)
|
||||
var setFirstTrack = lead.FirstTrackId is null;
|
||||
if (setFirstTrack)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
dbContext.ReferralTracks.Add(track);
|
||||
track.LeadId = lead.Id;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (setFirstTrack)
|
||||
{
|
||||
lead.FirstTrackId = track.Id;
|
||||
}
|
||||
|
||||
track.LeadId = lead.Id;
|
||||
crmQueue = await EnqueueCrmIfEnabledAsync(actor.TenantId, lead, "referral.track_event", cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
dbContext.ReferralTracks.Add(track);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
return new ReferralTrackResult(ToTrackItem(track), lead is null ? null : ToLeadItem(lead, true), ToQueuePreview(crmQueue));
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,14 @@ internal sealed class TenantSecretConfiguration : IEntityTypeConfiguration<Tenan
|
||||
builder.Property(entity => entity.SecretKey).HasMaxLength(120);
|
||||
builder.Property(entity => entity.SecretRef).HasMaxLength(300);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.SecretPayload).IsJson("{}");
|
||||
builder.Property(entity => entity.EncryptionKeyId).HasMaxLength(100);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.SecretRef }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.SecretKey }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Purpose, entity.Provider, entity.Status });
|
||||
builder.ToTable(table => table.HasCheckConstraint(
|
||||
"ck_tenant_secrets_encryption_envelope",
|
||||
"octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and " +
|
||||
"octet_length(encryption_tag) = 16 and encryption_key_id <> ''"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16434
Tiku.Infrastructure/Persistence/Migrations/20260727062859_EncryptTenantSecretPayloads.Designer.cs
generated
Normal file
16434
Tiku.Infrastructure/Persistence/Migrations/20260727062859_EncryptTenantSecretPayloads.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EncryptTenantSecretPayloads : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "secret_payload",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "encrypted_payload",
|
||||
table: "tenant_secrets",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "encryption_key_id",
|
||||
table: "tenant_secrets",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "encryption_nonce",
|
||||
table: "tenant_secrets",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "encryption_tag",
|
||||
table: "tenant_secrets",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "ck_tenant_secrets_encryption_envelope",
|
||||
table: "tenant_secrets",
|
||||
sql: "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "ck_tenant_secrets_encryption_envelope",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encrypted_payload",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encryption_key_id",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encryption_nonce",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encryption_tag",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "secret_payload",
|
||||
table: "tenant_secrets",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::jsonb");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12958,6 +12958,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<byte[]>("EncryptedPayload")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encrypted_payload");
|
||||
|
||||
b.Property<string>("EncryptionKeyId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("encryption_key_id");
|
||||
|
||||
b.Property<byte[]>("EncryptionNonce")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encryption_nonce");
|
||||
|
||||
b.Property<byte[]>("EncryptionTag")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("encryption_tag");
|
||||
|
||||
b.Property<DateTimeOffset?>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
@@ -12984,12 +13005,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(120)")
|
||||
.HasColumnName("secret_key");
|
||||
|
||||
b.Property<JsonElement>("SecretPayload")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("secret_payload")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("SecretRef")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
@@ -13029,7 +13044,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("TenantId", "Purpose", "Provider", "Status")
|
||||
.HasDatabaseName("ix_tenant_secrets_tenant_id_purpose_provider_status");
|
||||
|
||||
b.ToTable("tenant_secrets", (string)null);
|
||||
b.ToTable("tenant_secrets", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_tenant_secrets_encryption_envelope", "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -222,6 +223,9 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
|
||||
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
||||
{
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
var emptySubQuestions = JsonDefaults.Array();
|
||||
return
|
||||
from question in questions
|
||||
join version in dbContext.QuestionVersions.AsNoTracking()
|
||||
@@ -253,12 +257,12 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
version == null ? null : version.Id,
|
||||
version == null ? null : version.VersionNo,
|
||||
version == null ? null : version.Content,
|
||||
version == null ? default : version.Options,
|
||||
version == null ? emptyOptions : version.Options,
|
||||
version == null ? null : version.CorrectOptionIndex,
|
||||
version == null ? default : version.CorrectOptionIndices,
|
||||
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
|
||||
version == null ? null : version.AnswerText,
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? default : version.SubQuestions,
|
||||
version == null ? emptySubQuestions : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate);
|
||||
}
|
||||
|
||||
@@ -1263,7 +1263,7 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
|
||||
GrantedBy = actor.UserId,
|
||||
GrantedAt = command.GrantedAt ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId ?? $"badge:{command.BadgeId}:user:{command.UserId}";
|
||||
grant.LegacyId = Normalize(command.LegacyId) ?? grant.LegacyId;
|
||||
grant.Note = Normalize(command.Note) ?? grant.Note;
|
||||
grant.GrantedBy ??= actor.UserId;
|
||||
grant.GrantedAt ??= command.GrantedAt ?? DateTimeOffset.UtcNow;
|
||||
@@ -1272,19 +1272,20 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
|
||||
dbContext.UserBadges.Add(grant);
|
||||
}
|
||||
|
||||
var dedupeKey = $"badge:{grant.Id:N}";
|
||||
var notification = await dbContext.UserNotifications.FirstOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.NotificationType == "badge_granted" &&
|
||||
item.DedupeKey == grant.LegacyId,
|
||||
item.DedupeKey == dedupeKey,
|
||||
cancellationToken);
|
||||
notification ??= new UserNotification
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = command.UserId,
|
||||
NotificationType = "badge_granted",
|
||||
DedupeKey = grant.LegacyId,
|
||||
DedupeKey = dedupeKey,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
notification.Severity = NotificationSeverity.Success;
|
||||
|
||||
Reference in New Issue
Block a user