From 5c2cb7681228bbfdb4f8b84185d06083ae7c49c2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 6 Mar 2026 17:27:49 -0800 Subject: [PATCH 01/32] AB#31677 mq integration portal --- .../GrantManagerApplicationModule.cs | 19 ++ .../GrantsPortalRabbitMqOptions.cs | 22 ++ .../GrantsPortalAcknowledgmentPublisher.cs | 59 ++++ .../GrantsPortalCommandConsumerService.cs | 284 ++++++++++++++++++ .../GrantsPortalInboxProcessorService.cs | 223 ++++++++++++++ .../GrantsPortalMessageCleanupService.cs | 70 +++++ .../GrantsPortalOutboxProcessorService.cs | 192 ++++++++++++ .../Handlers/AddressEditHandler.cs | 55 ++++ .../Handlers/AddressSetPrimaryHandler.cs | 32 ++ .../Handlers/ContactCreateHandler.cs | 69 +++++ .../Handlers/ContactDeleteHandler.cs | 37 +++ .../Handlers/ContactEditHandler.cs | 43 +++ .../Handlers/ContactSetPrimaryHandler.cs | 40 +++ .../Handlers/IPortalCommandHandler.cs | 10 + .../Handlers/OrganizationEditHandler.cs | 50 +++ .../Messages/Commands/AddressEditData.cs | 33 ++ .../Messages/Commands/ContactCreateData.cs | 36 +++ .../Messages/Commands/ContactEditData.cs | 36 +++ .../Messages/Commands/OrganizationEditData.cs | 30 ++ .../Messages/MessageAcknowledgment.cs | 34 +++ .../Messages/PluginDataEnvelope.cs | 29 ++ .../Messages/PluginDataPayload.cs | 28 ++ .../Messaging/IInboxMessageRepository.cs | 13 + .../Messaging/IOutboxMessageRepository.cs | 12 + .../Messaging/InboxMessage.cs | 71 +++++ .../Messaging/OutboxMessage.cs | 68 +++++ .../Unity.GrantManager.Domain.csproj | 4 + .../GrantManagerDbContext.cs | 50 ++- .../Repositories/InboxMessageRepository.cs | 43 +++ .../Repositories/OutboxMessageRepository.cs | 37 +++ .../Repositories/PortalMessageRepository.cs | 0 .../Unity.GrantManager.Web/appsettings.json | 10 +- 32 files changed, 1737 insertions(+), 2 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 0f864af8e3..8aaefd869f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -41,6 +41,9 @@ using Unity.GrantManager.Integrations.Chefs; using Unity.Modules.Shared.Http; using Unity.GrantManager.Integrations.Geocoder; +using Unity.GrantManager.GrantsPortal; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Handlers; namespace Unity.GrantManager; @@ -147,6 +150,22 @@ public override void ConfigureServices(ServiceConfigurationContext context) } context.Services.ConfigureRabbitMQ(); + + // Grants Applicant Portal RabbitMQ integration + context.Services.Configure(configuration.GetSection(GrantsPortalRabbitMqOptions.SectionName)); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddScoped(); + context.Services.AddHostedService(); // RabbitMQ → inbox table + context.Services.AddHostedService(); // inbox table → process → outbox table + context.Services.AddHostedService(); // outbox table → RabbitMQ + context.Services.AddHostedService(); // purge old processed messages + context.Services.AddScoped(); context.Services.AddSingleton(provider => diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs new file mode 100644 index 0000000000..26fd4799d2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs @@ -0,0 +1,22 @@ +namespace Unity.GrantManager.GrantsPortal.Configuration; + +public class GrantsPortalRabbitMqOptions +{ + public const string SectionName = "RabbitMQ:GrantsPortal"; + + /// + /// The integration source identifier used in the IntegrationMessages table. + /// + public const string SourceName = "GrantsPortal"; + + public string Exchange { get; set; } = "grants.messaging"; + public string ExchangeType { get; set; } = "topic"; + public string InboundQueue { get; set; } = "unity.commands"; + public string[] InboundRoutingKeys { get; set; } = ["commands.unity.plugindata"]; + public string AckRoutingKey { get; set; } = "grants.unity.acknowledgment"; + + /// + /// Number of days to retain processed/failed messages before cleanup. + /// + public int MessageRetentionDays { get; set; } = 30; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs new file mode 100644 index 0000000000..c3aeda6258 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs @@ -0,0 +1,59 @@ +using System; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; + +namespace Unity.GrantManager.GrantsPortal; + +public class GrantsPortalAcknowledgmentPublisher( + IOptions options, + ILogger logger) +{ + private readonly GrantsPortalRabbitMqOptions _options = options.Value; + private static readonly JsonSerializerSettings s_jsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }; + + public void Publish(IModel channel, string originalMessageId, string correlationId, string status, string details) + { + var ack = new MessageAcknowledgment + { + MessageId = Guid.NewGuid().ToString(), + OriginalMessageId = originalMessageId, + CorrelationId = correlationId, + Status = status, + Details = details, + CreatedAt = DateTime.UtcNow, + ProcessedAt = DateTime.UtcNow + }; + + var json = JsonConvert.SerializeObject(ack, s_jsonSettings); + var body = Encoding.UTF8.GetBytes(json); + + var properties = channel.CreateBasicProperties(); + properties.Type = "MessageAcknowledgment"; + properties.ContentType = "application/json"; + properties.ContentEncoding = "utf-8"; + properties.Persistent = true; + properties.MessageId = ack.MessageId; + properties.CorrelationId = correlationId; + properties.Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + channel.BasicPublish( + exchange: _options.Exchange, + routingKey: _options.AckRoutingKey, + basicProperties: properties, + body: body); + + logger.LogInformation( + "Published {Status} acknowledgment for message {OriginalMessageId} with ack id {AckMessageId}", + status, originalMessageId, ack.MessageId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs new file mode 100644 index 0000000000..7631fad188 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -0,0 +1,284 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Pulls messages off the RabbitMQ queue, saves them to the inbox table, and ACKs immediately. +/// Actual processing is done by . +/// +public class GrantsPortalCommandConsumerService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IAsyncConnectionFactory _connectionFactory; + private readonly GrantsPortalRabbitMqOptions _options; + private readonly ILogger _logger; + + private IConnection? _connection; + private IModel? _channel; + + private const int MaxRetries = 5; + private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); + private CancellationToken _stoppingToken; + + public GrantsPortalCommandConsumerService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) + { + _serviceProvider = serviceProvider; + _connectionFactory = connectionFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _stoppingToken = stoppingToken; + _logger.LogInformation("Grants Portal command consumer starting..."); + + await ConnectAndConsumeAsync(stoppingToken); + + // Keep the service alive until cancellation + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Grants Portal command consumer stopping..."); + } + } + + private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) + { + for (int attempt = 1; attempt <= MaxRetries; attempt++) + { + try + { + _logger.LogInformation("Connecting to RabbitMQ for Grants Portal consumer (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); + + _connection = _connectionFactory.CreateConnection(); + _connection.ConnectionShutdown += OnConnectionShutdown; + _channel = _connection.CreateModel(); + _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + DeclareTopology(); + StartConsuming(); + + _logger.LogInformation("Grants Portal command consumer started. Listening on queue {Queue}", _options.InboundQueue); + return; + } + catch (Exception ex) when (attempt < MaxRetries) + { + var delay = TimeSpan.FromSeconds(InitialRetryDelay.TotalSeconds * Math.Pow(2, attempt - 1)); + _logger.LogWarning(ex, "Failed to connect to RabbitMQ (attempt {Attempt}). Retrying in {Delay}s...", attempt, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts", MaxRetries); + throw; + } + } + } + + private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) + { + if (_stoppingToken.IsCancellationRequested) return; + + _logger.LogWarning("RabbitMQ connection lost: {Reason}. Attempting to reconnect...", e.ReplyText); + + _ = Task.Run(async () => + { + await Task.Delay(InitialRetryDelay, _stoppingToken); + try + { + CleanupConnection(); + await ConnectAndConsumeAsync(_stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reconnect to RabbitMQ after connection loss"); + } + }, _stoppingToken); + } + + private void DeclareTopology() + { + if (_channel == null) return; + + _channel.ExchangeDeclare( + exchange: _options.Exchange, + type: _options.ExchangeType, + durable: true, + autoDelete: false); + + _channel.QueueDeclare( + queue: _options.InboundQueue, + durable: true, + exclusive: false, + autoDelete: false); + + foreach (var routingKey in _options.InboundRoutingKeys) + { + _channel.QueueBind( + queue: _options.InboundQueue, + exchange: _options.Exchange, + routingKey: routingKey); + } + + _logger.LogInformation( + "Declared exchange {Exchange} (topic), queue {Queue}, bound with routing keys [{RoutingKeys}]", + _options.Exchange, _options.InboundQueue, string.Join(", ", _options.InboundRoutingKeys)); + } + + private void StartConsuming() + { + if (_channel == null) return; + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.Received += OnMessageReceivedAsync; + + _channel.BasicConsume( + queue: _options.InboundQueue, + autoAck: false, + consumer: consumer); + } + + /// + /// Receives a message from RabbitMQ and saves it to the inbox table. + /// The message is ACKed immediately after saving — no processing happens here. + /// + private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs ea) + { + var messageId = ea.BasicProperties?.MessageId ?? string.Empty; + var messageType = ea.BasicProperties?.Type ?? string.Empty; + var correlationId = ea.BasicProperties?.CorrelationId ?? string.Empty; + var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; + + _logger.LogInformation("Received message {MessageId} type={MessageType}", messageId, messageType); + + // Guard: discard acknowledgment messages to prevent infinite loops (spec §4.2) + if (string.Equals(messageType, "MessageAcknowledgment", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Discarding acknowledgment message {MessageId} to prevent loop", messageId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + try + { + var json = Encoding.UTF8.GetString(ea.Body.ToArray()); + var envelope = JsonConvert.DeserializeObject(json); + + if (envelope == null) + { + _logger.LogError("Failed to deserialize message {MessageId}. Discarding.", messageId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + // Use envelope values as fallback for AMQP properties + if (string.IsNullOrEmpty(messageId)) messageId = envelope.MessageId; + if (string.IsNullOrEmpty(correlationId)) correlationId = envelope.CorrelationId; + + // Resolve tenant from the data.provider field (stored for later use by processors) + var payload = envelope.Data?.ToObject(); + var tenantId = ResolveTenantId(payload?.Provider); + + // Save to the central host inbox — no tenant context needed + using var scope = _serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + + // Idempotency: skip if we already have this message + var existing = await inboxRepo.FindByMessageIdAsync(messageId); + if (existing != null) + { + _logger.LogInformation("Message {MessageId} already in inbox (status={Status}). Skipping.", messageId, existing.Status); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + var inboxMessage = new InboxMessage + { + Source = GrantsPortalRabbitMqOptions.SourceName, + MessageId = messageId, + CorrelationId = correlationId, + DataType = envelope.DataType, + Payload = json, + Status = MessageStatus.Pending, + ReceivedAt = DateTime.UtcNow, + TenantId = tenantId + }; + + await inboxRepo.InsertAsync(inboxMessage, autoSave: true); + await uow.CompleteAsync(); + + _logger.LogInformation("Message {MessageId} saved to inbox for processing", messageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); + consumingChannel.BasicReject(ea.DeliveryTag, requeue: true); + return; + } + + // ACK only after successful save to inbox + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + } + + private static Guid? ResolveTenantId(string? provider) + { + if (string.IsNullOrWhiteSpace(provider)) + return null; + + if (Guid.TryParse(provider, out var tenantGuid)) + return tenantGuid; + + return null; + } + + private void CleanupConnection() + { + try + { + if (_connection != null) _connection.ConnectionShutdown -= OnConnectionShutdown; + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error during connection cleanup"); + } + + _channel = null; + _connection = null; + } + + public override void Dispose() + { + CleanupConnection(); + base.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs new file mode 100644 index 0000000000..f4d805c175 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central inbox table for pending inbound messages and processes them sequentially. +/// Switches to the correct tenant context only when executing the handler (domain operations). +/// On completion (success or failure), writes an outbound ack message to the same central table. +/// +public class GrantsPortalInboxProcessorService( + IServiceProvider serviceProvider, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); + private const int MaxRetryCount = 3; + + private static readonly Dictionary s_userFriendlyErrors = new(StringComparer.OrdinalIgnoreCase) + { + { "EntityNotFoundException", "The requested record was not found. It may have been deleted." }, + { "DbUpdateConcurrencyException", "The record was modified by another process. Please try again." }, + { "AbpDbConcurrencyException", "The record was modified by another process. Please try again." } + }; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Grants Portal inbox processor starting..."); + + // Wait for the application to fully start + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var processedAny = await ProcessPendingMessagesAsync(stoppingToken); + var delay = processedAny ? PollingInterval : IdleInterval; + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error in inbox processor loop"); + await Task.Delay(IdleInterval, stoppingToken); + } + } + + logger.LogInformation("Grants Portal inbox processor stopped."); + } + + private async Task ProcessPendingMessagesAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await inboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return false; + + foreach (var inboxMsg in pendingMessages) + { + if (cancellationToken.IsCancellationRequested) break; + + await ProcessSingleMessageAsync(scope, inboxMsg); + } + + return true; + } + + private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage inboxMsg) + { + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + var currentTenant = scope.ServiceProvider.GetRequiredService(); + var handlers = scope.ServiceProvider.GetServices(); + + logger.LogInformation("Processing inbox message {MessageId} (dataType={DataType}, tenantId={TenantId})", + inboxMsg.MessageId, inboxMsg.DataType, inboxMsg.TenantId); + + string ackStatus; + string details; + + try + { + // Mark as processing + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + inboxMsg.Status = MessageStatus.Processing; + inboxMsg.RetryCount++; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + + // Deserialize the payload + var envelope = JsonConvert.DeserializeObject(inboxMsg.Payload) + ?? throw new JsonException("Failed to deserialize message payload"); + + var payload = envelope.Data?.ToObject() + ?? throw new ArgumentException("Message data payload is missing"); + + var handler = handlers.FirstOrDefault(h => + string.Equals(h.DataType, inboxMsg.DataType, StringComparison.OrdinalIgnoreCase)); + + if (handler == null) + { + ackStatus = "FAILED"; + details = $"Unknown command type: {inboxMsg.DataType}"; + logger.LogWarning("No handler registered for dataType {DataType}", inboxMsg.DataType); + } + else + { + // Switch to tenant context ONLY for the domain handler execution + using (currentTenant.Change(inboxMsg.TenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + details = await handler.HandleAsync(payload); + await uow.CompleteAsync(); + } + ackStatus = "SUCCESS"; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing inbox message {MessageId}", inboxMsg.MessageId); + ackStatus = "FAILED"; + details = ToUserFriendlyMessage(ex); + + // Check if we should retry + if (inboxMsg.RetryCount < MaxRetryCount && IsTransientError(ex)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + inboxMsg.Status = MessageStatus.Pending; + inboxMsg.Details = details; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + await uow.CompleteAsync(); + logger.LogInformation("Message {MessageId} will be retried (attempt {Attempt}/{MaxRetries})", + inboxMsg.MessageId, inboxMsg.RetryCount, MaxRetryCount); + return; + } + } + + // Mark inbox as complete + write to outbox — same transaction + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + inboxMsg.Status = ackStatus == "SUCCESS" ? MessageStatus.Processed : MessageStatus.Failed; + inboxMsg.Details = details; + inboxMsg.ProcessedAt = DateTime.UtcNow; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + + var outboxMsg = new OutboxMessage + { + Source = GrantsPortalRabbitMqOptions.SourceName, + MessageId = Guid.NewGuid().ToString(), + OriginalMessageId = inboxMsg.MessageId, + CorrelationId = inboxMsg.CorrelationId, + DataType = inboxMsg.DataType, + AckStatus = ackStatus, + Details = details, + Status = MessageStatus.Pending, + CreatedAt = DateTime.UtcNow, + TenantId = inboxMsg.TenantId + }; + + await outboxRepo.InsertAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + + logger.LogInformation("Inbox message {MessageId} processed with status {Status}", + inboxMsg.MessageId, ackStatus); + } + + private static string ToUserFriendlyMessage(Exception ex) + { + var exType = ex.GetType().Name; + + if (s_userFriendlyErrors.TryGetValue(exType, out var friendly)) + return friendly; + + // Check inner exception type + if (ex.InnerException != null) + { + var innerType = ex.InnerException.GetType().Name; + if (s_userFriendlyErrors.TryGetValue(innerType, out var innerFriendly)) + return innerFriendly; + } + + // For unrecognized exceptions, return a generic message — never leak stack traces + return "An unexpected error occurred while processing your request. Please try again or contact support."; + } + + private static bool IsTransientError(Exception ex) + { + var typeName = ex.GetType().Name; + return typeName.Contains("Timeout", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Concurrency", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Transient", StringComparison.OrdinalIgnoreCase) + || ex.InnerException is TimeoutException; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs new file mode 100644 index 0000000000..ef8915cc20 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Periodically deletes processed/failed messages older than the configured retention period. +/// Runs once per hour against the central host database. +/// +public class GrantsPortalMessageCleanupService( + IServiceProvider serviceProvider, + IOptions options, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1); + private readonly int _retentionDays = options.Value.MessageRetentionDays; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Integration message cleanup service starting (retention={RetentionDays} days)", _retentionDays); + + // Wait for the application to fully start + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CleanupOldMessagesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during integration message cleanup"); + } + + await Task.Delay(CleanupInterval, stoppingToken); + } + } + + private async Task CleanupOldMessagesAsync() + { + var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); + + using var scope = serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + var inboxDeleted = await inboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + var outboxDeleted = await outboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + await uow.CompleteAsync(); + + var total = inboxDeleted + outboxDeleted; + if (total > 0) + { + logger.LogInformation( + "Cleaned up {Total} messages older than {CutoffDate:yyyy-MM-dd} (inbox={InboxCount}, outbox={OutboxCount})", + total, cutoffDate, inboxDeleted, outboxDeleted); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs new file mode 100644 index 0000000000..566e378535 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central outbox table for pending acknowledgment messages and publishes them to RabbitMQ. +/// Uses publisher confirms to ensure delivery before marking messages as sent. +/// No tenant context needed — the outbox table is in the host database. +/// +public class GrantsPortalOutboxProcessorService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IAsyncConnectionFactory _connectionFactory; + private readonly GrantsPortalRabbitMqOptions _options; + private readonly ILogger _logger; + + private IConnection? _connection; + private IModel? _channel; + + private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); + private const int MaxPublishRetries = 3; + + public GrantsPortalOutboxProcessorService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) + { + _serviceProvider = serviceProvider; + _connectionFactory = connectionFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Grants Portal outbox processor starting..."); + + // Wait for the application to fully start + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + EnsureChannel(); + var processedAny = await PublishPendingAcksAsync(stoppingToken); + var delay = processedAny ? PollingInterval : IdleInterval; + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in outbox processor loop. Will retry after delay."); + CleanupChannel(); + await Task.Delay(IdleInterval, stoppingToken); + } + } + + _logger.LogInformation("Grants Portal outbox processor stopped."); + } + + private void EnsureChannel() + { + if (_channel is { IsOpen: true }) return; + + CleanupChannel(); + + _connection = _connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + _channel.ConfirmSelect(); + + _logger.LogInformation("Outbox processor RabbitMQ channel established"); + } + + private async Task PublishPendingAcksAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await outboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return false; + + foreach (var outboxMsg in pendingMessages) + { + if (cancellationToken.IsCancellationRequested) break; + + await PublishSingleAckAsync(outboxMsg, outboxRepo, unitOfWorkManager); + } + + return true; + } + + private async Task PublishSingleAckAsync( + OutboxMessage outboxMsg, + IOutboxMessageRepository outboxRepo, + IUnitOfWorkManager unitOfWorkManager) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var publisher = scope.ServiceProvider.GetRequiredService(); + + publisher.Publish( + _channel!, + outboxMsg.OriginalMessageId, + outboxMsg.CorrelationId, + outboxMsg.AckStatus, + outboxMsg.Details); + + // Wait for broker to confirm + if (!_channel!.WaitForConfirms(TimeSpan.FromSeconds(5))) + { + throw new InvalidOperationException("Broker did not confirm ack publish"); + } + + // Mark as sent + using var uow = unitOfWorkManager.Begin(requiresNew: true); + outboxMsg.Status = MessageStatus.Processed; + outboxMsg.PublishedAt = DateTime.UtcNow; + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + + _logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", + outboxMsg.MessageId, outboxMsg.OriginalMessageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); + + outboxMsg.RetryCount++; + if (outboxMsg.RetryCount >= MaxPublishRetries) + { + outboxMsg.Status = MessageStatus.Failed; + outboxMsg.Details = $"Failed to publish after {MaxPublishRetries} attempts: {ex.Message}"; + _logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", + outboxMsg.MessageId, MaxPublishRetries); + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + } + + private void CleanupChannel() + { + try + { + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error during outbox channel cleanup"); + } + + _channel = null; + _connection = null; + } + + public override void Dispose() + { + CleanupChannel(); + base.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs new file mode 100644 index 0000000000..96df0b8d0b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressEditHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Address data is required"); + + logger.LogInformation("Editing address {AddressId} for profile {ProfileId}", addressId, payload.ProfileId); + + var address = await applicantAddressRepository.GetAsync(addressId); + + address.Street = innerData.Street; + address.Street2 = innerData.Street2; + address.Unit = innerData.Unit; + address.City = innerData.City; + address.Province = innerData.Province; + address.Postal = innerData.PostalCode; + address.Country = innerData.Country; + address.AddressType = MapAddressType(innerData.AddressType); + + await applicantAddressRepository.UpdateAsync(address, autoSave: true); + + logger.LogInformation("Address {AddressId} updated successfully", addressId); + return "Address updated successfully"; + } + + private static GrantApplications.AddressType MapAddressType(string? portalAddressType) + { + return portalAddressType?.ToUpperInvariant() switch + { + "MAILING" => GrantApplications.AddressType.MailingAddress, + "PHYSICAL" => GrantApplications.AddressType.PhysicalAddress, + "BUSINESS" => GrantApplications.AddressType.BusinessAddress, + _ => GrantApplications.AddressType.PhysicalAddress + }; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs new file mode 100644 index 0000000000..c6e3efe2eb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressSetPrimaryHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_SET_PRIMARY_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + + logger.LogInformation("Setting address {AddressId} as primary for profile {ProfileId}", addressId, profileId); + + // TODO: Implement set-primary logic once the primary address tracking mechanism is confirmed. + // The ApplicantAddress entity does not currently have an IsPrimary field. + // This may require updating sibling addresses for the same applicant. + + logger.LogInformation("Address {AddressId} set as primary", addressId); + return "Address set as primary"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs new file mode 100644 index 0000000000..8a24a76415 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactCreateHandler( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_CREATE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Contact data is required"); + + // Idempotency: if the contact already exists, treat as success + var existing = await contactRepository.FindAsync(contactId); + if (existing != null) + { + logger.LogInformation("Contact {ContactId} already exists. Treating as idempotent success.", contactId); + return "Contact already exists"; + } + + logger.LogInformation("Creating contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + var contact = new Contact + { + Name = innerData.Name, + Email = innerData.Email, + Title = innerData.Title, + HomePhoneNumber = innerData.HomePhoneNumber, + MobilePhoneNumber = innerData.MobilePhoneNumber, + WorkPhoneNumber = innerData.WorkPhoneNumber, + WorkPhoneExtension = innerData.WorkPhoneExtension + }; + + EntityHelper.TrySetId(contact, () => contactId); + + await contactRepository.InsertAsync(contact, autoSave: true); + + // Create a contact link to track the relationship and primary status + var contactLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = innerData.ContactType ?? "PORTAL", + RelatedEntityId = Guid.Parse(payload.ProfileId ?? Guid.Empty.ToString()), + Role = innerData.Role, + IsPrimary = innerData.IsPrimary, + IsActive = true + }; + + await contactLinkRepository.InsertAsync(contactLink, autoSave: true); + + logger.LogInformation("Contact {ContactId} created successfully", contactId); + return "Contact created successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs new file mode 100644 index 0000000000..128f5a5e10 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactDeleteHandler( + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_DELETE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + + logger.LogInformation("Deleting (deactivating) contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + // Soft-delete by deactivating contact links + var links = await contactLinkRepository.GetListAsync(cl => cl.ContactId == contactId && cl.IsActive); + + foreach (var link in links) + { + link.IsActive = false; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + logger.LogInformation("Contact {ContactId} deactivated successfully", contactId); + return "Contact deleted successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs new file mode 100644 index 0000000000..b3fcdeb129 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactEditHandler( + IContactRepository contactRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Contact data is required"); + + logger.LogInformation("Editing contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + var contact = await contactRepository.GetAsync(contactId); + + contact.Name = innerData.Name; + contact.Email = innerData.Email; + contact.Title = innerData.Title; + contact.HomePhoneNumber = innerData.HomePhoneNumber; + contact.MobilePhoneNumber = innerData.MobilePhoneNumber; + contact.WorkPhoneNumber = innerData.WorkPhoneNumber; + contact.WorkPhoneExtension = innerData.WorkPhoneExtension; + + await contactRepository.UpdateAsync(contact, autoSave: true); + + logger.LogInformation("Contact {ContactId} updated successfully", contactId); + return "Contact updated successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs new file mode 100644 index 0000000000..18a49cb974 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactSetPrimaryHandler( + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_SET_PRIMARY_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + + logger.LogInformation("Setting contact {ContactId} as primary for profile {ProfileId}", contactId, profileId); + + // Find all contact links for this profile and clear their primary flag + var profileLinks = await contactLinkRepository.GetListAsync( + cl => cl.RelatedEntityId == profileId && cl.IsActive); + + foreach (var link in profileLinks) + { + link.IsPrimary = link.ContactId == contactId; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + logger.LogInformation("Contact {ContactId} set as primary", contactId); + return "Contact set as primary"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs new file mode 100644 index 0000000000..88925c7ee2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Unity.GrantManager.GrantsPortal.Messages; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public interface IPortalCommandHandler +{ + string DataType { get; } + Task HandleAsync(PluginDataPayload payload); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs new file mode 100644 index 0000000000..87b1306ac0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class OrganizationEditHandler( + IApplicantRepository applicantRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ORGANIZATION_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Organization data is required"); + + logger.LogInformation("Editing organization for profile {ProfileId}", payload.ProfileId); + + // TODO: Determine the correct lookup strategy for the Applicant entity. + // For now, use organizationId from the payload as a direct Applicant ID. + var organizationId = Guid.Parse(payload.OrganizationId ?? throw new ArgumentException("organizationId is required")); + var applicant = await applicantRepository.GetAsync(organizationId); + + applicant.OrgName = innerData.Name; + applicant.OrganizationType = innerData.OrganizationType; + applicant.OrgNumber = innerData.OrganizationNumber; + applicant.OrgStatus = innerData.Status; + applicant.NonRegOrgName = innerData.NonRegOrgName; + applicant.FiscalMonth = innerData.FiscalMonth; + applicant.OrganizationSize = innerData.OrganizationSize; + + if (int.TryParse(innerData.FiscalDay, out var fiscalDay)) + { + applicant.FiscalDay = fiscalDay; + } + + await applicantRepository.UpdateAsync(applicant, autoSave: true); + + logger.LogInformation("Organization {OrganizationId} updated successfully", organizationId); + return "Organization updated successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs new file mode 100644 index 0000000000..3d6c655fb4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class AddressEditData +{ + [JsonProperty("addressType")] + public string? AddressType { get; set; } + + [JsonProperty("street")] + public string Street { get; set; } = string.Empty; + + [JsonProperty("street2")] + public string? Street2 { get; set; } + + [JsonProperty("unit")] + public string? Unit { get; set; } + + [JsonProperty("city")] + public string City { get; set; } = string.Empty; + + [JsonProperty("province")] + public string Province { get; set; } = string.Empty; + + [JsonProperty("postalCode")] + public string PostalCode { get; set; } = string.Empty; + + [JsonProperty("country")] + public string? Country { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs new file mode 100644 index 0000000000..e89566c42d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class ContactCreateData +{ + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("email")] + public string Email { get; set; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; set; } + + [JsonProperty("contactType")] + public string? ContactType { get; set; } + + [JsonProperty("homePhoneNumber")] + public string? HomePhoneNumber { get; set; } + + [JsonProperty("mobilePhoneNumber")] + public string? MobilePhoneNumber { get; set; } + + [JsonProperty("workPhoneNumber")] + public string? WorkPhoneNumber { get; set; } + + [JsonProperty("workPhoneExtension")] + public string? WorkPhoneExtension { get; set; } + + [JsonProperty("role")] + public string? Role { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs new file mode 100644 index 0000000000..a4f4f5d605 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class ContactEditData +{ + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("email")] + public string Email { get; set; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; set; } + + [JsonProperty("contactType")] + public string? ContactType { get; set; } + + [JsonProperty("homePhoneNumber")] + public string? HomePhoneNumber { get; set; } + + [JsonProperty("mobilePhoneNumber")] + public string? MobilePhoneNumber { get; set; } + + [JsonProperty("workPhoneNumber")] + public string? WorkPhoneNumber { get; set; } + + [JsonProperty("workPhoneExtension")] + public string? WorkPhoneExtension { get; set; } + + [JsonProperty("role")] + public string? Role { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs new file mode 100644 index 0000000000..e2d08dc007 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class OrganizationEditData +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("organizationType")] + public string? OrganizationType { get; set; } + + [JsonProperty("organizationNumber")] + public string? OrganizationNumber { get; set; } + + [JsonProperty("status")] + public string? Status { get; set; } + + [JsonProperty("nonRegOrgName")] + public string? NonRegOrgName { get; set; } + + [JsonProperty("fiscalMonth")] + public string? FiscalMonth { get; set; } + + [JsonProperty("fiscalDay")] + public string? FiscalDay { get; set; } + + [JsonProperty("organizationSize")] + public string? OrganizationSize { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs new file mode 100644 index 0000000000..fdb25805e7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs @@ -0,0 +1,34 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class MessageAcknowledgment +{ + [JsonProperty("messageId")] + public string MessageId { get; set; } = Guid.NewGuid().ToString(); + + [JsonProperty("messageType")] + public string MessageType { get; set; } = "MessageAcknowledgment"; + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [JsonProperty("correlationId")] + public string CorrelationId { get; set; } = string.Empty; + + [JsonProperty("pluginId")] + public string PluginId { get; set; } = "UNITY"; + + [JsonProperty("originalMessageId")] + public string OriginalMessageId { get; set; } = string.Empty; + + [JsonProperty("status")] + public string Status { get; set; } = string.Empty; + + [JsonProperty("details")] + public string Details { get; set; } = string.Empty; + + [JsonProperty("processedAt")] + public DateTime ProcessedAt { get; set; } = DateTime.UtcNow; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs new file mode 100644 index 0000000000..926d80a067 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs @@ -0,0 +1,29 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class PluginDataEnvelope +{ + [JsonProperty("messageId")] + public string MessageId { get; set; } = string.Empty; + + [JsonProperty("messageType")] + public string MessageType { get; set; } = string.Empty; + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("correlationId")] + public string CorrelationId { get; set; } = string.Empty; + + [JsonProperty("pluginId")] + public string PluginId { get; set; } = string.Empty; + + [JsonProperty("dataType")] + public string DataType { get; set; } = string.Empty; + + [JsonProperty("data")] + public JObject? Data { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs new file mode 100644 index 0000000000..f6109989a4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class PluginDataPayload +{ + [JsonProperty("action")] + public string Action { get; set; } = string.Empty; + + [JsonProperty("contactId")] + public string? ContactId { get; set; } + + [JsonProperty("addressId")] + public string? AddressId { get; set; } + + [JsonProperty("organizationId")] + public string? OrganizationId { get; set; } + + [JsonProperty("profileId")] + public string? ProfileId { get; set; } + + [JsonProperty("provider")] + public string? Provider { get; set; } + + [JsonProperty("data")] + public JObject? Data { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs new file mode 100644 index 0000000000..795eef08fa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.Messaging; + +public interface IInboxMessageRepository : IRepository +{ + Task FindByMessageIdAsync(string messageId); + Task> GetPendingAsync(string source, int maxCount = 10); + Task DeleteProcessedOlderThanAsync(DateTime cutoffDate); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs new file mode 100644 index 0000000000..01a1cd6740 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.Messaging; + +public interface IOutboxMessageRepository : IRepository +{ + Task> GetPendingAsync(string source, int maxCount = 10); + Task DeleteProcessedOlderThanAsync(DateTime cutoffDate); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs new file mode 100644 index 0000000000..c748b75390 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs @@ -0,0 +1,71 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Messaging; + +public enum MessageStatus +{ + Pending = 1, + Processing = 2, + Processed = 3, + Failed = 4 +} + +/// +/// A message received from an external system, stored for sequential processing. +/// +public class InboxMessage : AuditedAggregateRoot, IMultiTenant +{ + /// + /// Identifies the integration source (e.g. "GrantsPortal"). + /// + public string Source { get; set; } = string.Empty; + + /// + /// The message ID from the source system. Used for idempotency. + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// The correlation ID passed through from the source system. + /// + public string CorrelationId { get; set; } = string.Empty; + + /// + /// The command discriminator (e.g. CONTACT_CREATE_COMMAND). + /// + public string DataType { get; set; } = string.Empty; + + /// + /// The full JSON payload of the inbound message. + /// + public string Payload { get; set; } = string.Empty; + + /// + /// Current processing status. + /// + public MessageStatus Status { get; set; } = MessageStatus.Pending; + + /// + /// Human-readable details (processing result or error message). + /// + public string? Details { get; set; } + + /// + /// Number of processing attempts. + /// + public int RetryCount { get; set; } + + /// + /// When the message was received from the broker. + /// + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the message was successfully processed. + /// + public DateTime? ProcessedAt { get; set; } + + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs new file mode 100644 index 0000000000..56537ee16a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs @@ -0,0 +1,68 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Messaging; + +/// +/// An acknowledgment or response message to be published to an external system. +/// +public class OutboxMessage : AuditedAggregateRoot, IMultiTenant +{ + /// + /// Identifies the integration target (e.g. "GrantsPortal"). + /// + public string Source { get; set; } = string.Empty; + + /// + /// A unique message ID for this outbound message. + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// The message ID of the original inbound command this is responding to. + /// + public string OriginalMessageId { get; set; } = string.Empty; + + /// + /// The correlation ID passed through from the original inbound message. + /// + public string CorrelationId { get; set; } = string.Empty; + + /// + /// The data type of the original command (e.g. CONTACT_CREATE_COMMAND). + /// + public string DataType { get; set; } = string.Empty; + + /// + /// The acknowledgment status: SUCCESS, FAILED, or PROCESSING. + /// + public string AckStatus { get; set; } = string.Empty; + + /// + /// Human-readable details (shown to the Portal user on failure). + /// + public string Details { get; set; } = string.Empty; + + /// + /// Current publish status. + /// + public MessageStatus Status { get; set; } = MessageStatus.Pending; + + /// + /// Number of publish attempts. + /// + public int RetryCount { get; set; } + + /// + /// When the outbox message was created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the message was successfully published to the broker. + /// + public DateTime? PublishedAt { get; set; } + + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index 096adad348..5552818a00 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -33,4 +33,8 @@ + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 498c00a127..b7ad146b28 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -19,6 +19,8 @@ using AppAny.Quartz.EntityFrameworkCore.Migrations; using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL; using Unity.GrantManager.Integrations; +using Unity.GrantManager.Messaging; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Unity.GrantManager.EntityFrameworkCore; @@ -42,6 +44,8 @@ public class GrantManagerDbContext : public DbSet RegionalDistricts { get; set; } public DbSet TenantTokens { get; set; } public DbSet Communities { get; set; } + public DbSet InboxMessages { get; set; } + public DbSet OutboxMessages { get; set; } #region Entities from the modules @@ -179,7 +183,51 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(x => x.OidcSubUsername); b.HasIndex(x => new { x.OidcSubUsername, x.TenantId }).IsUnique(); }); - + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "InboxMessages", + GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Source).IsRequired().HasMaxLength(50); + b.Property(x => x.MessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DataType).IsRequired().HasMaxLength(100); + b.Property(x => x.Payload).IsRequired().HasColumnType("jsonb"); + b.Property(x => x.Details).HasMaxLength(2000); + + b.Property(x => x.Status) + .IsRequired() + .HasConversion(new EnumToStringConverter()); + + b.HasIndex(x => x.MessageId); + b.HasIndex(x => new { x.Source, x.Status }); + }); + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "OutboxMessages", + GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Source).IsRequired().HasMaxLength(50); + b.Property(x => x.MessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.OriginalMessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DataType).IsRequired().HasMaxLength(100); + b.Property(x => x.AckStatus).IsRequired().HasMaxLength(20); + b.Property(x => x.Details).HasMaxLength(2000); + + b.Property(x => x.Status) + .IsRequired() + .HasConversion(new EnumToStringConverter()); + + b.HasIndex(x => new { x.Source, x.Status }); + }); + var allEntityTypes = modelBuilder.Model.GetEntityTypes(); foreach (var type in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary)).Select(t => t.ClrType)) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs new file mode 100644 index 0000000000..2fe97f303f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.EntityFrameworkCore; +using Unity.GrantManager.Messaging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IInboxMessageRepository))] +public class InboxMessageRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), IInboxMessageRepository +{ + public async Task FindByMessageIdAsync(string messageId) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.FirstOrDefaultAsync(m => m.MessageId == messageId); + } + + public async Task> GetPendingAsync(string source, int maxCount = 10) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(m => m.Source == source && m.Status == MessageStatus.Pending) + .OrderBy(m => m.ReceivedAt) + .Take(maxCount) + .ToListAsync(); + } + + public async Task DeleteProcessedOlderThanAsync(DateTime cutoffDate) + { + var dbContext = await GetDbContextAsync(); + return await dbContext.InboxMessages + .Where(m => (m.Status == MessageStatus.Processed || m.Status == MessageStatus.Failed) + && m.ReceivedAt < cutoffDate) + .ExecuteDeleteAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs new file mode 100644 index 0000000000..ec533caba2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.EntityFrameworkCore; +using Unity.GrantManager.Messaging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IOutboxMessageRepository))] +public class OutboxMessageRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), IOutboxMessageRepository +{ + public async Task> GetPendingAsync(string source, int maxCount = 10) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(m => m.Source == source && m.Status == MessageStatus.Pending) + .OrderBy(m => m.CreatedAt) + .Take(maxCount) + .ToListAsync(); + } + + public async Task DeleteProcessedOlderThanAsync(DateTime cutoffDate) + { + var dbContext = await GetDbContextAsync(); + return await dbContext.OutboxMessages + .Where(m => (m.Status == MessageStatus.Processed || m.Status == MessageStatus.Failed) + && m.CreatedAt < cutoffDate) + .ExecuteDeleteAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 7d5da8e4b4..eb29270cfa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -20,7 +20,15 @@ "HostName": "127.0.0.1", "Port": 5672, "UserName": "guest", - "VirtualHost": "/" + "VirtualHost": "/", + "GrantsPortal": { + "Exchange": "grants.messaging", + "ExchangeType": "topic", + "InboundQueue": "unity.commands", + "InboundRoutingKeys": [ "commands.unity.plugindata" ], + "AckRoutingKey": "grants.unity.acknowledgment", + "MessageRetentionDays": 30 + } }, "Payments": { "CasBaseUrl": "https://:/ords/cas/", From 5ad09664f976e7fc009ccea062ba55e2472a551e Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 9 Mar 2026 16:32:33 -0700 Subject: [PATCH 02/32] AB#31677 portal mq integration wip --- .../GrantsPortalCommandConsumerService.cs | 62 +- .../GrantsPortalInboxProcessorService.cs | 2 +- .../GrantsPortalOutboxProcessorService.cs | 49 +- .../Handlers/AddressEditHandler.cs | 10 +- .../Handlers/AddressSetPrimaryHandler.cs | 28 +- .../Handlers/ContactCreateHandler.cs | 1 - .../Handlers/ContactDeleteHandler.cs | 21 +- .../Handlers/ContactEditHandler.cs | 1 - .../Handlers/ContactSetPrimaryHandler.cs | 2 - .../Handlers/OrganizationEditHandler.cs | 1 - .../Messaging/InboxMessage.cs | 4 +- .../Messaging/OutboxMessage.cs | 4 +- ...013604_Add_InboxOutboxMessages.Designer.cs | 2873 +++++++++++++++++ .../20260307013604_Add_InboxOutboxMessages.cs | 97 + .../GrantManagerDbContextModelSnapshot.cs | 179 + 15 files changed, 3239 insertions(+), 95 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index 7631fad188..0cf879cb24 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -20,13 +20,13 @@ namespace Unity.GrantManager.GrantsPortal; /// Pulls messages off the RabbitMQ queue, saves them to the inbox table, and ACKs immediately. /// Actual processing is done by . /// -public class GrantsPortalCommandConsumerService : BackgroundService +public class GrantsPortalCommandConsumerService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) : BackgroundService { - private readonly IServiceProvider _serviceProvider; - private readonly IAsyncConnectionFactory _connectionFactory; - private readonly GrantsPortalRabbitMqOptions _options; - private readonly ILogger _logger; - + private readonly GrantsPortalRabbitMqOptions _options = options.Value; private IConnection? _connection; private IModel? _channel; @@ -34,22 +34,10 @@ public class GrantsPortalCommandConsumerService : BackgroundService private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); private CancellationToken _stoppingToken; - public GrantsPortalCommandConsumerService( - IServiceProvider serviceProvider, - IAsyncConnectionFactory connectionFactory, - IOptions options, - ILogger logger) - { - _serviceProvider = serviceProvider; - _connectionFactory = connectionFactory; - _options = options.Value; - _logger = logger; - } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _stoppingToken = stoppingToken; - _logger.LogInformation("Grants Portal command consumer starting..."); + logger.LogInformation("Grants Portal command consumer starting..."); await ConnectAndConsumeAsync(stoppingToken); @@ -60,7 +48,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (OperationCanceledException) { - _logger.LogInformation("Grants Portal command consumer stopping..."); + logger.LogInformation("Grants Portal command consumer stopping..."); } } @@ -70,9 +58,9 @@ private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) { try { - _logger.LogInformation("Connecting to RabbitMQ for Grants Portal consumer (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); + logger.LogInformation("Connecting to RabbitMQ for Grants Portal consumer (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); - _connection = _connectionFactory.CreateConnection(); + _connection = connectionFactory.CreateConnection(); _connection.ConnectionShutdown += OnConnectionShutdown; _channel = _connection.CreateModel(); _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); @@ -80,18 +68,18 @@ private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) DeclareTopology(); StartConsuming(); - _logger.LogInformation("Grants Portal command consumer started. Listening on queue {Queue}", _options.InboundQueue); + logger.LogInformation("Grants Portal command consumer started. Listening on queue {Queue}", _options.InboundQueue); return; } catch (Exception ex) when (attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(InitialRetryDelay.TotalSeconds * Math.Pow(2, attempt - 1)); - _logger.LogWarning(ex, "Failed to connect to RabbitMQ (attempt {Attempt}). Retrying in {Delay}s...", attempt, delay.TotalSeconds); + logger.LogWarning(ex, "Failed to connect to RabbitMQ (attempt {Attempt}). Retrying in {Delay}s...", attempt, delay.TotalSeconds); await Task.Delay(delay, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts", MaxRetries); + logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts", MaxRetries); throw; } } @@ -101,7 +89,7 @@ private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) { if (_stoppingToken.IsCancellationRequested) return; - _logger.LogWarning("RabbitMQ connection lost: {Reason}. Attempting to reconnect...", e.ReplyText); + logger.LogWarning("RabbitMQ connection lost: {Reason}. Attempting to reconnect...", e.ReplyText); _ = Task.Run(async () => { @@ -113,7 +101,7 @@ private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) } catch (Exception ex) { - _logger.LogError(ex, "Failed to reconnect to RabbitMQ after connection loss"); + logger.LogError(ex, "Failed to reconnect to RabbitMQ after connection loss"); } }, _stoppingToken); } @@ -142,7 +130,7 @@ private void DeclareTopology() routingKey: routingKey); } - _logger.LogInformation( + logger.LogInformation( "Declared exchange {Exchange} (topic), queue {Queue}, bound with routing keys [{RoutingKeys}]", _options.Exchange, _options.InboundQueue, string.Join(", ", _options.InboundRoutingKeys)); } @@ -171,12 +159,12 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e var correlationId = ea.BasicProperties?.CorrelationId ?? string.Empty; var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; - _logger.LogInformation("Received message {MessageId} type={MessageType}", messageId, messageType); + logger.LogInformation("Received message {MessageId} type={MessageType}", messageId, messageType); // Guard: discard acknowledgment messages to prevent infinite loops (spec §4.2) if (string.Equals(messageType, "MessageAcknowledgment", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("Discarding acknowledgment message {MessageId} to prevent loop", messageId); + logger.LogDebug("Discarding acknowledgment message {MessageId} to prevent loop", messageId); consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); return; } @@ -188,7 +176,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e if (envelope == null) { - _logger.LogError("Failed to deserialize message {MessageId}. Discarding.", messageId); + logger.LogError("Failed to deserialize message {MessageId}. Discarding.", messageId); consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); return; } @@ -202,7 +190,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e var tenantId = ResolveTenantId(payload?.Provider); // Save to the central host inbox — no tenant context needed - using var scope = _serviceProvider.CreateScope(); + using var scope = serviceProvider.CreateScope(); var inboxRepo = scope.ServiceProvider.GetRequiredService(); var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); @@ -212,7 +200,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e var existing = await inboxRepo.FindByMessageIdAsync(messageId); if (existing != null) { - _logger.LogInformation("Message {MessageId} already in inbox (status={Status}). Skipping.", messageId, existing.Status); + logger.LogInformation("Message {MessageId} already in inbox (status={Status}). Skipping.", messageId, existing.Status); consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); return; } @@ -232,11 +220,11 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e await inboxRepo.InsertAsync(inboxMessage, autoSave: true); await uow.CompleteAsync(); - _logger.LogInformation("Message {MessageId} saved to inbox for processing", messageId); + logger.LogInformation("Message {MessageId} saved to inbox for processing", messageId); } catch (Exception ex) { - _logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); + logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); consumingChannel.BasicReject(ea.DeliveryTag, requeue: true); return; } @@ -260,7 +248,7 @@ private void CleanupConnection() { try { - if (_connection != null) _connection.ConnectionShutdown -= OnConnectionShutdown; + _connection?.ConnectionShutdown -= OnConnectionShutdown; _channel?.Close(); _channel?.Dispose(); _connection?.Close(); @@ -268,7 +256,7 @@ private void CleanupConnection() } catch (Exception ex) { - _logger.LogDebug(ex, "Error during connection cleanup"); + logger.LogDebug(ex, "Error during connection cleanup"); } _channel = null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs index f4d805c175..0e7ef3ef38 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs @@ -75,7 +75,7 @@ private async Task ProcessPendingMessagesAsync(CancellationToken cancellat using (var uow = unitOfWorkManager.Begin(requiresNew: true)) { pendingMessages = await inboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); - await uow.CompleteAsync(); + await uow.CompleteAsync(cancellationToken); } if (pendingMessages.Count == 0) return false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs index 566e378535..ff5c919faa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs @@ -18,13 +18,12 @@ namespace Unity.GrantManager.GrantsPortal; /// Uses publisher confirms to ensure delivery before marking messages as sent. /// No tenant context needed — the outbox table is in the host database. /// -public class GrantsPortalOutboxProcessorService : BackgroundService -{ - private readonly IServiceProvider _serviceProvider; - private readonly IAsyncConnectionFactory _connectionFactory; - private readonly GrantsPortalRabbitMqOptions _options; - private readonly ILogger _logger; - +public class GrantsPortalOutboxProcessorService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) : BackgroundService +{ private IConnection? _connection; private IModel? _channel; @@ -32,21 +31,9 @@ public class GrantsPortalOutboxProcessorService : BackgroundService private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); private const int MaxPublishRetries = 3; - public GrantsPortalOutboxProcessorService( - IServiceProvider serviceProvider, - IAsyncConnectionFactory connectionFactory, - IOptions options, - ILogger logger) - { - _serviceProvider = serviceProvider; - _connectionFactory = connectionFactory; - _options = options.Value; - _logger = logger; - } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Grants Portal outbox processor starting..."); + logger.LogInformation("Grants Portal outbox processor starting..."); // Wait for the application to fully start await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); @@ -66,13 +53,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "Error in outbox processor loop. Will retry after delay."); + logger.LogError(ex, "Error in outbox processor loop. Will retry after delay."); CleanupChannel(); await Task.Delay(IdleInterval, stoppingToken); } } - _logger.LogInformation("Grants Portal outbox processor stopped."); + logger.LogInformation("Grants Portal outbox processor stopped."); } private void EnsureChannel() @@ -81,16 +68,16 @@ private void EnsureChannel() CleanupChannel(); - _connection = _connectionFactory.CreateConnection(); + _connection = connectionFactory.CreateConnection(); _channel = _connection.CreateModel(); _channel.ConfirmSelect(); - _logger.LogInformation("Outbox processor RabbitMQ channel established"); + logger.LogInformation("Outbox processor RabbitMQ channel established"); } private async Task PublishPendingAcksAsync(CancellationToken cancellationToken) { - using var scope = _serviceProvider.CreateScope(); + using var scope = serviceProvider.CreateScope(); var outboxRepo = scope.ServiceProvider.GetRequiredService(); var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); @@ -98,7 +85,7 @@ private async Task PublishPendingAcksAsync(CancellationToken cancellationT using (var uow = unitOfWorkManager.Begin(requiresNew: true)) { pendingMessages = await outboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); - await uow.CompleteAsync(); + await uow.CompleteAsync(cancellationToken); } if (pendingMessages.Count == 0) return false; @@ -120,7 +107,7 @@ private async Task PublishSingleAckAsync( { try { - using var scope = _serviceProvider.CreateScope(); + using var scope = serviceProvider.CreateScope(); var publisher = scope.ServiceProvider.GetRequiredService(); publisher.Publish( @@ -143,19 +130,19 @@ private async Task PublishSingleAckAsync( await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); await uow.CompleteAsync(); - _logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", + logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", outboxMsg.MessageId, outboxMsg.OriginalMessageId); } catch (Exception ex) { - _logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); + logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); outboxMsg.RetryCount++; if (outboxMsg.RetryCount >= MaxPublishRetries) { outboxMsg.Status = MessageStatus.Failed; outboxMsg.Details = $"Failed to publish after {MaxPublishRetries} attempts: {ex.Message}"; - _logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", + logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", outboxMsg.MessageId, MaxPublishRetries); } @@ -176,7 +163,7 @@ private void CleanupChannel() } catch (Exception ex) { - _logger.LogDebug(ex, "Error during outbox channel cleanup"); + logger.LogDebug(ex, "Error during outbox channel cleanup"); } _channel = null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs index 96df0b8d0b..6983cc69ab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -42,14 +42,14 @@ public virtual async Task HandleAsync(PluginDataPayload payload) return "Address updated successfully"; } - private static GrantApplications.AddressType MapAddressType(string? portalAddressType) + private static AddressType MapAddressType(string? portalAddressType) { return portalAddressType?.ToUpperInvariant() switch { - "MAILING" => GrantApplications.AddressType.MailingAddress, - "PHYSICAL" => GrantApplications.AddressType.PhysicalAddress, - "BUSINESS" => GrantApplications.AddressType.BusinessAddress, - _ => GrantApplications.AddressType.PhysicalAddress + "MAILING" => AddressType.MailingAddress, + "PHYSICAL" => AddressType.PhysicalAddress, + "BUSINESS" => AddressType.BusinessAddress, + _ => AddressType.PhysicalAddress }; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs index c6e3efe2eb..bff7162168 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Unity.GrantManager.Applications; using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Uow; @@ -10,7 +11,8 @@ namespace Unity.GrantManager.GrantsPortal.Handlers; public class AddressSetPrimaryHandler( IApplicantAddressRepository applicantAddressRepository, - ILogger logger) : IPortalCommandHandler, ITransientDependency + ILogger logger) + : IPortalCommandHandler, ITransientDependency { public string DataType => "ADDRESS_SET_PRIMARY_COMMAND"; @@ -22,9 +24,27 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Setting address {AddressId} as primary for profile {ProfileId}", addressId, profileId); - // TODO: Implement set-primary logic once the primary address tracking mechanism is confirmed. - // The ApplicantAddress entity does not currently have an IsPrimary field. - // This may require updating sibling addresses for the same applicant. + var address = await applicantAddressRepository.GetAsync(addressId); + + address.SetProperty("profileId", profileId.ToString()); + address.SetProperty("isPrimary", true); + + if (address.ApplicantId.HasValue) + { + var siblingAddresses = await applicantAddressRepository.FindByApplicantIdAsync(address.ApplicantId.Value); + + foreach (var sibling in siblingAddresses) + { + if (sibling.Id == addressId) continue; + if (!sibling.HasProperty("isPrimary")) continue; + + var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); + trackedSibling.SetProperty("isPrimary", false); + await applicantAddressRepository.UpdateAsync(trackedSibling, autoSave: true); + } + } + + await applicantAddressRepository.UpdateAsync(address, autoSave: true); logger.LogInformation("Address {AddressId} set as primary", addressId); return "Address set as primary"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs index 8a24a76415..59309895e6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -6,7 +6,6 @@ using Unity.GrantManager.GrantsPortal.Messages.Commands; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities; -using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal.Handlers; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs index 128f5a5e10..e79c5208d8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs @@ -4,12 +4,12 @@ using Unity.GrantManager.Contacts; using Unity.GrantManager.GrantsPortal.Messages; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal.Handlers; public class ContactDeleteHandler( + IContactRepository contactRepository, IContactLinkRepository contactLinkRepository, ILogger logger) : IPortalCommandHandler, ITransientDependency { @@ -20,18 +20,23 @@ public virtual async Task HandleAsync(PluginDataPayload payload) { var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); - logger.LogInformation("Deleting (deactivating) contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + logger.LogInformation("Deleting contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); - // Soft-delete by deactivating contact links - var links = await contactLinkRepository.GetListAsync(cl => cl.ContactId == contactId && cl.IsActive); + // Delete all contact links first (FK dependency) + var links = await contactLinkRepository.GetListAsync(cl => cl.ContactId == contactId); + if (links.Count > 0) + { + await contactLinkRepository.DeleteManyAsync(links, autoSave: true); + } - foreach (var link in links) + // Delete the contact + var contact = await contactRepository.FindAsync(contactId); + if (contact != null) { - link.IsActive = false; - await contactLinkRepository.UpdateAsync(link, autoSave: true); + await contactRepository.DeleteAsync(contact, autoSave: true); } - logger.LogInformation("Contact {ContactId} deactivated successfully", contactId); + logger.LogInformation("Contact {ContactId} deleted successfully", contactId); return "Contact deleted successfully"; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs index b3fcdeb129..2269bb1255 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -5,7 +5,6 @@ using Unity.GrantManager.GrantsPortal.Messages; using Unity.GrantManager.GrantsPortal.Messages.Commands; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal.Handlers; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs index 18a49cb974..24ff1400de 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs @@ -1,11 +1,9 @@ using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Unity.GrantManager.Contacts; using Unity.GrantManager.GrantsPortal.Messages; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal.Handlers; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs index 87b1306ac0..57cadce7fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs @@ -5,7 +5,6 @@ using Unity.GrantManager.GrantsPortal.Messages; using Unity.GrantManager.GrantsPortal.Messages.Commands; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; using Volo.Abp.Uow; namespace Unity.GrantManager.GrantsPortal.Handlers; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs index c748b75390..9ac8c6f41c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs @@ -1,6 +1,5 @@ using System; using Volo.Abp.Domain.Entities.Auditing; -using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.Messaging; @@ -14,8 +13,9 @@ public enum MessageStatus /// /// A message received from an external system, stored for sequential processing. +/// TenantId is stored as metadata for handler dispatch — not for data isolation. /// -public class InboxMessage : AuditedAggregateRoot, IMultiTenant +public class InboxMessage : AuditedAggregateRoot { /// /// Identifies the integration source (e.g. "GrantsPortal"). diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs index 56537ee16a..ffbc1d7eeb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs @@ -1,13 +1,13 @@ using System; using Volo.Abp.Domain.Entities.Auditing; -using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.Messaging; /// /// An acknowledgment or response message to be published to an external system. +/// TenantId is stored as metadata — not for data isolation. /// -public class OutboxMessage : AuditedAggregateRoot, IMultiTenant +public class OutboxMessage : AuditedAggregateRoot { /// /// Identifies the integration target (e.g. "GrantsPortal"). diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs new file mode 100644 index 0000000000..e29e33eb47 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.Designer.cs @@ -0,0 +1,2873 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + [DbContext(typeof(GrantManagerDbContext))] + [Migration("20260307013604_Add_InboxOutboxMessages")] + partial class Add_InboxOutboxMessages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applicants.ApplicantTenantMap", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastUpdated") + .HasColumnType("timestamp without time zone"); + + b.Property("OidcSubUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TenantName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OidcSubUsername"); + + b.HasIndex("OidcSubUsername", "TenantId") + .IsUnique(); + + b.ToTable("ApplicantTenantMaps", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialMinistry") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinistryPrefix") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.HasKey("Id"); + + b.ToTable("CasClientCodes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("KeyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DynamicUrls", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Community", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Communities", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.EconomicRegion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("EconomicRegionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("EconomicRegions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.ElectoralDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ElectoralDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ElectoralDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("ElectoralDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.RegionalDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RegionalDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorId") + .HasColumnType("uuid"); + + b.Property("SubSectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubSectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SectorId"); + + b.ToTable("SubSectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Messaging.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.HasIndex("Source", "Status"); + + b.ToTable("InboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Messaging.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AckStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Status"); + + b.ToTable("OutboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("ApplicationName"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("BrowserInfo"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientId"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientIpAddress"); + + b.Property("ClientName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ClientName"); + + b.Property("Comments") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Comments"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("CorrelationId"); + + b.Property("Exceptions") + .HasColumnType("text"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HttpMethod") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("HttpMethod"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("HttpStatusCode"); + + b.Property("ImpersonatorTenantId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorTenantId"); + + b.Property("ImpersonatorTenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ImpersonatorTenantName"); + + b.Property("ImpersonatorUserId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorUserId"); + + b.Property("ImpersonatorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ImpersonatorUserName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("TenantName"); + + b.Property("Url") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ExecutionTime"); + + b.HasIndex("TenantId", "UserId", "ExecutionTime"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ExecutionTime"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("MethodName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("MethodName"); + + b.Property("Parameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("Parameters"); + + b.Property("ServiceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ServiceName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "ServiceName", "MethodName", "ExecutionTime"); + + b.ToTable("AuditLogActions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ChangeTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ChangeTime"); + + b.Property("ChangeType") + .HasColumnType("smallint") + .HasColumnName("ChangeType"); + + b.Property("EntityId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityId"); + + b.Property("EntityTenantId") + .HasColumnType("uuid"); + + b.Property("EntityTypeFullName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityTypeFullName"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "EntityTypeFullName", "EntityId"); + + b.ToTable("EntityChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntityChangeId") + .HasColumnType("uuid"); + + b.Property("NewValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("NewValue"); + + b.Property("OriginalValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("OriginalValue"); + + b.Property("PropertyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("PropertyName"); + + b.Property("PropertyTypeFullName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("PropertyTypeFullName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("EntityChangeId"); + + b.ToTable("EntityPropertyChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.BackgroundJobs.BackgroundJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsAbandoned") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JobArgs") + .IsRequired() + .HasMaxLength(1048576) + .HasColumnType("character varying(1048576)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("NextTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)15); + + b.Property("TryCount") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.HasKey("Id"); + + b.HasIndex("IsAbandoned", "NextTryTime"); + + b.ToTable("BackgroundJobs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedProviders") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DefaultValue") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsAvailableToHost") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ValueType") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Features", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("FeatureValues", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityClaimType", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsStatic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Regex") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RegexDescription") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClaimTypes", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityLinkUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("SourceTenantId") + .HasColumnType("uuid"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("TargetTenantId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceUserId", "SourceTenantId", "TargetUserId", "TargetTenantId") + .IsUnique(); + + b.ToTable("LinkUsers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("IsDefault"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("IsPublic"); + + b.Property("IsStatic") + .HasColumnType("boolean") + .HasColumnName("IsStatic"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySecurityLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Action") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Identity") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Action"); + + b.HasIndex("TenantId", "ApplicationName"); + + b.HasIndex("TenantId", "Identity"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("SecurityLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySession", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Device") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeviceInfo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IpAddresses") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastAccessed") + .HasColumnType("timestamp without time zone"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SignedIn") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Device"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("Sessions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.Property("EmailConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("EmailConfirmed"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("IsActive"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsExternal") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsExternal"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastPasswordChangeTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("LockoutEnabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Name"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedEmail"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedUserName"); + + b.Property("OidcSub") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("PasswordHash"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("PhoneNumber"); + + b.Property("PhoneNumberConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("PhoneNumberConfirmed"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("SecurityStamp"); + + b.Property("ShouldChangePasswordOnNextLogin") + .HasColumnType("boolean"); + + b.Property("Surname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Surname"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TwoFactorEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("TwoFactorEnabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("NormalizedEmail"); + + b.HasIndex("NormalizedUserName"); + + b.HasIndex("UserName"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserDelegation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("UserDelegations", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderDisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(196) + .HasColumnType("character varying(196)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "LoginProvider"); + + b.HasIndex("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "UserId"); + + b.HasIndex("UserId", "OrganizationUnitId"); + + b.ToTable("UserOrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(95) + .HasColumnType("character varying(95)") + .HasColumnName("Code"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("DisplayName"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("ParentId"); + + b.ToTable("OrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "RoleId"); + + b.HasIndex("RoleId", "OrganizationUnitId"); + + b.ToTable("OrganizationUnitRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MultiTenancySide") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateCheckers") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("PermissionGrants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PermissionGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.SettingDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultValue") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInherited") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SettingDefinitions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("TenantId", "Name"); + + b.ToTable("TenantConnectionStrings", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.HasOne("Unity.GrantManager.Locality.Sector", "Sector") + .WithMany("SubSectors") + .HasForeignKey("SectorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sector"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("Actions") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("EntityChanges") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.EntityChange", null) + .WithMany("PropertyChanges") + .HasForeignKey("EntityChangeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("OrganizationUnits") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany("Roles") + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.HasOne("Volo.Abp.TenantManagement.Tenant", null) + .WithMany("ConnectionStrings") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Navigation("SubSectors"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Navigation("Actions"); + + b.Navigation("EntityChanges"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Navigation("PropertyChanges"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("OrganizationUnits"); + + b.Navigation("Roles"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Navigation("Roles"); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Navigation("ConnectionStrings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs new file mode 100644 index 0000000000..36a7cefc34 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs @@ -0,0 +1,97 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + /// + public partial class Add_InboxOutboxMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxMessages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + MessageId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + DataType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Payload = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "text", nullable: false), + Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + RetryCount = table.Column(type: "integer", nullable: false), + ReceivedAt = table.Column(type: "timestamp without time zone", nullable: false), + ProcessedAt = table.Column(type: "timestamp without time zone", nullable: true), + TenantId = table.Column(type: "uuid", nullable: true), + ExtraProperties = table.Column(type: "text", nullable: false), + ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreationTime = table.Column(type: "timestamp without time zone", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: true), + LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), + LastModifierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxMessages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Source = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + MessageId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + OriginalMessageId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + DataType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AckStatus = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Details = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + Status = table.Column(type: "text", nullable: false), + RetryCount = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp without time zone", nullable: false), + PublishedAt = table.Column(type: "timestamp without time zone", nullable: true), + TenantId = table.Column(type: "uuid", nullable: true), + ExtraProperties = table.Column(type: "text", nullable: false), + ConcurrencyStamp = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + CreationTime = table.Column(type: "timestamp without time zone", nullable: false), + CreatorId = table.Column(type: "uuid", nullable: true), + LastModificationTime = table.Column(type: "timestamp without time zone", nullable: true), + LastModifierId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxMessages_MessageId", + table: "InboxMessages", + column: "MessageId"); + + migrationBuilder.CreateIndex( + name: "IX_InboxMessages_Source_Status", + table: "InboxMessages", + columns: new[] { "Source", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessages_Source_Status", + table: "OutboxMessages", + columns: new[] { "Source", "Status" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InboxMessages"); + + migrationBuilder.DropTable( + name: "OutboxMessages"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index d034c14e8e..88c683517e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -948,6 +948,185 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubSectors", (string)null); }); + modelBuilder.Entity("Unity.GrantManager.Messaging.InboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.HasIndex("Source", "Status"); + + b.ToTable("InboxMessages", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Messaging.OutboxMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AckStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OriginalMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Status"); + + b.ToTable("OutboxMessages", (string)null); + }); + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => { b.Property("Id") From 9c153d92ed5166bb4d5c5e27c1f89b5e73f1c225 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Mon, 9 Mar 2026 17:04:05 -0700 Subject: [PATCH 03/32] AB#31677 update documents --- .../grants-portal-rabbitmq-integration.md | 512 ++++++++++++++++++ .../transactional-outbox-pattern.md | 326 +++++++++++ 2 files changed, 838 insertions(+) create mode 100644 applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md create mode 100644 applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md diff --git a/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md b/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md new file mode 100644 index 0000000000..097831b5bf --- /dev/null +++ b/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md @@ -0,0 +1,512 @@ +# Grants Portal — RabbitMQ Messaging Integration + +## Overview + +The Unity Grant Manager receives commands from the Applicant Portal (Grants Portal) via RabbitMQ and sends acknowledgment responses back. This provides reliable, decoupled communication for profile data mutations (contacts, addresses, organizations) that the portal user initiates. + +The integration is built on the [Transactional Outbox Pattern](./transactional-outbox-pattern.md) with four dedicated `BackgroundService` instances forming a message processing pipeline. + +**Source name**: `"GrantsPortal"` (used as the discriminator in inbox/outbox tables) + +--- + +## Architecture + +```mermaid +graph TB + subgraph Portal["Applicant Portal"] + PP["Portal Plugin"] + end + + subgraph Broker["RabbitMQ"] + EX["Exchange: grants.messaging
(topic)"] + QI["Queue: unity.commands
Routing: commands.unity.plugindata"] + QA["Routing: grants.unity.acknowledgment"] + end + + subgraph Unity["Unity Grant Manager"] + S1["① GrantsPortalCommandConsumerService
RabbitMQ → InboxMessages"] + IT[(InboxMessages)] + S2["② GrantsPortalInboxProcessorService
InboxMessages → Handler → OutboxMessages"] + H["IPortalCommandHandler
implementations"] + OT[(OutboxMessages)] + S3["③ GrantsPortalOutboxProcessorService
OutboxMessages → RabbitMQ"] + S4["④ GrantsPortalMessageCleanupService
Purge old rows"] + end + + PP -->|"Publish command"| EX + EX -->|"commands.unity.plugindata"| QI + QI --> S1 + S1 --> IT + IT --> S2 + S2 --> H + S2 --> OT + OT --> S3 + S3 -->|"grants.unity.acknowledgment"| EX + EX -->|"grants.*.acknowledgment"| Portal + S4 -.-> IT + S4 -.-> OT + + style IT fill:#e8f5e9 + style OT fill:#fff4e6 + style Broker fill:#fff4e6 +``` + +--- + +## RabbitMQ Topology + +The consumer service declares the following topology on startup: + +| Element | Name | Type | Durable | +|---------|------|------|---------| +| **Exchange** | `grants.messaging` | `topic` | ✅ | +| **Queue** | `unity.commands` | — | ✅ | +| **Binding** | `unity.commands` ← `grants.messaging` | Routing key: `commands.unity.plugindata` | — | +| **Ack Routing Key** | `grants.unity.acknowledgment` | Published to same exchange | — | + +The exchange and queue are declared idempotently each time the consumer connects (including reconnects). + +**Prefetch**: `prefetchCount = 1` — messages are consumed one at a time per connection. + +--- + +## Message Format + +### Inbound — PluginDataEnvelope + +All inbound commands use the `PluginDataEnvelope` wrapper: + +```json +{ + "messageId": "550e8400-e29b-41d4-a716-446655440000", + "messageType": "PluginData", + "createdAt": "2026-01-15T22:42:24.115Z", + "correlationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "pluginId": "grants-portal", + "dataType": "CONTACT_CREATE_COMMAND", + "data": { + "action": "CONTACT_CREATE_COMMAND", + "contactId": "a1b2c3d4-...", + "profileId": "3fa85f64-...", + "provider": "7c9e6679-...", + "data": { } + } +} +``` + +**AMQP Properties** (set by the publisher): + +| Property | Usage | +|----------|-------| +| `MessageId` | Idempotency key (falls back to `envelope.messageId`) | +| `Type` | Message type discriminator | +| `CorrelationId` | Correlation ID (falls back to `envelope.correlationId`) | + +**Envelope Fields**: + +| Field | Type | Description | +|-------|------|-------------| +| `messageId` | `string` | Unique message ID | +| `messageType` | `string` | Message type | +| `createdAt` | `DateTime` | When the message was created | +| `correlationId` | `string` | Groups related messages | +| `pluginId` | `string` | Source plugin identifier | +| `dataType` | `string` | **Command discriminator** — used to route to the correct handler | +| `data` | `JObject` | Nested `PluginDataPayload` with command-specific fields | + +**PluginDataPayload Fields** (inside `data`): + +| Field | Type | Description | +|-------|------|-------------| +| `action` | `string` | Redundant with `dataType` | +| `contactId` | `string?` | Target contact ID (for contact commands) | +| `addressId` | `string?` | Target address ID (for address commands) | +| `organizationId` | `string?` | Target organization/applicant ID | +| `profileId` | `string?` | Applicant profile ID | +| `provider` | `string?` | **Tenant ID** as a GUID string — used for tenant resolution | +| `data` | `JObject?` | Inner command-specific data payload | + +### Outbound — MessageAcknowledgment + +After processing, Unity publishes an acknowledgment: + +```json +{ + "messageId": "new-uuid", + "messageType": "MessageAcknowledgment", + "createdAt": "2026-01-15T22:42:25.003Z", + "correlationId": "7c9e6679-...", + "pluginId": "UNITY", + "originalMessageId": "550e8400-...", + "status": "SUCCESS", + "details": "Contact created successfully", + "processedAt": "2026-01-15T22:42:25.003Z" +} +``` + +**AMQP Properties**: + +| Property | Value | +|----------|-------| +| `Type` | `"MessageAcknowledgment"` | +| `ContentType` | `"application/json"` | +| `Persistent` | `true` | +| `MessageId` | New UUID for the ack | +| `CorrelationId` | Passthrough from original | + +**Ack Loop Prevention**: The consumer discards any received messages where `Type == "MessageAcknowledgment"` to prevent infinite loops when the same exchange is used for both directions. + +--- + +## Pipeline Services + +### ① GrantsPortalCommandConsumerService + +**Role**: Pulls messages from RabbitMQ, saves to inbox, ACKs. + +**Startup**: +- Connects to RabbitMQ with exponential backoff retry (5 attempts, starting at 5s) +- Declares exchange + queue + bindings +- Starts async consumer with `autoAck: false` + +**On message received**: +1. Extract `MessageId`, `Type`, `CorrelationId` from AMQP properties (with envelope fallbacks) +2. Discard if `Type == "MessageAcknowledgment"` +3. Deserialize JSON body to `PluginDataEnvelope` +4. Resolve `TenantId` from `data.provider` field (GUID parse) +5. **Idempotency check**: `FindByMessageIdAsync(messageId)` — skip if already exists +6. Insert `InboxMessage` with `Status = Pending` +7. **ACK** the delivery tag (only after commit) + +**Connection recovery**: On `ConnectionShutdown`, waits 5s then re-runs `ConnectAndConsumeAsync`. + +### ② GrantsPortalInboxProcessorService + +**Role**: Polls inbox, dispatches to handlers, writes ack to outbox. + +**Polling**: Every 5s when messages were found, every 15s when idle. Batch size: 10. + +**Per message**: +1. Mark `Status = Processing`, increment `RetryCount` +2. Deserialize `Payload` → `PluginDataEnvelope` → `PluginDataPayload` +3. Find matching `IPortalCommandHandler` by `DataType` (case-insensitive) +4. If no handler: `ackStatus = "FAILED"`, `details = "Unknown command type: ..."` +5. If handler found: + - **Switch to tenant context** (`ICurrentTenant.Change(inboxMsg.TenantId)`) + - Execute handler inside a new Unit of Work + - `ackStatus = "SUCCESS"`, `details` = handler return string +6. On exception: + - If **transient** and under max retries (3): reset to `Pending` for retry + - Otherwise: `ackStatus = "FAILED"`, `details` = user-friendly error message +7. Mark inbox as `Processed` or `Failed` and write `OutboxMessage` — **same transaction** + +**Tenant context**: Only the handler execution runs under the tenant context. Inbox/outbox operations run against the host database without tenant scoping. + +### ③ GrantsPortalOutboxProcessorService + +**Role**: Polls outbox, publishes acks to RabbitMQ with publisher confirms. + +**Polling**: Every 5s when messages were found, every 15s when idle. Batch size: 10. + +**Per message**: +1. Ensure RabbitMQ channel is open (with `ConfirmSelect` enabled) +2. Publish via `GrantsPortalAcknowledgmentPublisher` to `grants.messaging` exchange with routing key `grants.unity.acknowledgment` +3. Wait for broker confirm (`WaitForConfirms` with 5s timeout) +4. On confirm: mark `Status = Processed`, set `PublishedAt` +5. On failure: increment `RetryCount`; after 3 attempts mark as `Failed` + +### ④ GrantsPortalMessageCleanupService + +**Role**: Purges old processed/failed messages from both tables. + +- **Interval**: Every 1 hour +- **Retention**: Configurable via `MessageRetentionDays` (default: 30) +- **Scope**: Deletes rows where `Status ∈ {Processed, Failed}` and `ReceivedAt`/`CreatedAt` < cutoff +- **Startup delay**: 1 minute (waits for app to fully start) + +--- + +## Command Handlers + +All handlers implement `IPortalCommandHandler` and are registered as transient services: + +```csharp +public interface IPortalCommandHandler +{ + string DataType { get; } + Task HandleAsync(PluginDataPayload payload); +} +``` + +The return string becomes the `Details` field in the outbound acknowledgment. + +### Implemented Commands + +| DataType | Handler | Entity | Description | +|----------|---------|--------|-------------| +| `CONTACT_CREATE_COMMAND` | `ContactCreateHandler` | `Contact` + `ContactLink` | Creates a new contact and links it to the profile. Idempotent — skips if contact already exists. | +| `CONTACT_EDIT_COMMAND` | `ContactEditHandler` | `Contact` | Updates an existing contact's fields. | +| `CONTACT_SET_PRIMARY_COMMAND` | `ContactSetPrimaryHandler` | `ContactLink` | Sets one contact as primary for a profile; clears primary on all other links. | +| `CONTACT_DELETE_COMMAND` | `ContactDeleteHandler` | `ContactLink` + `Contact` | Deletes contact links then the contact entity. | +| `ADDRESS_EDIT_COMMAND` | `AddressEditHandler` | `ApplicantAddress` | Updates address fields (street, city, province, etc.) and address type. | +| `ADDRESS_SET_PRIMARY_COMMAND` | `AddressSetPrimaryHandler` | `ApplicantAddress` | Sets `isPrimary` extra property on the target address; clears it on sibling addresses that had it set. | +| `ORGANIZATION_EDIT_COMMAND` | `OrganizationEditHandler` | `Applicant` | Updates organization fields on the applicant entity. | + +### Command Data Payloads + +Each command that requires inner data deserializes `payload.Data` to a typed class in `GrantsPortal/Messages/Commands/`: + +**ContactCreateData / ContactEditData**: +```json +{ + "name": "John Doe", + "email": "john@example.com", + "title": "Director", + "contactType": "ApplicantProfile", + "homePhoneNumber": "(555) 111-1111", + "mobilePhoneNumber": "(555) 222-2222", + "workPhoneNumber": "(555) 333-3333", + "workPhoneExtension": "123", + "role": "Primary Contact", + "isPrimary": true +} +``` + +**AddressEditData**: +```json +{ + "addressType": "PHYSICAL", + "street": "123 Main St", + "street2": "Suite 100", + "unit": "4B", + "city": "Victoria", + "province": "BC", + "postalCode": "V8V 1A1", + "country": "Canada", + "isPrimary": false +} +``` + +**OrganizationEditData**: +```json +{ + "name": "Acme Corp", + "organizationType": "Non-Profit", + "organizationNumber": "BC1234567", + "status": "Active", + "nonRegOrgName": null, + "fiscalMonth": "April", + "fiscalDay": "1", + "organizationSize": "51-100" +} +``` + +--- + +## Tenant Resolution + +The consumer extracts the tenant ID from `data.provider` in the message payload: + +```csharp +private static Guid? ResolveTenantId(string? provider) +{ + if (string.IsNullOrWhiteSpace(provider)) return null; + if (Guid.TryParse(provider, out var tenantGuid)) return tenantGuid; + return null; +} +``` + +The tenant ID is stored on the `InboxMessage.TenantId` field. The inbox processor uses `ICurrentTenant.Change(tenantId)` only when executing the domain handler — inbox/outbox table operations always run against the host database. + +--- + +## Configuration + +### appsettings.json + +```json +{ + "RabbitMQ": { + "HostName": "127.0.0.1", + "Port": 5672, + "UserName": "guest", + "VirtualHost": "/", + "GrantsPortal": { + "Exchange": "grants.messaging", + "ExchangeType": "topic", + "InboundQueue": "unity.commands", + "InboundRoutingKeys": [ "commands.unity.plugindata" ], + "AckRoutingKey": "grants.unity.acknowledgment", + "MessageRetentionDays": 30 + } + } +} +``` + +### GrantsPortalRabbitMqOptions + +``` +Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs +``` + +| Property | Default | Description | +|----------|---------|-------------| +| `Exchange` | `"grants.messaging"` | Topic exchange name | +| `ExchangeType` | `"topic"` | Exchange type | +| `InboundQueue` | `"unity.commands"` | Queue to consume from | +| `InboundRoutingKeys` | `["commands.unity.plugindata"]` | Routing keys to bind | +| `AckRoutingKey` | `"grants.unity.acknowledgment"` | Routing key for outbound acks | +| `MessageRetentionDays` | `30` | Days to retain processed/failed messages | + +**Section path**: `RabbitMQ:GrantsPortal` + +--- + +## Service Registration + +All services are registered in `GrantManagerApplicationModule.ConfigureServices`: + +```csharp +// Options +context.Services.Configure( + configuration.GetSection(GrantsPortalRabbitMqOptions.SectionName)); + +// Command handlers +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); +context.Services.AddTransient(); + +// Acknowledgment publisher +context.Services.AddScoped(); + +// Background services (pipeline stages) +context.Services.AddHostedService(); // ① RabbitMQ → inbox +context.Services.AddHostedService(); // ② inbox → handler → outbox +context.Services.AddHostedService(); // ③ outbox → RabbitMQ +context.Services.AddHostedService(); // ④ purge old rows +``` + +--- + +## End-to-End Example + +A portal user edits a contact: + +```mermaid +sequenceDiagram + participant Portal as Applicant Portal + participant RMQ as RabbitMQ + participant Consumer as ① Consumer + participant Inbox as InboxMessages + participant Processor as ② Inbox Processor + participant Handler as ContactEditHandler + participant Outbox as OutboxMessages + participant Publisher as ③ Outbox Processor + + Portal->>RMQ: Publish CONTACT_EDIT_COMMAND
routing: commands.unity.plugindata + RMQ->>Consumer: Deliver message + Consumer->>Inbox: INSERT (Pending) + Consumer->>RMQ: ACK + + loop Poll every 5s + Processor->>Inbox: GetPendingAsync("GrantsPortal") + end + + Processor->>Inbox: UPDATE Status=Processing + Processor->>Handler: HandleAsync(payload)
[tenant context] + Handler->>Handler: Update Contact entity + Processor->>Inbox: UPDATE Status=Processed + Processor->>Outbox: INSERT ack (Pending, SUCCESS) + + loop Poll every 5s + Publisher->>Outbox: GetPendingAsync("GrantsPortal") + end + + Publisher->>RMQ: Publish MessageAcknowledgment
routing: grants.unity.acknowledgment + RMQ-->>Publisher: Confirm + Publisher->>Outbox: UPDATE Status=Processed + + RMQ->>Portal: Deliver acknowledgment +``` + +--- + +## Monitoring + +### Key Queries + +**Pending messages (stuck?)**: +```sql +SELECT "Source", "DataType", "Status", COUNT(*), MIN("ReceivedAt") +FROM "InboxMessages" +WHERE "Status" IN ('Pending', 'Processing') +GROUP BY "Source", "DataType", "Status"; +``` + +**Failed messages**: +```sql +SELECT "MessageId", "DataType", "Details", "RetryCount", "ReceivedAt" +FROM "InboxMessages" +WHERE "Status" = 'Failed' AND "Source" = 'GrantsPortal' +ORDER BY "ReceivedAt" DESC +LIMIT 20; +``` + +**Outbox backlog**: +```sql +SELECT COUNT(*), MIN("CreatedAt") +FROM "OutboxMessages" +WHERE "Status" = 'Pending' AND "Source" = 'GrantsPortal'; +``` + +### Log Markers + +| Log Message | Service | Meaning | +|-------------|---------|---------| +| `"Grants Portal command consumer starting..."` | Consumer | Service starting | +| `"Message {id} saved to inbox for processing"` | Consumer | Message received and saved | +| `"Message {id} already in inbox"` | Consumer | Duplicate detected | +| `"Processing inbox message {id}"` | Processor | Handler dispatch starting | +| `"Inbox message {id} processed with status {status}"` | Processor | Handler completed | +| `"Message {id} will be retried"` | Processor | Transient error, will retry | +| `"Outbox message {id} published"` | Publisher | Ack sent to broker | +| `"Cleaned up {n} messages older than ..."` | Cleanup | Old rows purged | + +--- + +## Troubleshooting + +### Messages stuck in Pending (Inbox) + +**Cause**: Inbox processor may have crashed or is not running. + +**Check**: +```sql +SELECT * FROM "InboxMessages" +WHERE "Status" = 'Pending' AND "ReceivedAt" < NOW() - INTERVAL '5 minutes'; +``` + +**Resolution**: Verify `GrantsPortalInboxProcessorService` is running in application logs. Restart the application if needed. + +### Messages stuck in Pending (Outbox) + +**Cause**: Outbox processor can't connect to RabbitMQ or broker is not confirming. + +**Check**: Look for `"Error in outbox processor loop"` in logs. Verify RabbitMQ is reachable. + +### Unknown command type + +**Cause**: Portal sent a `dataType` that has no registered `IPortalCommandHandler`. + +**Resolution**: Register the new handler in `GrantManagerApplicationModule`. The failed message will have `Details = "Unknown command type: ..."`. + +### Consumer not connecting + +**Cause**: RabbitMQ unreachable or credentials wrong. + +**Check**: Consumer retries 5 times with exponential backoff. After 5 failures it throws and the hosted service stops. Look for `"Failed to connect to RabbitMQ after 5 attempts"` in logs. diff --git a/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md b/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md new file mode 100644 index 0000000000..2918157397 --- /dev/null +++ b/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md @@ -0,0 +1,326 @@ +# Transactional Outbox Pattern + +## Overview + +Unity Grant Manager uses the **Transactional Inbox/Outbox** pattern for reliable asynchronous messaging with external systems. The pattern ensures that message receipt, processing, and response publishing are each atomic operations — even if the broker or application crashes mid-flow. + +The implementation is **integration-source agnostic**. The same `InboxMessage` and `OutboxMessage` tables, entities, and repositories are shared by all integrations. Each integration is identified by a `Source` discriminator (e.g. `"GrantsPortal"`). + +--- + +## Why This Pattern + +Direct RabbitMQ consumption with inline processing has several failure modes: + +| Problem | Without Outbox | With Outbox | +|---------|---------------|-------------| +| App crashes after processing but before ACK | Message redelivered, duplicate side-effects | Message already saved to inbox; ACK happened at save time | +| Broker unavailable when sending response | Response lost | Response saved to outbox table; publisher retries independently | +| Database commit fails after ACK | ACK'd but no state change | ACK only happens after inbox save commits | +| Need to audit message history | Logs only | Full database trail with status, timestamps, retry counts | + +--- + +## Architecture + +The pattern separates the messaging pipeline into four independent stages, each run by a dedicated `BackgroundService`: + +```mermaid +graph LR + RMQ[(RabbitMQ
Broker)] + + subgraph Unity["Unity Grant Manager — Host Database"] + S1["① Consumer
BackgroundService"] + IT[(InboxMessages
Table)] + S2["② Inbox Processor
BackgroundService"] + OT[(OutboxMessages
Table)] + S3["③ Outbox Processor
BackgroundService"] + S4["④ Cleanup
BackgroundService"] + end + + RMQ -->|"Consume + ACK"| S1 + S1 -->|"INSERT (Pending)"| IT + S2 -->|"Poll Pending"| IT + S2 -->|"Process → INSERT ack"| OT + S3 -->|"Poll Pending"| OT + S3 -->|"Publish + Confirm"| RMQ + S4 -.->|"DELETE old rows"| IT + S4 -.->|"DELETE old rows"| OT + + style IT fill:#e8f5e9 + style OT fill:#fff4e6 +``` + +### Stage Responsibilities + +| # | Stage | Scope | Transaction Boundary | +|---|-------|-------|---------------------| +| ① | **Consumer** | Receive from broker → save to inbox → ACK | Inbox INSERT committed before ACK sent | +| ② | **Inbox Processor** | Poll inbox → dispatch to handler → write ack to outbox | Handler execution + outbox INSERT in one UoW | +| ③ | **Outbox Processor** | Poll outbox → publish to broker → mark as sent | Publish with broker confirms before UPDATE | +| ④ | **Cleanup** | Delete old Processed/Failed rows | Periodic bulk delete | + +--- + +## Domain Entities + +Both entities live in `Unity.GrantManager.Domain/Messaging/` and are stored in the **host database** (`GrantManagerDbContext`), not in tenant databases. They inherit from ABP's `AuditedAggregateRoot`. + +### InboxMessage + +Represents a message received from an external system, staged for sequential processing. + +``` +Unity.GrantManager.Domain/Messaging/InboxMessage.cs +``` + +| Property | Type | Description | +|----------|------|-------------| +| `Source` | `string` | Integration discriminator (e.g. `"GrantsPortal"`) | +| `MessageId` | `string` | Source system's message ID — used for **idempotency** | +| `CorrelationId` | `string` | Correlation ID passed through from the source | +| `DataType` | `string` | Command discriminator (e.g. `CONTACT_CREATE_COMMAND`) | +| `Payload` | `string` | Full JSON payload of the inbound message | +| `Status` | `MessageStatus` | `Pending` → `Processing` → `Processed` / `Failed` | +| `Details` | `string?` | Processing result or user-friendly error message | +| `RetryCount` | `int` | Number of processing attempts | +| `ReceivedAt` | `DateTime` | When the message arrived from the broker | +| `ProcessedAt` | `DateTime?` | When processing completed | +| `TenantId` | `Guid?` | Tenant context for handler dispatch (metadata, not data isolation) | + +### OutboxMessage + +Represents a response/acknowledgment to be published back to an external system. + +``` +Unity.GrantManager.Domain/Messaging/OutboxMessage.cs +``` + +| Property | Type | Description | +|----------|------|-------------| +| `Source` | `string` | Integration discriminator | +| `MessageId` | `string` | Unique ID for this outbound message | +| `OriginalMessageId` | `string` | The inbound message ID this responds to | +| `CorrelationId` | `string` | Correlation ID from the original message | +| `DataType` | `string` | Command type of the original message | +| `AckStatus` | `string` | `SUCCESS` or `FAILED` | +| `Details` | `string` | Human-readable result or error (safe for end-user display) | +| `Status` | `MessageStatus` | `Pending` → `Processed` / `Failed` | +| `RetryCount` | `int` | Number of publish attempts | +| `CreatedAt` | `DateTime` | When the outbox entry was created | +| `PublishedAt` | `DateTime?` | When the message was confirmed by the broker | +| `TenantId` | `Guid?` | Tenant context metadata | + +### MessageStatus Enum + +```csharp +public enum MessageStatus +{ + Pending = 1, + Processing = 2, + Processed = 3, + Failed = 4 +} +``` + +--- + +## Database Tables + +Both tables are in the **host database** and were added in migration `20260307013604_Add_InboxOutboxMessages`. + +### InboxMessages + +```sql +CREATE TABLE "InboxMessages" ( + "Id" UUID PRIMARY KEY, + "Source" VARCHAR(50) NOT NULL, + "MessageId" VARCHAR(64) NOT NULL, + "CorrelationId" VARCHAR(128) NOT NULL, + "DataType" VARCHAR(100) NOT NULL, + "Payload" JSONB NOT NULL, + "Status" TEXT NOT NULL, + "Details" VARCHAR(2000), + "RetryCount" INTEGER NOT NULL DEFAULT 0, + "ReceivedAt" TIMESTAMP NOT NULL, + "ProcessedAt" TIMESTAMP, + "TenantId" UUID, + -- ABP audit columns + "ExtraProperties" TEXT NOT NULL, + "ConcurrencyStamp" VARCHAR(40) NOT NULL, + "CreationTime" TIMESTAMP NOT NULL, + "CreatorId" UUID, + "LastModificationTime" TIMESTAMP, + "LastModifierId" UUID +); + +CREATE UNIQUE INDEX "IX_InboxMessages_Source_MessageId" + ON "InboxMessages" ("Source", "MessageId"); + +CREATE INDEX "IX_InboxMessages_Status_ReceivedAt" + ON "InboxMessages" ("Status", "ReceivedAt"); +``` + +### OutboxMessages + +```sql +CREATE TABLE "OutboxMessages" ( + "Id" UUID PRIMARY KEY, + "Source" VARCHAR(50) NOT NULL, + "MessageId" VARCHAR(64) NOT NULL, + "OriginalMessageId" VARCHAR(64) NOT NULL, + "CorrelationId" VARCHAR(128) NOT NULL, + "DataType" VARCHAR(100) NOT NULL, + "AckStatus" VARCHAR(20) NOT NULL, + "Details" VARCHAR(2000) NOT NULL, + "Status" TEXT NOT NULL, + "RetryCount" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TIMESTAMP NOT NULL, + "PublishedAt" TIMESTAMP, + "TenantId" UUID, + -- ABP audit columns + "ExtraProperties" TEXT NOT NULL, + "ConcurrencyStamp" VARCHAR(40) NOT NULL, + "CreationTime" TIMESTAMP NOT NULL, + "CreatorId" UUID, + "LastModificationTime" TIMESTAMP, + "LastModifierId" UUID +); + +CREATE INDEX "IX_OutboxMessages_Status_CreatedAt" + ON "OutboxMessages" ("Status", "CreatedAt"); +``` + +--- + +## Repository Interfaces + +Both repositories extend ABP's `IRepository` and add integration-specific queries. + +``` +Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs +Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs +``` + +### IInboxMessageRepository + +| Method | Description | +|--------|-------------| +| `FindByMessageIdAsync(string messageId)` | Idempotency check — find by source message ID | +| `GetPendingAsync(string source, int maxCount)` | Poll for messages with `Status == Pending`, ordered by `ReceivedAt` | +| `DeleteProcessedOlderThanAsync(DateTime cutoff)` | Bulk delete `Processed` or `Failed` rows older than cutoff | + +### IOutboxMessageRepository + +| Method | Description | +|--------|-------------| +| `GetPendingAsync(string source, int maxCount)` | Poll for messages with `Status == Pending`, ordered by `CreatedAt` | +| `DeleteProcessedOlderThanAsync(DateTime cutoff)` | Bulk delete `Processed` or `Failed` rows older than cutoff | + +EF Core implementations are in `Unity.GrantManager.EntityFrameworkCore/Repositories/` and use `GrantManagerDbContext` (host DB). + +--- + +## Message Lifecycle + +```mermaid +stateDiagram-v2 + direction LR + + state "Inbox" as inbox { + [*] --> Pending : Consumer saves + Pending --> Processing : Processor picks up + Processing --> Processed : Handler succeeds + Processing --> Failed : Handler fails (max retries) + Processing --> Pending : Transient error (retry) + } + + state "Outbox" as outbox { + [*] --> OPending : Processor writes ack + OPending --> OProcessed : Publisher confirms + OPending --> OFailed : Max publish retries + + state "Pending" as OPending + state "Processed" as OProcessed + state "Failed" as OFailed + } + + inbox --> outbox : On inbox completion +``` + +### Detailed Flow + +1. **Consumer receives** a message from the broker. +2. Consumer saves it to `InboxMessages` with `Status = Pending` inside a Unit of Work. +3. After the UoW commits, the consumer **ACKs** the broker delivery. If the save fails, the message is **rejected/requeued**. +4. **Inbox Processor** polls `InboxMessages` for `Pending` rows (filtered by `Source`). +5. For each message, the processor: + - Sets `Status = Processing` and increments `RetryCount` + - Deserializes the payload and dispatches to the appropriate handler + - On **success**: sets `Status = Processed` and writes an `OutboxMessage` with `AckStatus = "SUCCESS"` — both in the **same Unit of Work** + - On **transient failure** (under max retries): resets `Status = Pending` for retry + - On **permanent failure** (or max retries exceeded): sets `Status = Failed` and writes an `OutboxMessage` with `AckStatus = "FAILED"` +6. **Outbox Processor** polls `OutboxMessages` for `Pending` rows. +7. For each message, the processor: + - Publishes to the broker using **publisher confirms** + - After broker confirmation: sets `Status = Processed` and records `PublishedAt` + - On failure (under max retries): increments `RetryCount` + - On max retries exceeded: sets `Status = Failed` +8. **Cleanup Service** periodically deletes `Processed` and `Failed` rows older than the retention period. + +--- + +## Idempotency + +The consumer performs an idempotency check before saving to the inbox: + +``` +FindByMessageIdAsync(messageId) → if exists, ACK and skip +``` + +This prevents duplicate inbox rows if the broker redelivers a message (e.g. after a network hiccup before the ACK reached the broker). + +--- + +## Error Handling + +### User-Friendly Error Messages + +The inbox processor maps known exception types to user-friendly messages that are safe to return to the external system: + +| Exception Type | User-Facing Message | +|---------------|-------------------| +| `EntityNotFoundException` | The requested record was not found. It may have been deleted. | +| `DbUpdateConcurrencyException` | The record was modified by another process. Please try again. | +| `AbpDbConcurrencyException` | The record was modified by another process. Please try again. | +| _(any other)_ | An unexpected error occurred while processing your request. Please try again or contact support. | + +Stack traces and internal details are **never** leaked to the external system. + +### Transient Error Detection + +Errors are considered transient (eligible for retry) if the exception type name contains `Timeout`, `Concurrency`, or `Transient`, or if the inner exception is a `TimeoutException`. + +--- + +## Cleanup / Retention + +A dedicated `BackgroundService` runs hourly and deletes `Processed` and `Failed` messages older than the configured retention period. The default retention is **30 days**. + +Both inbox and outbox tables are cleaned in the same pass. + +--- + +## Adding a New Integration Source + +To add a new external system using this pattern: + +1. **Choose a source name** (e.g. `"NewSystem"`) — this discriminates your messages in the shared tables. +2. **Create a Consumer** (`BackgroundService`) that receives from your broker/transport, saves to `InboxMessages` with your source name, and ACKs. +3. **Create command handlers** implementing your handler interface, registered in DI. +4. **Create an Inbox Processor** (`BackgroundService`) that polls `GetPendingAsync(yourSource)`, dispatches to handlers, and writes acknowledgments to `OutboxMessages`. +5. **Create an Outbox Processor** (`BackgroundService`) that polls outbox for your source and publishes responses back. +6. **Create a Cleanup Service** (or reuse the existing one if the retention policy matches). +7. **Register** all services in your application module's `ConfigureServices`. + +The entities, tables, and repositories are already shared — no schema changes needed. From 7dd0b819eabf127caf3b4baf28b1316c96a55d4a Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Tue, 10 Mar 2026 10:36:31 -0700 Subject: [PATCH 04/32] AB#31677 fix for null conditional preview --- .../GrantsPortal/GrantsPortalCommandConsumerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index 0cf879cb24..c6844cf8ad 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -248,7 +248,7 @@ private void CleanupConnection() { try { - _connection?.ConnectionShutdown -= OnConnectionShutdown; + if (_connection != null) _connection.ConnectionShutdown -= OnConnectionShutdown; _channel?.Close(); _channel?.Dispose(); _connection?.Close(); From 43ee63e31a09aa844f7cbd430a6fe54a68151f72 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Wed, 11 Mar 2026 16:59:46 -0700 Subject: [PATCH 05/32] AB#31677 abstract base for inbox/outbox --- .../applicant-profile-data-providers.md | 166 +++++++ .../grants-portal-rabbitmq-integration.md | 87 +++- .../transactional-outbox-pattern.md | 202 +++++++- .../ProfileData/ApplicantOrgInfoDto.cs | 4 + .../ProfileData/OrgInfoItemDto.cs | 19 + .../AddressInfoDataProvider.cs | 24 +- .../ApplicantProfile/OrgInfoDataProvider.cs | 71 ++- .../GrantManagerApplicationModule.cs | 14 +- .../GrantsPortalRabbitMqOptions.cs | 15 + .../GrantsPortalCommandConsumerService.cs | 69 ++- .../GrantsPortal/GrantsPortalInboxWorker.cs | 37 ++ .../GrantsPortalMessageCleanupService.cs | 70 --- .../GrantsPortalMessageCleanupWorker.cs | 82 ++++ .../GrantsPortalOutboxProcessorService.cs | 179 ------- .../GrantsPortal/GrantsPortalOutboxWorker.cs | 109 +++++ .../Handlers/AddressEditHandler.cs | 2 +- .../Handlers/AddressSetPrimaryHandler.cs | 4 +- .../Handlers/ContactCreateHandler.cs | 77 ++- .../Handlers/ContactDeleteHandler.cs | 4 +- .../Handlers/ContactEditHandler.cs | 2 +- .../Handlers/ContactSetPrimaryHandler.cs | 2 +- .../Handlers/OrganizationEditHandler.cs | 4 +- .../Handlers/PortalCommandHandlerAdapter.cs | 32 ++ .../Messages/PluginDataPayload.cs | 3 + .../InboxWorkerBase.cs} | 135 +++--- .../Messaging/OutboxWorkerBase.cs | 140 ++++++ .../Messaging/IInboxMessageHandler.cs | 30 ++ .../GrantManagerDbContext.cs | 2 +- .../appsettings.Development.json | 4 +- .../AddressInfoDataProviderTests.cs | 55 +++ .../ApplicantProfileDataProviderTests.cs | 17 +- .../Applicants/OrgInfoDataProviderTests.cs | 323 +++++++++++++ .../SubmissionInfoDataProviderTests.cs | 99 ++++ .../Contacts/ContactInfoDataProviderTests.cs | 20 + .../GrantsPortal/AddressEditHandlerTests.cs | 205 ++++++++ .../AddressSetPrimaryHandlerTests.cs | 204 ++++++++ .../GrantsPortal/ContactCreateHandlerTests.cs | 439 ++++++++++++++++++ .../GrantsPortal/ContactDeleteHandlerTests.cs | 143 ++++++ .../GrantsPortal/ContactEditHandlerTests.cs | 151 ++++++ .../ContactSetPrimaryHandlerTests.cs | 172 +++++++ .../OrganizationEditHandlerTests.cs | 242 ++++++++++ 41 files changed, 3260 insertions(+), 399 deletions(-) create mode 100644 applications/Unity.GrantManager/documentation/applicant-portal/applicant-profile-data-providers.md create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/{GrantsPortal/GrantsPortalInboxProcessorService.cs => Messaging/InboxWorkerBase.cs} (59%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs create mode 100644 applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs diff --git a/applications/Unity.GrantManager/documentation/applicant-portal/applicant-profile-data-providers.md b/applications/Unity.GrantManager/documentation/applicant-portal/applicant-profile-data-providers.md new file mode 100644 index 0000000000..9f14a9c8e0 --- /dev/null +++ b/applications/Unity.GrantManager/documentation/applicant-portal/applicant-profile-data-providers.md @@ -0,0 +1,166 @@ +# Applicant Profile — Data Providers + +## Overview + +The Applicant Profile system uses a provider-based architecture to serve different categories of profile data to the Applicant Portal. Each provider implements `IApplicantProfileDataProvider` and is responsible for a single data domain (contacts, addresses, organizations, etc.). + +The portal requests profile data by **key**, and the `ApplicantProfileAppService` routes the request to the matching provider. + +--- + +## Architecture + +```mermaid +graph LR + Portal["Applicant Portal"] -->|"GET /profile?key=..."| API["ApplicantProfileController"] + API --> SVC["ApplicantProfileAppService"] + SVC -->|"Key lookup"| P1["ContactInfoDataProvider"] + SVC -->|"Key lookup"| P2["OrgInfoDataProvider"] + SVC -->|"Key lookup"| P3["AddressInfoDataProvider"] + SVC -->|"Key lookup"| P4["SubmissionInfoDataProvider"] + SVC -->|"Key lookup"| P5["PaymentInfoDataProvider"] +``` + +--- + +## Provider Interface + +```csharp +public interface IApplicantProfileDataProvider +{ + string Key { get; } + Task GetDataAsync(ApplicantProfileInfoRequest request); +} +``` + +All providers are registered via ABP's `[ExposeServices(typeof(IApplicantProfileDataProvider))]` attribute and resolved as an `IEnumerable` collection. The app service indexes them by `Key` for O(1) dispatch. + +### Request Fields + +| Field | Type | Description | +|-------|------|-------------| +| `ProfileId` | `Guid` | The applicant's profile identifier | +| `Subject` | `string` | OIDC subject (e.g. `testuser@idir`) — normalized by stripping the `@domain` suffix and upper-casing | +| `TenantId` | `Guid` | Tenant context for data isolation | +| `Key` | `string` | Provider discriminator (e.g. `ORGINFO`, `CONTACTINFO`) | + +### Subject Normalization + +All providers that query by OIDC subject use the same normalization logic: + +``` +testuser@idir → TESTUSER +TESTUSER → TESTUSER +``` + +This matches the `OidcSub` values stored on `ApplicationFormSubmission` records. + +--- + +## Providers + +### OrgInfoDataProvider + +**Key**: `ORGINFO` + +**Source**: `Applicant` entity, linked via `ApplicationFormSubmission.ApplicantId`. + +**Query**: Joins `ApplicationFormSubmission` → `Applicant` where `OidcSub` matches the normalized subject. Returns all matching applicant records — duplicates are **not** removed, since a single user may have multiple submissions pointing to the same or different applicant records. The UI is responsible for presenting this appropriately. + +**Response DTO**: `ApplicantOrgInfoDto` + +```json +{ + "dataType": "ORGINFO", + "organizations": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "orgName": "Acme Corp", + "organizationType": "Non-Profit", + "orgNumber": "BC1234567", + "orgStatus": "Active", + "nonRegOrgName": null, + "fiscalMonth": "April", + "fiscalDay": 1, + "organizationSize": "51-100", + "sector": "Technology", + "subSector": "Software" + } + ] +} +``` + +**Fields** (from `Applicant` entity): + +| DTO Field | Entity Field | Type | Description | +|-----------|-------------|------|-------------| +| `Id` | `Applicant.Id` | `Guid` | Applicant ID — used as `organizationId` for edit commands | +| `OrgName` | `Applicant.OrgName` | `string?` | Organization name | +| `OrganizationType` | `Applicant.OrganizationType` | `string?` | Type of organization | +| `OrgNumber` | `Applicant.OrgNumber` | `string?` | Organization registration number | +| `OrgStatus` | `Applicant.OrgStatus` | `string?` | Organization status | +| `NonRegOrgName` | `Applicant.NonRegOrgName` | `string?` | Non-registered organization name | +| `FiscalMonth` | `Applicant.FiscalMonth` | `string?` | Fiscal year start month | +| `FiscalDay` | `Applicant.FiscalDay` | `int?` | Fiscal year start day | +| `OrganizationSize` | `Applicant.OrganizationSize` | `string?` | Size category | +| `Sector` | `Applicant.Sector` | `string?` | Industry sector | +| `SubSector` | `Applicant.SubSector` | `string?` | Industry sub-sector | + +**Multiple Applicants**: It is possible for a single OIDC subject to be linked to multiple distinct `Applicant` records (via different `ApplicationFormSubmission` rows). The provider returns all of them. When the same applicant is linked by multiple submissions, each join result is returned — the UI handles presentation and any eventual deduplication is a process-level concern. + +**Relationship to OrganizationEditHandler**: The `ORGANIZATION_EDIT_COMMAND` handler (see [RabbitMQ integration](./grants-portal-rabbitmq-integration.md)) updates a single `Applicant` entity by its ID. The `Id` field in the org info response corresponds to the `organizationId` expected by the edit command payload. + +### ContactInfoDataProvider + +**Key**: `CONTACTINFO` + +**Source**: Aggregates contacts from three sources via `IApplicantProfileContactService`: +1. Profile-linked contacts (by `ProfileId`) +2. Application-level contacts (by normalized subject) +3. Applicant agent contacts (by normalized subject) + +### AddressInfoDataProvider + +**Key**: `ADDRESSINFO` + +**Source**: `ApplicantAddress` entity, linked via both `ApplicationFormSubmission.ApplicationId` and `ApplicationFormSubmission.ApplicantId`. + +**Editability rules:** +- Addresses linked by `ApplicationId` are **always read-only** (owned by an application). +- Addresses linked by `ApplicantId` are **editable**, unless the ApplicantId path resolves to more than one distinct `ApplicantId` — in that case, those addresses are also marked read-only to prevent ambiguous edits. + +Duplicates (same address ID appearing in both joins) are deduplicated, with the application-linked version taking priority. + +### SubmissionInfoDataProvider + +**Key**: `SUBMISSIONINFO` + +**Source**: `ApplicationFormSubmission` joined to `Application` and `ApplicationStatus`. Resolves submission timestamps from CHEFS JSON data and derives form view URLs from the INTAKE_API_BASE setting. + +### PaymentInfoDataProvider + +**Key**: `PAYMENTINFO` + +**Source**: Placeholder — returns an empty `ApplicantPaymentInfoDto`. Reserved for future implementation. + +--- + +## Data Flow: Read vs. Write + +| Direction | Mechanism | Example | +|-----------|-----------|---------| +| **Read** (Portal → Unity) | HTTP GET via `ApplicantProfileController` → provider | Portal requests org info by key `ORGINFO` | +| **Write** (Portal → Unity) | RabbitMQ command via [messaging pipeline](./grants-portal-rabbitmq-integration.md) | Portal sends `ORGANIZATION_EDIT_COMMAND` with applicant ID | + +The `Id` returned by each provider's read response is used as the entity identifier in the corresponding write command. For organization data, the `OrgInfoItemDto.Id` maps to the `organizationId` field in `PluginDataPayload`. + +--- + +## Adding a New Provider + +1. Create a DTO class inheriting from `ApplicantProfileDataDto` in `Application.Contracts/ApplicantProfile/ProfileData/` +2. Register the DTO as a `[JsonDerivedType]` on `ApplicantProfileDataDto` +3. Add a key constant to `ApplicantProfileKeys` +4. Implement `IApplicantProfileDataProvider` in `Application/ApplicantProfile/` +5. Annotate with `[ExposeServices(typeof(IApplicantProfileDataProvider))]` and `ITransientDependency` +6. Add unit tests following the patterns in `OrgInfoDataProviderTests` or `AddressInfoDataProviderTests` diff --git a/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md b/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md index 097831b5bf..1e8cbd532f 100644 --- a/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md +++ b/applications/Unity.GrantManager/documentation/applicant-portal/grants-portal-rabbitmq-integration.md @@ -4,7 +4,7 @@ The Unity Grant Manager receives commands from the Applicant Portal (Grants Portal) via RabbitMQ and sends acknowledgment responses back. This provides reliable, decoupled communication for profile data mutations (contacts, addresses, organizations) that the portal user initiates. -The integration is built on the [Transactional Outbox Pattern](./transactional-outbox-pattern.md) with four dedicated `BackgroundService` instances forming a message processing pipeline. +The integration is built on the [Transactional Outbox Pattern](./transactional-outbox-pattern.md) with a message processing pipeline: the consumer runs as a `BackgroundService` (competing consumer on every pod), while the inbox processor, outbox publisher, and cleanup workers run as Quartz `[DisallowConcurrentExecution]` jobs coordinated via the clustered Quartz scheduler. **Source name**: `"GrantsPortal"` (used as the discriminator in inbox/outbox tables) @@ -27,11 +27,11 @@ graph TB subgraph Unity["Unity Grant Manager"] S1["① GrantsPortalCommandConsumerService
RabbitMQ → InboxMessages"] IT[(InboxMessages)] - S2["② GrantsPortalInboxProcessorService
InboxMessages → Handler → OutboxMessages"] + S2["② GrantsPortalInboxWorker
InboxMessages → Handler → OutboxMessages"] H["IPortalCommandHandler
implementations"] OT[(OutboxMessages)] - S3["③ GrantsPortalOutboxProcessorService
OutboxMessages → RabbitMQ"] - S4["④ GrantsPortalMessageCleanupService
Purge old rows"] + S3["③ GrantsPortalOutboxWorker
OutboxMessages → RabbitMQ"] + S4["④ GrantsPortalMessageCleanupWorker
Purge old rows"] end PP -->|"Publish command"| EX @@ -89,6 +89,7 @@ All inbound commands use the `PluginDataEnvelope` wrapper: "action": "CONTACT_CREATE_COMMAND", "contactId": "a1b2c3d4-...", "profileId": "3fa85f64-...", + "subject": "testuser@idir", "provider": "7c9e6679-...", "data": { } } @@ -124,6 +125,7 @@ All inbound commands use the `PluginDataEnvelope` wrapper: | `addressId` | `string?` | Target address ID (for address commands) | | `organizationId` | `string?` | Target organization/applicant ID | | `profileId` | `string?` | Applicant profile ID | +| `subject` | `string?` | Raw OIDC subject identifier (e.g. `testuser@idir`). Used by `ContactCreateHandler` to match submissions — Unity normalizes by stripping the IDP suffix and uppercasing before comparison. | | `provider` | `string?` | **Tenant ID** as a GUID string — used for tenant resolution | | `data` | `JObject?` | Inner command-specific data payload | @@ -179,13 +181,17 @@ After processing, Unity publishes an acknowledgment: 6. Insert `InboxMessage` with `Status = Pending` 7. **ACK** the delivery tag (only after commit) -**Connection recovery**: On `ConnectionShutdown`, waits 5s then re-runs `ConnectAndConsumeAsync`. +**Connection recovery**: On `ConnectionShutdown`, waits 5s then re-runs `ConnectAndConsumeAsync`. A `SemaphoreSlim` guard prevents parallel reconnect attempts within the same process when RabbitMQ fires multiple shutdown events in rapid succession (e.g., network flap). -### ② GrantsPortalInboxProcessorService +**Multi-pod idempotency**: The `InboxMessages.MessageId` column has a **unique index**. The consumer first checks `FindByMessageIdAsync` (fast path), but if two pods race on a redelivered message, the unique constraint prevents duplicate inserts. The consumer catches the PostgreSQL `23505` (unique violation) and treats it as idempotent success — ACKs without requeueing. + +### ② GrantsPortalInboxWorker **Role**: Polls inbox, dispatches to handlers, writes ack to outbox. -**Polling**: Every 5s when messages were found, every 15s when idle. Batch size: 10. +**Schedule**: Quartz cron (default: `0/5 * * * * ?` — every 5 seconds). Configurable via `InboxProcessorCron`. + +**Concurrency**: `[DisallowConcurrentExecution]` — only one instance runs at a time. **Per message**: 1. Mark `Status = Processing`, increment `RetryCount` @@ -203,11 +209,13 @@ After processing, Unity publishes an acknowledgment: **Tenant context**: Only the handler execution runs under the tenant context. Inbox/outbox operations run against the host database without tenant scoping. -### ③ GrantsPortalOutboxProcessorService +### ③ GrantsPortalOutboxWorker **Role**: Polls outbox, publishes acks to RabbitMQ with publisher confirms. -**Polling**: Every 5s when messages were found, every 15s when idle. Batch size: 10. +**Schedule**: Quartz cron (default: `0/5 * * * * ?` — every 5 seconds). Configurable via `OutboxProcessorCron`. + +**Concurrency**: `[DisallowConcurrentExecution]` — only one instance runs at a time. **Per message**: 1. Ensure RabbitMQ channel is open (with `ConfirmSelect` enabled) @@ -216,14 +224,14 @@ After processing, Unity publishes an acknowledgment: 4. On confirm: mark `Status = Processed`, set `PublishedAt` 5. On failure: increment `RetryCount`; after 3 attempts mark as `Failed` -### ④ GrantsPortalMessageCleanupService +### ④ GrantsPortalMessageCleanupWorker **Role**: Purges old processed/failed messages from both tables. -- **Interval**: Every 1 hour +- **Schedule**: Quartz cron (default: `0 0 0/1 * * ?` — every hour). Configurable via `MessageCleanupCron`. - **Retention**: Configurable via `MessageRetentionDays` (default: 30) - **Scope**: Deletes rows where `Status ∈ {Processed, Failed}` and `ReceivedAt`/`CreatedAt` < cutoff -- **Startup delay**: 1 minute (waits for app to fully start) +- **Concurrency**: `[DisallowConcurrentExecution]` --- @@ -245,13 +253,13 @@ The return string becomes the `Details` field in the outbound acknowledgment. | DataType | Handler | Entity | Description | |----------|---------|--------|-------------| -| `CONTACT_CREATE_COMMAND` | `ContactCreateHandler` | `Contact` + `ContactLink` | Creates a new contact and links it to the profile. Idempotent — skips if contact already exists. | +| `CONTACT_CREATE_COMMAND` | `ContactCreateHandler` | `Contact` + `ContactLink` | Creates a new contact and links it to the profile. Enriches the contact with applicant agent IDs from matching submissions. Idempotent — skips if contact already exists. | | `CONTACT_EDIT_COMMAND` | `ContactEditHandler` | `Contact` | Updates an existing contact's fields. | | `CONTACT_SET_PRIMARY_COMMAND` | `ContactSetPrimaryHandler` | `ContactLink` | Sets one contact as primary for a profile; clears primary on all other links. | | `CONTACT_DELETE_COMMAND` | `ContactDeleteHandler` | `ContactLink` + `Contact` | Deletes contact links then the contact entity. | | `ADDRESS_EDIT_COMMAND` | `AddressEditHandler` | `ApplicantAddress` | Updates address fields (street, city, province, etc.) and address type. | | `ADDRESS_SET_PRIMARY_COMMAND` | `AddressSetPrimaryHandler` | `ApplicantAddress` | Sets `isPrimary` extra property on the target address; clears it on sibling addresses that had it set. | -| `ORGANIZATION_EDIT_COMMAND` | `OrganizationEditHandler` | `Applicant` | Updates organization fields on the applicant entity. | +| `ORGANIZATION_EDIT_COMMAND` | `OrganizationEditHandler` | `Applicant` | Updates organization fields on the applicant entity. The `organizationId` corresponds to `Applicant.Id` returned by [OrgInfoDataProvider](./applicant-profile-data-providers.md#orginfordataprovider). | ### Command Data Payloads @@ -273,6 +281,33 @@ Each command that requires inner data deserializes `payload.Data` to a typed cla } ``` +### Applicant Agent ID Enrichment + +When a contact is created via `CONTACT_CREATE_COMMAND`, the handler enriches the `Contact` entity with applicant agent IDs linked to the subject's submissions. This allows downstream systems to associate portal contacts with existing application agents. + +**How it works**: + +1. The handler reads the raw OIDC subject from `payload.Subject` (e.g. `testuser@idir`). +2. It normalizes the subject to match the format stored in `ApplicationFormSubmission.OidcSub`: + - Strips the IDP suffix (everything after and including `@`) + - Converts to uppercase + - Example: `testuser@idir` → `TESTUSER` +3. It queries `ApplicationFormSubmission` records where `OidcSub` matches the normalized value. +4. From those submissions, it collects distinct `ApplicationId` values. +5. It queries `ApplicantAgent` records linked to those applications. +6. The distinct agent IDs are stored on the contact's `ExtraProperties` as `applicantAgentIds`. + +> **Note**: The normalization follows the same convention as `IntakeSubmissionHelper.ExtractOidcSub`, which is used when CHEFS submissions are ingested. + +**Resulting ExtraProperties** (on the `Contact` entity): +```json +{ + "applicantAgentIds": ["agent-guid-1", "agent-guid-2"] +} +``` + +If `subject` is null/empty, or no matching submissions or agents are found, the `applicantAgentIds` property is not set. This is a best-effort enrichment and does not fail the contact creation. + **AddressEditData**: ```json { @@ -338,7 +373,10 @@ The tenant ID is stored on the `InboxMessage.TenantId` field. The inbox processo "InboundQueue": "unity.commands", "InboundRoutingKeys": [ "commands.unity.plugindata" ], "AckRoutingKey": "grants.unity.acknowledgment", - "MessageRetentionDays": 30 + "MessageRetentionDays": 30, + "InboxProcessorCron": "0/5 * * * * ?", + "OutboxProcessorCron": "0/5 * * * * ?", + "MessageCleanupCron": "0 0 0/1 * * ?" } } } @@ -358,6 +396,9 @@ Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOp | `InboundRoutingKeys` | `["commands.unity.plugindata"]` | Routing keys to bind | | `AckRoutingKey` | `"grants.unity.acknowledgment"` | Routing key for outbound acks | | `MessageRetentionDays` | `30` | Days to retain processed/failed messages | +| `InboxProcessorCron` | `"0/5 * * * * ?"` | Quartz cron for inbox polling (every 5s) | +| `OutboxProcessorCron` | `"0/5 * * * * ?"` | Quartz cron for outbox publishing (every 5s) | +| `MessageCleanupCron` | `"0 0 0/1 * * ?"` | Quartz cron for message cleanup (every hour) | **Section path**: `RabbitMQ:GrantsPortal` @@ -384,13 +425,15 @@ context.Services.AddTransient(); // Acknowledgment publisher context.Services.AddScoped(); -// Background services (pipeline stages) +// Pipeline services context.Services.AddHostedService(); // ① RabbitMQ → inbox -context.Services.AddHostedService(); // ② inbox → handler → outbox -context.Services.AddHostedService(); // ③ outbox → RabbitMQ -context.Services.AddHostedService(); // ④ purge old rows +// ② GrantsPortalInboxWorker — Quartz (auto-registered) — inbox → handler → outbox +// ③ GrantsPortalOutboxWorker — Quartz (auto-registered) — outbox → RabbitMQ +// ④ GrantsPortalMessageCleanupWorker — Quartz (auto-registered) — purge old rows ``` +> **Note**: Workers ②③④ extend `QuartzBackgroundWorkerBase` with `[DisallowConcurrentExecution]` and are auto-registered by ABP when `BackgroundJobs:Quartz:IsAutoRegisterEnabled` is `true`. + --- ## End-to-End Example @@ -403,10 +446,10 @@ sequenceDiagram participant RMQ as RabbitMQ participant Consumer as ① Consumer participant Inbox as InboxMessages - participant Processor as ② Inbox Processor + participant Processor as ② Inbox Worker participant Handler as ContactEditHandler participant Outbox as OutboxMessages - participant Publisher as ③ Outbox Processor + participant Publisher as ③ Outbox Worker Portal->>RMQ: Publish CONTACT_EDIT_COMMAND
routing: commands.unity.plugindata RMQ->>Consumer: Deliver message @@ -491,7 +534,7 @@ SELECT * FROM "InboxMessages" WHERE "Status" = 'Pending' AND "ReceivedAt" < NOW() - INTERVAL '5 minutes'; ``` -**Resolution**: Verify `GrantsPortalInboxProcessorService` is running in application logs. Restart the application if needed. +**Resolution**: Verify `GrantsPortalInboxWorker` is running in application logs. Restart the application if needed. ### Messages stuck in Pending (Outbox) diff --git a/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md b/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md index 2918157397..253d606770 100644 --- a/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md +++ b/applications/Unity.GrantManager/documentation/applicant-portal/transactional-outbox-pattern.md @@ -23,7 +23,7 @@ Direct RabbitMQ consumption with inline processing has several failure modes: ## Architecture -The pattern separates the messaging pipeline into four independent stages, each run by a dedicated `BackgroundService`: +The pattern separates the messaging pipeline into four independent stages: ```mermaid graph LR @@ -32,10 +32,10 @@ graph LR subgraph Unity["Unity Grant Manager — Host Database"] S1["① Consumer
BackgroundService"] IT[(InboxMessages
Table)] - S2["② Inbox Processor
BackgroundService"] + S2["② Inbox Worker
Quartz [DisallowConcurrentExecution]"] OT[(OutboxMessages
Table)] - S3["③ Outbox Processor
BackgroundService"] - S4["④ Cleanup
BackgroundService"] + S3["③ Outbox Worker
Quartz [DisallowConcurrentExecution]"] + S4["④ Cleanup Worker
Quartz [DisallowConcurrentExecution]"] end RMQ -->|"Consume + ACK"| S1 @@ -154,11 +154,11 @@ CREATE TABLE "InboxMessages" ( "LastModifierId" UUID ); -CREATE UNIQUE INDEX "IX_InboxMessages_Source_MessageId" - ON "InboxMessages" ("Source", "MessageId"); +CREATE UNIQUE INDEX "IX_InboxMessages_MessageId" + ON "InboxMessages" ("MessageId"); -CREATE INDEX "IX_InboxMessages_Status_ReceivedAt" - ON "InboxMessages" ("Status", "ReceivedAt"); +CREATE INDEX "IX_InboxMessages_Source_Status" + ON "InboxMessages" ("Source", "Status"); ``` ### OutboxMessages @@ -187,8 +187,8 @@ CREATE TABLE "OutboxMessages" ( "LastModifierId" UUID ); -CREATE INDEX "IX_OutboxMessages_Status_CreatedAt" - ON "OutboxMessages" ("Status", "CreatedAt"); +CREATE INDEX "IX_OutboxMessages_Source_Status" + ON "OutboxMessages" ("Source", "Status"); ``` --- @@ -280,6 +280,8 @@ FindByMessageIdAsync(messageId) → if exists, ACK and skip This prevents duplicate inbox rows if the broker redelivers a message (e.g. after a network hiccup before the ACK reached the broker). +**Multi-pod safety**: In a multi-pod deployment, two pods could race past the `FindByMessageIdAsync` check on the same redelivered message. The `MessageId` column has a **unique index** (`IX_InboxMessages_MessageId`) as the definitive guard. If the second pod’s insert hits the unique constraint (PostgreSQL error `23505`), the consumer catches it and treats it as idempotent success — ACKs without requeueing. + --- ## Error Handling @@ -305,7 +307,7 @@ Errors are considered transient (eligible for retry) if the exception type name ## Cleanup / Retention -A dedicated `BackgroundService` runs hourly and deletes `Processed` and `Failed` messages older than the configured retention period. The default retention is **30 days**. +A dedicated Quartz worker runs hourly and deletes `Processed` and `Failed` messages older than the configured retention period. The default retention is **30 days**. Both inbox and outbox tables are cleaned in the same pass. @@ -313,14 +315,174 @@ Both inbox and outbox tables are cleaned in the same pass. ## Adding a New Integration Source -To add a new external system using this pattern: +The inbox/outbox infrastructure provides base classes that handle all orchestration logic. +A new integration only needs to provide source-specific configuration, handlers, and a publish implementation. + +### What you get for free + +| Concern | Provided by | +|---------|------------| +| Poll pending → mark processing → dispatch → retry → mark complete → write outbox ack | `InboxWorkerBase` (`Unity.GrantManager.Application/Messaging/`) | +| Poll pending outbox → publish → mark sent/failed with retry | `OutboxWorkerBase` (`Unity.GrantManager.Application/Messaging/`) | +| Handler dispatch by `Source` + `DataType` | `IInboxMessageHandler` (`Unity.GrantManager.Domain/Messaging/`) | +| Shared tables, entities, repos, status machine | `InboxMessage`, `OutboxMessage`, `IInboxMessageRepository`, `IOutboxMessageRepository` | +| Message cleanup | Existing `GrantsPortalMessageCleanupWorker` (deletes all sources — can be reused or cloned) | + +### Step-by-step + +#### 1. Choose a source name + +Pick a unique string (e.g. `"Finance"`) that will be stored in the `Source` column of both tables. + +#### 2. Create an options class + +```csharp +// YourIntegration/Configuration/FinanceIntegrationOptions.cs +public class FinanceIntegrationOptions +{ + public const string SectionName = "Integrations:Finance"; + public const string SourceName = "Finance"; + + public string InboxProcessorCron { get; set; } = "0/10 * * * * ?"; + public string OutboxProcessorCron { get; set; } = "0/10 * * * * ?"; + + // Add transport-specific properties (endpoints, queues, API keys, etc.) +} +``` + +#### 3. Create message handlers + +Implement `IInboxMessageHandler` for each command type. Each handler receives the raw JSON payload and is responsible for its own deserialization: + +```csharp +// YourIntegration/Handlers/InvoiceCreatedHandler.cs +public class InvoiceCreatedHandler : IInboxMessageHandler, ITransientDependency +{ + public string Source => FinanceIntegrationOptions.SourceName; + public string DataType => "INVOICE_CREATED"; + + public async Task HandleAsync(string rawPayload) + { + var data = JsonConvert.DeserializeObject(rawPayload) + ?? throw new JsonException("Invalid payload"); + + // Domain logic here... + + return "Invoice processed successfully"; + } +} +``` + +#### 4. Create an inbox worker + +Subclass `InboxWorkerBase` — typically ~15 lines: + +```csharp +// YourIntegration/FinanceInboxWorker.cs +public class FinanceInboxWorker : InboxWorkerBase +{ + protected override string SourceName => FinanceIntegrationOptions.SourceName; + + public FinanceInboxWorker( + IServiceProvider serviceProvider, + IOptions options) + : base(serviceProvider) + { + JobDetail = JobBuilder.Create() + .WithIdentity(nameof(FinanceInboxWorker)).Build(); + + Trigger = TriggerBuilder.Create() + .WithIdentity(nameof(FinanceInboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(options.Value.InboxProcessorCron) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } +} +``` + +The base class handles: polling, status transitions, tenant context switching, handler dispatch by `Source` + `DataType`, transient error retry, user-friendly error mapping, and writing the outbox ack. + +Override `ToUserFriendlyMessage()` or `IsTransientError()` if your integration has custom error types. + +#### 5. Create an outbox worker + +Subclass `OutboxWorkerBase` and implement `PublishMessageAsync`: + +```csharp +// YourIntegration/FinanceOutboxWorker.cs +public class FinanceOutboxWorker : OutboxWorkerBase +{ + protected override string SourceName => FinanceIntegrationOptions.SourceName; + + public FinanceOutboxWorker( + IServiceProvider serviceProvider, + IOptions options) + : base(serviceProvider) + { + JobDetail = JobBuilder.Create() + .WithIdentity(nameof(FinanceOutboxWorker)).Build(); + + Trigger = TriggerBuilder.Create() + .WithIdentity(nameof(FinanceOutboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(options.Value.OutboxProcessorCron) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } + + protected override async Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg) + { + // Your transport-specific publish logic here (HTTP, RabbitMQ, gRPC, etc.) + // Throw on failure — the base class handles retry and status updates. + } + + // Optional: override OnBeforePublishCycle() to ensure connections are ready + // Optional: override OnPublishCycleError() to clean up connections on failure +} +``` + +#### 6. Create a consumer (optional — depends on your transport) + +If consuming from a message broker (RabbitMQ, Kafka, etc.), create a `BackgroundService` that: +- Receives messages from the broker +- Saves them to `InboxMessages` with your source name inside a Unit of Work +- ACKs the broker only after the UoW commits + +See `GrantsPortalCommandConsumerService` for a RabbitMQ reference implementation. + +If your inbound messages arrive via HTTP webhook, save them to the inbox table in the webhook endpoint controller instead. + +#### 7. Register in DI + +In your module's `ConfigureServices`: + +```csharp +// Options +context.Services.Configure( + configuration.GetSection(FinanceIntegrationOptions.SectionName)); + +// Handlers (auto-registered if using ITransientDependency, otherwise register explicitly) +context.Services.AddTransient(); + +// Consumer (if using a BackgroundService) +context.Services.AddHostedService(); +``` + +The Quartz inbox/outbox workers auto-register when `BackgroundJobs:Quartz:IsAutoRegisterEnabled` is `true`. + +#### 8. Cleanup + +The existing `GrantsPortalMessageCleanupWorker` deletes all `Processed`/`Failed` rows regardless of source. If the default 30-day retention works for your integration, no additional cleanup worker is needed. + +### What you do NOT need to do + +- No schema changes — the shared tables already support multiple sources via the `Source` column +- No changes to existing integrations — each source's workers and handlers are fully independent +- No custom orchestration logic — `InboxWorkerBase` and `OutboxWorkerBase` handle the full lifecycle -1. **Choose a source name** (e.g. `"NewSystem"`) — this discriminates your messages in the shared tables. -2. **Create a Consumer** (`BackgroundService`) that receives from your broker/transport, saves to `InboxMessages` with your source name, and ACKs. -3. **Create command handlers** implementing your handler interface, registered in DI. -4. **Create an Inbox Processor** (`BackgroundService`) that polls `GetPendingAsync(yourSource)`, dispatches to handlers, and writes acknowledgments to `OutboxMessages`. -5. **Create an Outbox Processor** (`BackgroundService`) that polls outbox for your source and publishes responses back. -6. **Create a Cleanup Service** (or reuse the existing one if the retention policy matches). -7. **Register** all services in your application module's `ConfigureServices`. +### Future considerations -The entities, tables, and repositories are already shared — no schema changes needed. +| Area | Status | Notes | +|------|--------|-------| +| Base options interface | Not yet extracted | Could extract `IIntegrationSourceOptions` with shared cron/retention fields | +| Base consumer service | Not yet extracted | RabbitMQ connection management could be shared; currently each consumer owns its own | +| Source-aware cleanup | Not yet needed | Current cleanup worker deletes all sources; could filter by source if retention policies differ | diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs index 4a99135f3d..4ca718a427 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantOrgInfoDto : ApplicantProfileDataDto { public override string DataType => "ORGINFO"; + + public List Organizations { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs new file mode 100644 index 0000000000..f5ef23aac4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class OrgInfoItemDto + { + public Guid Id { get; set; } + public string? OrgName { get; set; } + public string? OrganizationType { get; set; } + public string? OrgNumber { get; set; } + public string? OrgStatus { get; set; } + public string? NonRegOrgName { get; set; } + public string? FiscalMonth { get; set; } + public int? FiscalDay { get; set; } + public string? OrganizationSize { get; set; } + public string? Sector { get; set; } + public string? SubSector { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index 44d54844b6..cee6f516ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -16,7 +16,9 @@ namespace Unity.GrantManager.ApplicantProfile /// Provides address information for the applicant profile by querying /// application addresses linked to the applicant's form submissions. /// Addresses are resolved via both the ApplicationId and ApplicantId - /// relationships, with duplicates removed. + /// relationships, with duplicates removed. Addresses linked via + /// ApplicationId are always read-only. Addresses linked via ApplicantId + /// are editable only when that set resolves to a single ApplicantId. ///
[ExposeServices(typeof(IApplicantProfileDataProvider))] public class AddressInfoDataProvider( @@ -56,26 +58,34 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ from submission in matchingSubmissions join address in addressesQuery on submission.ApplicationId equals address.ApplicationId join application in applicationsQuery on address.ApplicationId equals application.Id - select new { address, address.CreationTime, application.ReferenceNo, IsEditable = false }; + select new { address, address.CreationTime, application.ReferenceNo, IsFromApplicantPath = false, address.ApplicantId }; - // Addresses linked via ApplicantId — editable (directly from the applicant) + // Addresses linked via ApplicantId — conditionally editable var byApplicantId = from submission in matchingSubmissions join address in addressesQuery on submission.ApplicantId equals address.ApplicantId join application in applicationsQuery on address.ApplicationId equals application.Id into apps from application in apps.DefaultIfEmpty() - select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsEditable = true }; + select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsFromApplicantPath = true, address.ApplicantId }; var results = await byApplicationId .Concat(byApplicantId) .ToListAsync(); - // Deduplicate by address Id — application-linked (IsEditable = false) takes priority + // Deduplicate by address Id — application-linked (IsFromApplicantPath = false) takes priority var deduplicated = results .GroupBy(r => r.address.Id) - .Select(g => g.OrderBy(r => r.IsEditable).First()) + .Select(g => g.OrderBy(r => r.IsFromApplicantPath).First()) .ToList(); + // Addresses from the ApplicantId path are editable only when + // that path resolves to a single ApplicantId + var applicantPathEditable = results + .Where(r => r.IsFromApplicantPath && r.ApplicantId != null) + .Select(r => r.ApplicantId) + .Distinct() + .Count() <= 1; + var addressDtos = deduplicated.Select(r => new AddressInfoItemDto { Id = r.address.Id, @@ -88,7 +98,7 @@ from application in apps.DefaultIfEmpty() PostalCode = r.address.Postal ?? string.Empty, Country = r.address.Country ?? string.Empty, IsPrimary = r.address.HasProperty("isPrimary") && r.address.GetProperty("isPrimary"), - IsEditable = r.IsEditable, + IsEditable = r.IsFromApplicantPath && applicantPathEditable, ReferenceNo = r.ReferenceNo }).ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs index cc0bc93682..45ff3298ae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs @@ -1,23 +1,84 @@ +using System; +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager.ApplicantProfile { /// - /// Provides organization information for the applicant profile. - /// This is a placeholder provider for future implementation. + /// Provides organization information for the applicant profile by querying + /// applicants linked to the applicant's form submissions via OIDC subject. /// [ExposeServices(typeof(IApplicantProfileDataProvider))] - public class OrgInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency + public class OrgInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicantRepository) + : IApplicantProfileDataProvider, ITransientDependency { /// public string Key => ApplicantProfileKeys.OrgInfo; /// - public Task GetDataAsync(ApplicantProfileInfoRequest request) + public async Task GetDataAsync(ApplicantProfileInfoRequest request) { - return Task.FromResult(new ApplicantOrgInfoDto()); + var dto = new ApplicantOrgInfoDto + { + Organizations = [] + }; + + var subject = request.Subject ?? string.Empty; + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicantsQuery = await applicantRepository.GetQueryableAsync(); + + var results = await ( + from submission in submissionsQuery + join applicant in applicantsQuery on submission.ApplicantId equals applicant.Id + where submission.OidcSub == normalizedSubject + select new + { + applicant.Id, + applicant.OrgName, + applicant.OrganizationType, + applicant.OrgNumber, + applicant.OrgStatus, + applicant.NonRegOrgName, + applicant.FiscalMonth, + applicant.FiscalDay, + applicant.OrganizationSize, + applicant.Sector, + applicant.SubSector + }) + .ToListAsync(); + + dto.Organizations.AddRange(results.Select(r => new OrgInfoItemDto + { + Id = r.Id, + OrgName = r.OrgName, + OrganizationType = r.OrganizationType, + OrgNumber = r.OrgNumber, + OrgStatus = r.OrgStatus, + NonRegOrgName = r.NonRegOrgName, + FiscalMonth = r.FiscalMonth, + FiscalDay = r.FiscalDay, + OrganizationSize = r.OrganizationSize, + Sector = r.Sector, + SubSector = r.SubSector + })); + } + + return dto; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 8aaefd869f..0204698482 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -44,6 +44,7 @@ using Unity.GrantManager.GrantsPortal; using Unity.GrantManager.GrantsPortal.Configuration; using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.Messaging; namespace Unity.GrantManager; @@ -160,11 +161,18 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddTransient(); context.Services.AddTransient(); context.Services.AddTransient(); + + // Register generic IInboxMessageHandler adapters for each portal command handler + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddTransient(sp => new PortalCommandHandlerAdapter(sp.GetRequiredService())); + context.Services.AddScoped(); context.Services.AddHostedService(); // RabbitMQ → inbox table - context.Services.AddHostedService(); // inbox table → process → outbox table - context.Services.AddHostedService(); // outbox table → RabbitMQ - context.Services.AddHostedService(); // purge old processed messages context.Services.AddScoped(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs index 26fd4799d2..95e30b2a99 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs @@ -19,4 +19,19 @@ public class GrantsPortalRabbitMqOptions /// Number of days to retain processed/failed messages before cleanup. ///
public int MessageRetentionDays { get; set; } = 30; + + /// + /// Cron expression for the inbox processor worker. Default: every 5 seconds. + /// + public string InboxProcessorCron { get; set; } = "0/5 * * * * ?"; + + /// + /// Cron expression for the outbox processor worker. Default: every 5 seconds. + /// + public string OutboxProcessorCron { get; set; } = "0/5 * * * * ?"; + + /// + /// Cron expression for the message cleanup worker. Default: every hour at minute 0. + /// + public string MessageCleanupCron { get; set; } = "0 0 0/1 * * ?"; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index c6844cf8ad..9df874d52e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -18,7 +18,8 @@ namespace Unity.GrantManager.GrantsPortal; /// /// Pulls messages off the RabbitMQ queue, saves them to the inbox table, and ACKs immediately. -/// Actual processing is done by . +/// Runs on every pod as a competing consumer — RabbitMQ distributes messages round-robin. +/// Actual processing is done by . /// public class GrantsPortalCommandConsumerService( IServiceProvider serviceProvider, @@ -27,6 +28,15 @@ public class GrantsPortalCommandConsumerService( ILogger logger) : BackgroundService { private readonly GrantsPortalRabbitMqOptions _options = options.Value; + + // Guards against concurrent reconnect attempts within this process. + // RabbitMQ can fire ConnectionShutdown multiple times in rapid succession + // (e.g., network flap, broker restart) on different threadpool threads. + // Without this, parallel Task.Run calls would race on the shared + // _connection/_channel fields — one disposes while the other connects. + // This is NOT for cross-pod coordination (RabbitMQ handles that). + private readonly SemaphoreSlim _reconnectLock = new(1, 1); + private IConnection? _connection; private IModel? _channel; @@ -46,9 +56,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(Timeout.Infinite, stoppingToken); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { - logger.LogInformation("Grants Portal command consumer stopping..."); + logger.LogInformation("Grants Portal command consumer stopping... {Ex}", ex.Message); } } @@ -79,7 +89,7 @@ private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) } catch (Exception ex) { - logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts", MaxRetries); + logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts : {Ex}", MaxRetries, ex); throw; } } @@ -93,16 +103,30 @@ private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) _ = Task.Run(async () => { - await Task.Delay(InitialRetryDelay, _stoppingToken); + if (!await _reconnectLock.WaitAsync(0, _stoppingToken)) + { + logger.LogDebug("Reconnect already in progress, skipping duplicate attempt"); + return; + } + try { + await Task.Delay(InitialRetryDelay, _stoppingToken); CleanupConnection(); await ConnectAndConsumeAsync(_stoppingToken); } + catch (OperationCanceledException) when (_stoppingToken.IsCancellationRequested) + { + // Shutting down — expected + } catch (Exception ex) { logger.LogError(ex, "Failed to reconnect to RabbitMQ after connection loss"); } + finally + { + _reconnectLock.Release(); + } }, _stoppingToken); } @@ -120,7 +144,11 @@ private void DeclareTopology() queue: _options.InboundQueue, durable: true, exclusive: false, - autoDelete: false); + autoDelete: false, + arguments: new System.Collections.Generic.Dictionary + { + { "x-queue-type", "quorum" } + }); foreach (var routingKey in _options.InboundRoutingKeys) { @@ -222,6 +250,12 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e logger.LogInformation("Message {MessageId} saved to inbox for processing", messageId); } + catch (Exception ex) when (IsDuplicateKeyException(ex)) + { + // Another pod inserted the same MessageId between our check and insert (unique index). + // This is expected in multi-pod environments on RabbitMQ redelivery — treat as success. + logger.LogInformation("Message {MessageId} was concurrently inserted by another pod. Treating as idempotent success.", messageId); + } catch (Exception ex) { logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); @@ -263,9 +297,32 @@ private void CleanupConnection() _connection = null; } + /// + /// Detects PostgreSQL unique constraint violation (error code 23505) propagated through EF Core. + /// This occurs when two pods concurrently insert the same MessageId on RabbitMQ redelivery. + /// Uses reflection to avoid a direct Npgsql dependency in the Application layer. + /// + private static bool IsDuplicateKeyException(Exception ex) + { + var current = ex; + while (current != null) + { + // Npgsql.PostgresException has a SqlState property — check by type name to avoid package reference + var type = current.GetType(); + if (type.Name == "PostgresException") + { + var sqlState = type.GetProperty("SqlState")?.GetValue(current) as string; + if (sqlState == "23505") return true; + } + current = current.InnerException; + } + return false; + } + public override void Dispose() { CleanupConnection(); + _reconnectLock.Dispose(); base.Dispose(); GC.SuppressFinalize(this); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs new file mode 100644 index 0000000000..d6004c45a4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxWorker.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Extensions.Options; +using Quartz; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central inbox table for pending GrantsPortal inbound messages and processes them sequentially. +/// All orchestration logic (retry, tenant switching, outbox ack) is handled by . +/// Handlers are resolved as instances with Source == "GrantsPortal". +/// +public class GrantsPortalInboxWorker : InboxWorkerBase +{ + protected override string SourceName => GrantsPortalRabbitMqOptions.SourceName; + + public GrantsPortalInboxWorker( + IServiceProvider serviceProvider, + IOptions options) + : base(serviceProvider) + { + var cronExpression = options.Value.InboxProcessorCron; + + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(GrantsPortalInboxWorker)) + .Build(); + + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(GrantsPortalInboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs deleted file mode 100644 index ef8915cc20..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Unity.GrantManager.GrantsPortal.Configuration; -using Unity.GrantManager.Messaging; -using Volo.Abp.Uow; - -namespace Unity.GrantManager.GrantsPortal; - -/// -/// Periodically deletes processed/failed messages older than the configured retention period. -/// Runs once per hour against the central host database. -/// -public class GrantsPortalMessageCleanupService( - IServiceProvider serviceProvider, - IOptions options, - ILogger logger) : BackgroundService -{ - private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1); - private readonly int _retentionDays = options.Value.MessageRetentionDays; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("Integration message cleanup service starting (retention={RetentionDays} days)", _retentionDays); - - // Wait for the application to fully start - await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - await CleanupOldMessagesAsync(); - } - catch (Exception ex) - { - logger.LogError(ex, "Error during integration message cleanup"); - } - - await Task.Delay(CleanupInterval, stoppingToken); - } - } - - private async Task CleanupOldMessagesAsync() - { - var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); - - using var scope = serviceProvider.CreateScope(); - var inboxRepo = scope.ServiceProvider.GetRequiredService(); - var outboxRepo = scope.ServiceProvider.GetRequiredService(); - var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); - - using var uow = unitOfWorkManager.Begin(requiresNew: true); - var inboxDeleted = await inboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); - var outboxDeleted = await outboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); - await uow.CompleteAsync(); - - var total = inboxDeleted + outboxDeleted; - if (total > 0) - { - logger.LogInformation( - "Cleaned up {Total} messages older than {CutoffDate:yyyy-MM-dd} (inbox={InboxCount}, outbox={OutboxCount})", - total, cutoffDate, inboxDeleted, outboxDeleted); - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs new file mode 100644 index 0000000000..e1cd8a10f9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupWorker.cs @@ -0,0 +1,82 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Periodically deletes processed/failed messages older than the configured retention period. +/// Runs against the central host database. +/// +[DisallowConcurrentExecution] +public class GrantsPortalMessageCleanupWorker : QuartzBackgroundWorkerBase +{ + private readonly IServiceProvider _serviceProvider; + private readonly int _retentionDays; + + public GrantsPortalMessageCleanupWorker( + IServiceProvider serviceProvider, + IOptions options) + { + _serviceProvider = serviceProvider; + _retentionDays = options.Value.MessageRetentionDays; + + var cronExpression = options.Value.MessageCleanupCron; + + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(GrantsPortalMessageCleanupWorker)) + .Build(); + + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(GrantsPortalMessageCleanupWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } + + public override async Task Execute(IJobExecutionContext context) + { + Logger.LogDebug("GrantsPortalMessageCleanupWorker executing (retention={RetentionDays} days)...", _retentionDays); + + try + { + await CleanupOldMessagesAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error during integration message cleanup"); + } + } + + private async Task CleanupOldMessagesAsync() + { + var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); + + using var scope = _serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + var inboxDeleted = await inboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + var outboxDeleted = await outboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + await uow.CompleteAsync(); + + var total = inboxDeleted + outboxDeleted; + if (total > 0) + { + Logger.LogInformation( + "Cleaned up {Total} messages older than {CutoffDate:yyyy-MM-dd} (inbox={InboxCount}, outbox={OutboxCount})", + total, cutoffDate, inboxDeleted, outboxDeleted); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs deleted file mode 100644 index ff5c919faa..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using RabbitMQ.Client; -using Unity.GrantManager.GrantsPortal.Configuration; -using Unity.GrantManager.Messaging; -using Volo.Abp.Uow; - -namespace Unity.GrantManager.GrantsPortal; - -/// -/// Polls the central outbox table for pending acknowledgment messages and publishes them to RabbitMQ. -/// Uses publisher confirms to ensure delivery before marking messages as sent. -/// No tenant context needed — the outbox table is in the host database. -/// -public class GrantsPortalOutboxProcessorService( - IServiceProvider serviceProvider, - IAsyncConnectionFactory connectionFactory, - IOptions options, - ILogger logger) : BackgroundService -{ - private IConnection? _connection; - private IModel? _channel; - - private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(5); - private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); - private const int MaxPublishRetries = 3; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - logger.LogInformation("Grants Portal outbox processor starting..."); - - // Wait for the application to fully start - await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); - - while (!stoppingToken.IsCancellationRequested) - { - try - { - EnsureChannel(); - var processedAny = await PublishPendingAcksAsync(stoppingToken); - var delay = processedAny ? PollingInterval : IdleInterval; - await Task.Delay(delay, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogError(ex, "Error in outbox processor loop. Will retry after delay."); - CleanupChannel(); - await Task.Delay(IdleInterval, stoppingToken); - } - } - - logger.LogInformation("Grants Portal outbox processor stopped."); - } - - private void EnsureChannel() - { - if (_channel is { IsOpen: true }) return; - - CleanupChannel(); - - _connection = connectionFactory.CreateConnection(); - _channel = _connection.CreateModel(); - _channel.ConfirmSelect(); - - logger.LogInformation("Outbox processor RabbitMQ channel established"); - } - - private async Task PublishPendingAcksAsync(CancellationToken cancellationToken) - { - using var scope = serviceProvider.CreateScope(); - var outboxRepo = scope.ServiceProvider.GetRequiredService(); - var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); - - List pendingMessages; - using (var uow = unitOfWorkManager.Begin(requiresNew: true)) - { - pendingMessages = await outboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); - await uow.CompleteAsync(cancellationToken); - } - - if (pendingMessages.Count == 0) return false; - - foreach (var outboxMsg in pendingMessages) - { - if (cancellationToken.IsCancellationRequested) break; - - await PublishSingleAckAsync(outboxMsg, outboxRepo, unitOfWorkManager); - } - - return true; - } - - private async Task PublishSingleAckAsync( - OutboxMessage outboxMsg, - IOutboxMessageRepository outboxRepo, - IUnitOfWorkManager unitOfWorkManager) - { - try - { - using var scope = serviceProvider.CreateScope(); - var publisher = scope.ServiceProvider.GetRequiredService(); - - publisher.Publish( - _channel!, - outboxMsg.OriginalMessageId, - outboxMsg.CorrelationId, - outboxMsg.AckStatus, - outboxMsg.Details); - - // Wait for broker to confirm - if (!_channel!.WaitForConfirms(TimeSpan.FromSeconds(5))) - { - throw new InvalidOperationException("Broker did not confirm ack publish"); - } - - // Mark as sent - using var uow = unitOfWorkManager.Begin(requiresNew: true); - outboxMsg.Status = MessageStatus.Processed; - outboxMsg.PublishedAt = DateTime.UtcNow; - await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); - await uow.CompleteAsync(); - - logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", - outboxMsg.MessageId, outboxMsg.OriginalMessageId); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); - - outboxMsg.RetryCount++; - if (outboxMsg.RetryCount >= MaxPublishRetries) - { - outboxMsg.Status = MessageStatus.Failed; - outboxMsg.Details = $"Failed to publish after {MaxPublishRetries} attempts: {ex.Message}"; - logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", - outboxMsg.MessageId, MaxPublishRetries); - } - - using var uow = unitOfWorkManager.Begin(requiresNew: true); - await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); - await uow.CompleteAsync(); - } - } - - private void CleanupChannel() - { - try - { - _channel?.Close(); - _channel?.Dispose(); - _connection?.Close(); - _connection?.Dispose(); - } - catch (Exception ex) - { - logger.LogDebug(ex, "Error during outbox channel cleanup"); - } - - _channel = null; - _connection = null; - } - - public override void Dispose() - { - CleanupChannel(); - base.Dispose(); - GC.SuppressFinalize(this); - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs new file mode 100644 index 0000000000..a6de10b2be --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxWorker.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Quartz; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central outbox table for pending GrantsPortal acknowledgment messages and publishes them to RabbitMQ. +/// Uses publisher confirms to ensure delivery before the base class marks messages as sent. +/// All orchestration logic (retry, status updates) is handled by . +/// +public class GrantsPortalOutboxWorker : OutboxWorkerBase +{ + private readonly IAsyncConnectionFactory _connectionFactory; + private IConnection? _connection; + private IModel? _channel; + + protected override string SourceName => GrantsPortalRabbitMqOptions.SourceName; + + public GrantsPortalOutboxWorker( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options) + : base(serviceProvider) + { + _connectionFactory = connectionFactory; + + var cronExpression = options.Value.OutboxProcessorCron; + + JobDetail = JobBuilder + .Create() + .WithIdentity(nameof(GrantsPortalOutboxWorker)) + .Build(); + + Trigger = TriggerBuilder + .Create() + .WithIdentity(nameof(GrantsPortalOutboxWorker)) + .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression) + .WithMisfireHandlingInstructionIgnoreMisfires()) + .Build(); + } + + protected override void OnBeforePublishCycle() + { + EnsureChannel(); + } + + protected override void OnPublishCycleError(Exception ex) + { + CleanupChannel(); + } + + protected override async Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg) + { + var publisher = scope.ServiceProvider.GetRequiredService(); + + publisher.Publish( + _channel!, + outboxMsg.OriginalMessageId, + outboxMsg.CorrelationId, + outboxMsg.AckStatus, + outboxMsg.Details); + + // Wait for broker to confirm + if (!_channel!.WaitForConfirms(TimeSpan.FromSeconds(5))) + { + throw new InvalidOperationException("Broker did not confirm ack publish"); + } + + await Task.CompletedTask; + } + + private void EnsureChannel() + { + if (_channel is { IsOpen: true }) return; + + CleanupChannel(); + + _connection = _connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + _channel.ConfirmSelect(); + + Logger.LogInformation("Outbox worker RabbitMQ channel established"); + } + + private void CleanupChannel() + { + try + { + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Error during outbox channel cleanup"); + } + + _channel = null; + _connection = null; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs index 6983cc69ab..202464a00e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -36,7 +36,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) address.Country = innerData.Country; address.AddressType = MapAddressType(innerData.AddressType); - await applicantAddressRepository.UpdateAsync(address, autoSave: true); + await applicantAddressRepository.UpdateAsync(address); logger.LogInformation("Address {AddressId} updated successfully", addressId); return "Address updated successfully"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs index bff7162168..96607436d3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -40,11 +40,11 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var trackedSibling = await applicantAddressRepository.GetAsync(sibling.Id); trackedSibling.SetProperty("isPrimary", false); - await applicantAddressRepository.UpdateAsync(trackedSibling, autoSave: true); + await applicantAddressRepository.UpdateAsync(trackedSibling); } } - await applicantAddressRepository.UpdateAsync(address, autoSave: true); + await applicantAddressRepository.UpdateAsync(address); logger.LogInformation("Address {AddressId} set as primary", addressId); return "Address set as primary"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs index 59309895e6..2a3a886006 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -1,9 +1,13 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; using Unity.GrantManager.Contacts; using Unity.GrantManager.GrantsPortal.Messages; using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities; using Volo.Abp.Uow; @@ -13,6 +17,8 @@ namespace Unity.GrantManager.GrantsPortal.Handlers; public class ContactCreateHandler( IContactRepository contactRepository, IContactLinkRepository contactLinkRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicantAgentRepository applicantAgentRepository, ILogger logger) : IPortalCommandHandler, ITransientDependency { public string DataType => "CONTACT_CREATE_COMMAND"; @@ -47,7 +53,15 @@ public virtual async Task HandleAsync(PluginDataPayload payload) EntityHelper.TrySetId(contact, () => contactId); - await contactRepository.InsertAsync(contact, autoSave: true); + // Lookup applicant agent IDs associated with this subject's submissions + var applicantAgentIds = await GetApplicantAgentIdsAsync(payload.Subject); + if (applicantAgentIds.Count > 0) + { + contact.SetProperty("applicantAgentIds", applicantAgentIds); + logger.LogInformation("Found {Count} applicant agent(s) for subject {Subject}", applicantAgentIds.Count, payload.Subject); + } + + await contactRepository.InsertAsync(contact); // Create a contact link to track the relationship and primary status var contactLink = new ContactLink @@ -60,9 +74,68 @@ public virtual async Task HandleAsync(PluginDataPayload payload) IsActive = true }; - await contactLinkRepository.InsertAsync(contactLink, autoSave: true); + await contactLinkRepository.InsertAsync(contactLink); logger.LogInformation("Contact {ContactId} created successfully", contactId); return "Contact created successfully"; } + + private async Task> GetApplicantAgentIdsAsync(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) + { + return []; + } + + var normalizedSub = NormalizeOidcSub(subject); + if (string.IsNullOrWhiteSpace(normalizedSub)) + { + return []; + } + + // Find submissions matching the normalized OidcSub + var submissions = await applicationFormSubmissionRepository.GetListAsync(s => s.OidcSub == normalizedSub); + if (submissions.Count == 0) + { + logger.LogDebug("No submissions found for subject {Subject} (normalized: {NormalizedSub})", subject, normalizedSub); + return []; + } + + // Get distinct application IDs from the submissions + var applicationIds = submissions + .Select(s => s.ApplicationId) + .Distinct() + .ToList(); + + // Lookup applicant agents linked to those applications + var agents = await applicantAgentRepository + .GetListAsync(a => a.ApplicationId != null && applicationIds.Contains(a.ApplicationId!.Value)); + + return [.. agents + .Select(a => a.Id.ToString()) + .Distinct()]; + } + + /// + /// Normalizes a raw OIDC subject by stripping the IDP suffix (after @) and uppercasing. + /// This matches the format stored in ApplicationFormSubmission.OidcSub. + /// + internal static string? NormalizeOidcSub(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) + { + return null; + } + + var atIndex = subject.IndexOf('@'); + + if (atIndex == 0) + { + return null; + } + + return atIndex > 0 + ? subject[..atIndex].ToUpperInvariant() + : subject.ToUpperInvariant(); + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs index e79c5208d8..9b7029f7bb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs @@ -26,14 +26,14 @@ public virtual async Task HandleAsync(PluginDataPayload payload) var links = await contactLinkRepository.GetListAsync(cl => cl.ContactId == contactId); if (links.Count > 0) { - await contactLinkRepository.DeleteManyAsync(links, autoSave: true); + await contactLinkRepository.DeleteManyAsync(links); } // Delete the contact var contact = await contactRepository.FindAsync(contactId); if (contact != null) { - await contactRepository.DeleteAsync(contact, autoSave: true); + await contactRepository.DeleteAsync(contact); } logger.LogInformation("Contact {ContactId} deleted successfully", contactId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs index 2269bb1255..c640ec3808 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -34,7 +34,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) contact.WorkPhoneNumber = innerData.WorkPhoneNumber; contact.WorkPhoneExtension = innerData.WorkPhoneExtension; - await contactRepository.UpdateAsync(contact, autoSave: true); + await contactRepository.UpdateAsync(contact); logger.LogInformation("Contact {ContactId} updated successfully", contactId); return "Contact updated successfully"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs index 24ff1400de..b02a8c43fa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs @@ -29,7 +29,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) foreach (var link in profileLinks) { link.IsPrimary = link.ContactId == contactId; - await contactLinkRepository.UpdateAsync(link, autoSave: true); + await contactLinkRepository.UpdateAsync(link); } logger.LogInformation("Contact {ContactId} set as primary", contactId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs index 57cadce7fd..78770d0bf5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs @@ -23,8 +23,6 @@ public virtual async Task HandleAsync(PluginDataPayload payload) logger.LogInformation("Editing organization for profile {ProfileId}", payload.ProfileId); - // TODO: Determine the correct lookup strategy for the Applicant entity. - // For now, use organizationId from the payload as a direct Applicant ID. var organizationId = Guid.Parse(payload.OrganizationId ?? throw new ArgumentException("organizationId is required")); var applicant = await applicantRepository.GetAsync(organizationId); @@ -41,7 +39,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) applicant.FiscalDay = fiscalDay; } - await applicantRepository.UpdateAsync(applicant, autoSave: true); + await applicantRepository.UpdateAsync(applicant); logger.LogInformation("Organization {OrganizationId} updated successfully", organizationId); return "Organization updated successfully"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs new file mode 100644 index 0000000000..768888c05f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/PortalCommandHandlerAdapter.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +/// +/// Adapts the portal-specific to the generic +/// interface so existing handlers don't need to change. +/// +/// Each is wrapped in one of these adapters at DI registration time. +/// The adapter handles PluginDataEnvelope → PluginDataPayload deserialization before delegating. +/// +internal class PortalCommandHandlerAdapter(IPortalCommandHandler inner) : IInboxMessageHandler +{ + public string Source => GrantsPortalRabbitMqOptions.SourceName; + public string DataType => inner.DataType; + + public async Task HandleAsync(string rawPayload) + { + var envelope = JsonConvert.DeserializeObject(rawPayload) + ?? throw new JsonException("Failed to deserialize message payload"); + + var payload = envelope.Data?.ToObject() + ?? throw new ArgumentException("Message data payload is missing"); + + return await inner.HandleAsync(payload); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs index f6109989a4..63290ffc4a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs @@ -23,6 +23,9 @@ public class PluginDataPayload [JsonProperty("provider")] public string? Provider { get; set; } + [JsonProperty("subject")] + public string? Subject { get; set; } + [JsonProperty("data")] public JObject? Data { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs similarity index 59% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index 0e7ef3ef38..07091e398d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -1,33 +1,45 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Unity.GrantManager.GrantsPortal.Configuration; -using Unity.GrantManager.GrantsPortal.Handlers; -using Unity.GrantManager.GrantsPortal.Messages; -using Unity.GrantManager.Messaging; +using Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; -namespace Unity.GrantManager.GrantsPortal; +namespace Unity.GrantManager.Messaging; /// -/// Polls the central inbox table for pending inbound messages and processes them sequentially. -/// Switches to the correct tenant context only when executing the handler (domain operations). -/// On completion (success or failure), writes an outbound ack message to the same central table. +/// Base class for inbox processing workers. Provides the full orchestration loop: +/// poll pending → mark processing → dispatch to handler → retry on transient errors → mark complete → write outbox ack. +/// +/// Subclasses only need to provide the source name and configure the Quartz schedule in their constructor. +/// Handlers are resolved from DI as filtered by . /// -public class GrantsPortalInboxProcessorService( - IServiceProvider serviceProvider, - ILogger logger) : BackgroundService +[DisallowConcurrentExecution] +public abstract class InboxWorkerBase : QuartzBackgroundWorkerBase { - private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(5); - private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); - private const int MaxRetryCount = 3; + private readonly IServiceProvider _serviceProvider; + + /// + /// The integration source discriminator (e.g. "GrantsPortal"). + /// Used to filter pending inbox messages and tag outbox acknowledgments. + /// + protected abstract string SourceName { get; } + + /// + /// Maximum number of retry attempts for transient errors before marking as failed. + /// Override to customize per integration. Default is 3. + /// + protected virtual int MaxRetryCount => 3; + + /// + /// Maximum number of pending messages to fetch per polling cycle. + /// Override to customize per integration. Default is 10. + /// + protected virtual int BatchSize => 10; private static readonly Dictionary s_userFriendlyErrors = new(StringComparer.OrdinalIgnoreCase) { @@ -36,58 +48,44 @@ public class GrantsPortalInboxProcessorService( { "AbpDbConcurrencyException", "The record was modified by another process. Please try again." } }; - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + protected InboxWorkerBase(IServiceProvider serviceProvider) { - logger.LogInformation("Grants Portal inbox processor starting..."); + _serviceProvider = serviceProvider; + } - // Wait for the application to fully start - await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + public override async Task Execute(IJobExecutionContext context) + { + Logger.LogDebug("{WorkerName} executing...", GetType().Name); - while (!stoppingToken.IsCancellationRequested) + try { - try - { - var processedAny = await ProcessPendingMessagesAsync(stoppingToken); - var delay = processedAny ? PollingInterval : IdleInterval; - await Task.Delay(delay, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error in inbox processor loop"); - await Task.Delay(IdleInterval, stoppingToken); - } + await ProcessPendingMessagesAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error in {WorkerName} execution", GetType().Name); } - - logger.LogInformation("Grants Portal inbox processor stopped."); } - private async Task ProcessPendingMessagesAsync(CancellationToken cancellationToken) + private async Task ProcessPendingMessagesAsync() { - using var scope = serviceProvider.CreateScope(); + using var scope = _serviceProvider.CreateScope(); var inboxRepo = scope.ServiceProvider.GetRequiredService(); var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); List pendingMessages; using (var uow = unitOfWorkManager.Begin(requiresNew: true)) { - pendingMessages = await inboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); - await uow.CompleteAsync(cancellationToken); + pendingMessages = await inboxRepo.GetPendingAsync(SourceName, BatchSize); + await uow.CompleteAsync(); } - if (pendingMessages.Count == 0) return false; + if (pendingMessages.Count == 0) return; foreach (var inboxMsg in pendingMessages) { - if (cancellationToken.IsCancellationRequested) break; - await ProcessSingleMessageAsync(scope, inboxMsg); } - - return true; } private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage inboxMsg) @@ -96,10 +94,10 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i var outboxRepo = scope.ServiceProvider.GetRequiredService(); var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); var currentTenant = scope.ServiceProvider.GetRequiredService(); - var handlers = scope.ServiceProvider.GetServices(); + var handlers = scope.ServiceProvider.GetServices(); - logger.LogInformation("Processing inbox message {MessageId} (dataType={DataType}, tenantId={TenantId})", - inboxMsg.MessageId, inboxMsg.DataType, inboxMsg.TenantId); + Logger.LogInformation("Processing inbox message {MessageId} (source={Source}, dataType={DataType}, tenantId={TenantId})", + inboxMsg.MessageId, inboxMsg.Source, inboxMsg.DataType, inboxMsg.TenantId); string ackStatus; string details; @@ -115,21 +113,16 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i await uow.CompleteAsync(); } - // Deserialize the payload - var envelope = JsonConvert.DeserializeObject(inboxMsg.Payload) - ?? throw new JsonException("Failed to deserialize message payload"); - - var payload = envelope.Data?.ToObject() - ?? throw new ArgumentException("Message data payload is missing"); - var handler = handlers.FirstOrDefault(h => - string.Equals(h.DataType, inboxMsg.DataType, StringComparison.OrdinalIgnoreCase)); + string.Equals(h.Source, SourceName, StringComparison.OrdinalIgnoreCase) + && string.Equals(h.DataType, inboxMsg.DataType, StringComparison.OrdinalIgnoreCase)); if (handler == null) { ackStatus = "FAILED"; details = $"Unknown command type: {inboxMsg.DataType}"; - logger.LogWarning("No handler registered for dataType {DataType}", inboxMsg.DataType); + Logger.LogWarning("No handler registered for source {Source}, dataType {DataType}", + SourceName, inboxMsg.DataType); } else { @@ -137,7 +130,7 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i using (currentTenant.Change(inboxMsg.TenantId)) { using var uow = unitOfWorkManager.Begin(requiresNew: true); - details = await handler.HandleAsync(payload); + details = await handler.HandleAsync(inboxMsg.Payload); await uow.CompleteAsync(); } ackStatus = "SUCCESS"; @@ -145,7 +138,7 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i } catch (Exception ex) { - logger.LogError(ex, "Error processing inbox message {MessageId}", inboxMsg.MessageId); + Logger.LogError(ex, "Error processing inbox message {MessageId}", inboxMsg.MessageId); ackStatus = "FAILED"; details = ToUserFriendlyMessage(ex); @@ -157,7 +150,7 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i inboxMsg.Details = details; await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); await uow.CompleteAsync(); - logger.LogInformation("Message {MessageId} will be retried (attempt {Attempt}/{MaxRetries})", + Logger.LogInformation("Message {MessageId} will be retried (attempt {Attempt}/{MaxRetries})", inboxMsg.MessageId, inboxMsg.RetryCount, MaxRetryCount); return; } @@ -173,7 +166,7 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i var outboxMsg = new OutboxMessage { - Source = GrantsPortalRabbitMqOptions.SourceName, + Source = SourceName, MessageId = Guid.NewGuid().ToString(), OriginalMessageId = inboxMsg.MessageId, CorrelationId = inboxMsg.CorrelationId, @@ -189,18 +182,20 @@ private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage i await uow.CompleteAsync(); } - logger.LogInformation("Inbox message {MessageId} processed with status {Status}", + Logger.LogInformation("Inbox message {MessageId} processed with status {Status}", inboxMsg.MessageId, ackStatus); } - private static string ToUserFriendlyMessage(Exception ex) + /// + /// Maps exception types to user-friendly messages. Override to add integration-specific mappings. + /// + protected virtual string ToUserFriendlyMessage(Exception ex) { var exType = ex.GetType().Name; if (s_userFriendlyErrors.TryGetValue(exType, out var friendly)) return friendly; - // Check inner exception type if (ex.InnerException != null) { var innerType = ex.InnerException.GetType().Name; @@ -208,11 +203,13 @@ private static string ToUserFriendlyMessage(Exception ex) return innerFriendly; } - // For unrecognized exceptions, return a generic message — never leak stack traces return "An unexpected error occurred while processing your request. Please try again or contact support."; } - private static bool IsTransientError(Exception ex) + /// + /// Determines if an error is transient (eligible for retry). Override to add integration-specific checks. + /// + protected virtual bool IsTransientError(Exception ex) { var typeName = ex.GetType().Name; return typeName.Contains("Timeout", StringComparison.OrdinalIgnoreCase) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs new file mode 100644 index 0000000000..12280fd136 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/OutboxWorkerBase.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Quartz; +using Volo.Abp.BackgroundWorkers.Quartz; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.Messaging; + +/// +/// Base class for outbox processing workers. Provides the full publish loop: +/// poll pending → publish → mark sent/failed. +/// +/// Subclasses provide the source name, Quartz schedule, and the actual publish implementation +/// via . +/// +[DisallowConcurrentExecution] +public abstract class OutboxWorkerBase : QuartzBackgroundWorkerBase +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// The integration source discriminator (e.g. "GrantsPortal"). + /// Used to filter pending outbox messages. + /// + protected abstract string SourceName { get; } + + /// + /// Maximum number of publish retry attempts before marking as failed. + /// Override to customize per integration. Default is 3. + /// + protected virtual int MaxPublishRetries => 3; + + /// + /// Maximum number of pending messages to fetch per polling cycle. + /// Override to customize per integration. Default is 10. + /// + protected virtual int BatchSize => 10; + + protected OutboxWorkerBase(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public override async Task Execute(IJobExecutionContext context) + { + Logger.LogDebug("{WorkerName} executing...", GetType().Name); + + try + { + OnBeforePublishCycle(); + await PublishPendingMessagesAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error in {WorkerName} execution. Resources will be reset on next run.", GetType().Name); + OnPublishCycleError(ex); + } + } + + /// + /// Called before each publish cycle. Use to ensure transport connections/channels are ready. + /// + protected virtual void OnBeforePublishCycle() { } + + /// + /// Called when the publish cycle throws an unhandled exception. Use to clean up transport resources. + /// + protected virtual void OnPublishCycleError(Exception ex) { } + + /// + /// Publishes a single outbox message to the external system. + /// Implementations should throw on failure — the base class handles retry and status updates. + /// + /// The current DI scope for resolving transport-specific services. + /// The outbox message to publish. + protected abstract Task PublishMessageAsync(IServiceScope scope, OutboxMessage outboxMsg); + + private async Task PublishPendingMessagesAsync() + { + using var scope = _serviceProvider.CreateScope(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await outboxRepo.GetPendingAsync(SourceName, BatchSize); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return; + + foreach (var outboxMsg in pendingMessages) + { + await PublishSingleAsync(outboxMsg, scope, outboxRepo, unitOfWorkManager); + } + } + + private async Task PublishSingleAsync( + OutboxMessage outboxMsg, + IServiceScope scope, + IOutboxMessageRepository outboxRepo, + IUnitOfWorkManager unitOfWorkManager) + { + try + { + await PublishMessageAsync(scope, outboxMsg); + + // Mark as sent + using var uow = unitOfWorkManager.Begin(requiresNew: true); + outboxMsg.Status = MessageStatus.Processed; + outboxMsg.PublishedAt = DateTime.UtcNow; + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + + Logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", + outboxMsg.MessageId, outboxMsg.OriginalMessageId); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); + + outboxMsg.RetryCount++; + if (outboxMsg.RetryCount >= MaxPublishRetries) + { + outboxMsg.Status = MessageStatus.Failed; + outboxMsg.Details = $"Failed to publish after {MaxPublishRetries} attempts: {ex.Message}"; + Logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", + outboxMsg.MessageId, MaxPublishRetries); + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs new file mode 100644 index 0000000000..b14408c0fe --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageHandler.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; + +namespace Unity.GrantManager.Messaging; + +/// +/// A source-agnostic handler for inbox messages. +/// Implementations receive the raw JSON payload and are responsible for their own deserialization. +/// Dispatched by based on and . +/// +public interface IInboxMessageHandler +{ + /// + /// The integration source this handler belongs to (e.g. "GrantsPortal"). + /// Must match the value. + /// + string Source { get; } + + /// + /// The command discriminator this handler processes (e.g. "CONTACT_CREATE_COMMAND"). + /// Must match the value. + /// + string DataType { get; } + + /// + /// Processes the raw JSON payload of an inbox message. + /// + /// The full JSON payload string from . + /// A human-readable details string describing the outcome. + Task HandleAsync(string rawPayload); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index b7ad146b28..657adaa3fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -202,7 +202,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .IsRequired() .HasConversion(new EnumToStringConverter()); - b.HasIndex(x => x.MessageId); + b.HasIndex(x => x.MessageId).IsUnique(); b.HasIndex(x => new { x.Source, x.Status }); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json index e41b752c50..bb92c5bf84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.Development.json @@ -24,8 +24,8 @@ "BackgroundJobs": { "IsJobExecutionEnabled": true, "Quartz": { - "UseCluster": false, - "IsAutoRegisterEnabled": false + "UseCluster": true, + "IsAutoRegisterEnabled": true }, "IntakeResync": { "Expression": "0 0 12 1/1 * ? *", diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs index 7b38a48e83..708cd4e30a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs @@ -433,5 +433,60 @@ public async Task GetDataAsync_ShouldNotOverridePrimaryWhenAlreadySet() var primary = dto.Addresses.Single(a => a.IsPrimary); primary.City.ShouldBe("Vancouver"); } + + [Fact] + public async Task GetDataAsync_MultipleApplicantIds_ShouldMakeApplicantPathNotEditable() + { + // Arrange + var request = CreateRequest(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + var applicantId1 = Guid.NewGuid(); + var applicantId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", s => s.ApplicantId = applicantId1), + CreateSubmission(applicationId2, "TESTUSER", s => s.ApplicantId = applicantId2) + ], + [ + CreateAddress(a => { a.ApplicantId = applicantId1; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicantId = applicantId2; a.City = "Vancouver"; }) + ]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert — multiple distinct ApplicantIds means applicant-path addresses are NOT editable + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + dto.Addresses.ShouldAllBe(a => !a.IsEditable); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + // Arrange + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.AddressInfo + }; + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs index 7d7c20fc77..4aecdf0599 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/ApplicantProfileDataProviderTests.cs @@ -52,6 +52,17 @@ private static AddressInfoDataProvider CreateAddressInfoDataProvider() return new AddressInfoDataProvider(currentTenant, submissionRepo, addressRepo, applicationRepo); } + private static OrgInfoDataProvider CreateOrgInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var applicantRepo = Substitute.For>(); + applicantRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + return new OrgInfoDataProvider(currentTenant, submissionRepo, applicantRepo); + } + private static SubmissionInfoDataProvider CreateSubmissionInfoDataProvider() { var currentTenant = Substitute.For(); @@ -87,14 +98,14 @@ public async Task ContactInfoDataProvider_GetDataAsync_ShouldReturnContactInfoDt [Fact] public void OrgInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new OrgInfoDataProvider(); + var provider = CreateOrgInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.OrgInfo); } [Fact] public async Task OrgInfoDataProvider_GetDataAsync_ShouldReturnOrgInfoDto() { - var provider = new OrgInfoDataProvider(); + var provider = CreateOrgInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.OrgInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -154,7 +165,7 @@ public void AllProviders_ShouldHaveUniqueKeys() IApplicantProfileDataProvider[] providers = [ CreateContactInfoDataProvider(), - new OrgInfoDataProvider(), + CreateOrgInfoDataProvider(), CreateAddressInfoDataProvider(), CreateSubmissionInfoDataProvider(), new PaymentInfoDataProvider() diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs new file mode 100644 index 0000000000..fe252f011b --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/OrgInfoDataProviderTests.cs @@ -0,0 +1,323 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class OrgInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _applicantRepo; + private readonly OrgInfoDataProvider _provider; + + public OrgInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _applicantRepo = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new OrgInfoDataProvider(_currentTenant, _submissionRepo, _applicantRepo); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicantRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable applicants) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _applicantRepo.GetQueryableAsync() + .Returns(Task.FromResult(applicants.AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + private static ApplicationFormSubmission CreateSubmission( + Guid applicationId, string oidcSub, Guid applicantId) + { + var entity = new ApplicationFormSubmission + { + ApplicationId = applicationId, + OidcSub = oidcSub, + ApplicantId = applicantId + }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + return entity; + } + + private static Applicant CreateApplicant(Guid id, Action? configure = null) + { + var entity = new Applicant(); + EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); + return entity; + } + + [Fact] + public void Key_ShouldMatchExpected() + { + _provider.Key.ShouldBe(ApplicantProfileKeys.OrgInfo); + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + var request = CreateRequest(); + + await _provider.GetDataAsync(request); + + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + var request = CreateRequest(); + + var result = await _provider.GetDataAsync(request); + + result.DataType.ShouldBe("ORGINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoSubmissions_ShouldReturnEmptyList() + { + var request = CreateRequest(); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapAllApplicantFields() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId, a => + { + a.OrgName = "Acme Corp"; + a.OrganizationType = "Non-Profit"; + a.OrgNumber = "BC1234567"; + a.OrgStatus = "Active"; + a.NonRegOrgName = "Acme Trading"; + a.FiscalMonth = "April"; + a.FiscalDay = 1; + a.OrganizationSize = "51-100"; + a.Sector = "Technology"; + a.SubSector = "Software"; + })]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + + var org = dto.Organizations[0]; + org.Id.ShouldBe(applicantId); + org.OrgName.ShouldBe("Acme Corp"); + org.OrganizationType.ShouldBe("Non-Profit"); + org.OrgNumber.ShouldBe("BC1234567"); + org.OrgStatus.ShouldBe("Active"); + org.NonRegOrgName.ShouldBe("Acme Trading"); + org.FiscalMonth.ShouldBe("April"); + org.FiscalDay.ShouldBe(1); + org.OrganizationSize.ShouldBe("51-100"); + org.Sector.ShouldBe("Technology"); + org.SubSector.ShouldBe("Software"); + } + + [Fact] + public async Task GetDataAsync_ShouldHandleNullApplicantFields() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId)]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + + var org = dto.Organizations[0]; + org.OrgName.ShouldBeNull(); + org.OrganizationType.ShouldBeNull(); + org.OrgNumber.ShouldBeNull(); + org.OrgStatus.ShouldBeNull(); + org.NonRegOrgName.ShouldBeNull(); + org.FiscalMonth.ShouldBeNull(); + org.FiscalDay.ShouldBeNull(); + org.OrganizationSize.ShouldBeNull(); + org.Sector.ShouldBeNull(); + org.SubSector.ShouldBeNull(); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnApplicantsForOtherSubjects() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER", applicantId)], + [CreateApplicant(applicantId, a => a.OrgName = "Other Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultipleApplicants() + { + var request = CreateRequest(); + var applicantId1 = Guid.NewGuid(); + var applicantId2 = Guid.NewGuid(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", applicantId1), + CreateSubmission(applicationId2, "TESTUSER", applicantId2) + ], + [ + CreateApplicant(applicantId1, a => a.OrgName = "Org One"), + CreateApplicant(applicantId2, a => a.OrgName = "Org Two") + ]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(2); + dto.Organizations.ShouldContain(o => o.OrgName == "Org One"); + dto.Organizations.ShouldContain(o => o.OrgName == "Org Two"); + } + + [Fact] + public async Task GetDataAsync_MultipleSubmissionsSameApplicant_ShouldReturnDuplicates() + { + var request = CreateRequest(); + var applicantId = Guid.NewGuid(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", applicantId), + CreateSubmission(applicationId2, "TESTUSER", applicantId) + ], + [CreateApplicant(applicantId, a => a.OrgName = "Same Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(2); + dto.Organizations.ShouldAllBe(o => o.OrgName == "Same Org"); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithAtSign() + { + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId, a => a.OrgName = "Test Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + var applicantId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", applicantId)], + [CreateApplicant(applicantId, a => a.OrgName = "Test Org")]); + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.Count.ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_WithNullSubject_ShouldReturnEmptyList() + { + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = null!, + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.OrgInfo + }; + + var result = await _provider.GetDataAsync(request); + + var dto = result.ShouldBeOfType(); + dto.Organizations.ShouldBeEmpty(); + } + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs index ecae9fbfcb..501a684ee4 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs @@ -315,5 +315,104 @@ public async Task GetDataAsync_ShouldNotReturnSubmissionsForOtherSubjects() var dto = result.ShouldBeOfType(); dto.Submissions.ShouldBeEmpty(); } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultipleSubmissions() + { + // Arrange + var request = CreateRequest(); + var applicationId1 = Guid.NewGuid(); + var applicationId2 = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [ + CreateSubmission(applicationId1, "TESTUSER", s => s.ChefsSubmissionGuid = "sub-1"), + CreateSubmission(applicationId2, "TESTUSER", s => s.ChefsSubmissionGuid = "sub-2") + ], + [ + CreateApplication(applicationId1, statusId, a => a.ReferenceNo = "REF-001"), + CreateApplication(applicationId2, statusId, a => a.ReferenceNo = "REF-002") + ], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.Count.ShouldBe(2); + dto.Submissions.ShouldContain(s => s.ReferenceNo == "REF-001"); + dto.Submissions.ShouldContain(s => s.ReferenceNo == "REF-002"); + } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + // Arrange + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.SubmissionInfo + }; + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.Count.ShouldBe(1); + } + + [Fact] + public async Task GetDataAsync_ShouldResolveLinkSourceWithTrailingSlash() + { + // Arrange + var request = CreateRequest(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(Task.FromResult("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1/")); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.LinkSource.ShouldBe("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/user/view?s="); + } + + [Fact] + public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenSubmissionIsNullOrEmpty() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = null!; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs index dde051f27b..40bce57b1a 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs @@ -192,5 +192,25 @@ public async Task GetDataAsync_ShouldReturnCorrectDataType() // Assert result.DataType.ShouldBe("CONTACTINFO"); } + + [Fact] + public async Task GetDataAsync_ShouldNormalizeSubjectWithoutAtSign() + { + // Arrange + var request = new ApplicantProfileInfoRequest + { + ProfileId = Guid.NewGuid(), + Subject = "testuser", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.ContactInfo + }; + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync("TESTUSER"); + await _applicantProfileContactService.Received(1).GetApplicantAgentContactsBySubjectAsync("TESTUSER"); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs new file mode 100644 index 0000000000..dd6ea9ae46 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressEditHandlerTests.cs @@ -0,0 +1,205 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class AddressEditHandlerTests +{ + private readonly IApplicantAddressRepository _addressRepository; + private readonly AddressEditHandler _handler; + + public AddressEditHandlerTests() + { + _addressRepository = Substitute.For(); + + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new AddressEditHandler( + _addressRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? addressId = null, + JObject? data = null) + { + addressId ??= Guid.NewGuid(); + + data ??= JObject.FromObject(new + { + street = "123 Main St", + street2 = "Suite 100", + unit = "4A", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1", + country = "Canada", + addressType = "MAILING", + isPrimary = true + }); + + return new PluginDataPayload + { + Action = "ADDRESS_EDIT_COMMAND", + AddressId = addressId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldUpdateAddressFields() + { + // Arrange + var addressId = Guid.NewGuid(); + var existingAddress = WithId(new ApplicantAddress + { + Street = "Old Street", + City = "Old City" + }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(existingAddress); + + ApplicantAddress? updatedAddress = null; + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedAddress = ci.ArgAt(0); + return updatedAddress; + }); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address updated successfully"); + updatedAddress.ShouldNotBeNull(); + updatedAddress.Street.ShouldBe("123 Main St"); + updatedAddress.Street2.ShouldBe("Suite 100"); + updatedAddress.Unit.ShouldBe("4A"); + updatedAddress.City.ShouldBe("Victoria"); + updatedAddress.Province.ShouldBe("BC"); + updatedAddress.Postal.ShouldBe("V8W 1A1"); + updatedAddress.Country.ShouldBe("Canada"); + updatedAddress.AddressType.ShouldBe(AddressType.MailingAddress); + } + + [Fact] + public async Task HandleAsync_ShouldCallUpdateOnRepository() + { + // Arrange + var addressId = Guid.NewGuid(); + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(WithId(new ApplicantAddress(), addressId)); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _addressRepository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Address type mapping + + [Theory] + [InlineData("MAILING", AddressType.MailingAddress)] + [InlineData("mailing", AddressType.MailingAddress)] + [InlineData("PHYSICAL", AddressType.PhysicalAddress)] + [InlineData("physical", AddressType.PhysicalAddress)] + [InlineData("BUSINESS", AddressType.BusinessAddress)] + [InlineData("business", AddressType.BusinessAddress)] + [InlineData("UNKNOWN", AddressType.PhysicalAddress)] + [InlineData(null, AddressType.PhysicalAddress)] + public async Task HandleAsync_ShouldMapAddressTypeCorrectly(string? addressType, AddressType expected) + { + // Arrange + var addressId = Guid.NewGuid(); + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(WithId(new ApplicantAddress(), addressId)); + + ApplicantAddress? updatedAddress = null; + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedAddress = ci.ArgAt(0); + return updatedAddress; + }); + + var data = JObject.FromObject(new + { + street = "123 Main St", + city = "Victoria", + province = "BC", + postalCode = "V8W 1A1" + }); + if (addressType != null) + { + data["addressType"] = addressType; + } + + var payload = CreatePayload(addressId: addressId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert + updatedAddress.ShouldNotBeNull(); + updatedAddress.AddressType.ShouldBe(expected); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenAddressIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.AddressId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs new file mode 100644 index 0000000000..224f396b89 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/AddressSetPrimaryHandlerTests.cs @@ -0,0 +1,204 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class AddressSetPrimaryHandlerTests +{ + private readonly IApplicantAddressRepository _addressRepository; + private readonly AddressSetPrimaryHandler _handler; + + public AddressSetPrimaryHandlerTests() + { + _addressRepository = Substitute.For(); + + _addressRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new AddressSetPrimaryHandler( + _addressRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? addressId = null, + Guid? profileId = null) + { + addressId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "ADDRESS_SET_PRIMARY_COMMAND", + AddressId = addressId.Value.ToString(), + ProfileId = profileId.Value.ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldSetPrimaryOnTargetAddress() + { + // Arrange + var addressId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List()); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address set as primary"); + address.GetProperty("isPrimary").ShouldBeTrue(); + await _addressRepository.Received(1).UpdateAsync(address, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldClearPrimaryOnSiblingAddresses() + { + // Arrange + var addressId = Guid.NewGuid(); + var siblingId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); + + var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingId); + sibling.SetProperty("isPrimary", true); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.GetAsync(siblingId, Arg.Any(), Arg.Any()) + .Returns(sibling); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List { address, sibling }); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert — sibling should have isPrimary cleared + sibling.GetProperty("isPrimary").ShouldBeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenNoApplicantId_ShouldNotLookupSiblings() + { + // Arrange + var addressId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = null }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var payload = CreatePayload(addressId: addressId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Address set as primary"); + await _addressRepository.DidNotReceive().FindByApplicantIdAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSetProfileIdProperty() + { + // Arrange + var addressId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + var address = WithId(new ApplicantAddress { ApplicantId = null }, addressId); + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + + var payload = CreatePayload(addressId: addressId, profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + address.GetProperty("profileId").ShouldBe(profileId.ToString()); + } + + [Fact] + public async Task HandleAsync_ShouldSkipSiblingsWithoutIsPrimaryProperty() + { + // Arrange + var addressId = Guid.NewGuid(); + var siblingWithoutProp = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + var address = WithId(new ApplicantAddress { ApplicantId = applicantId }, addressId); + var sibling = WithId(new ApplicantAddress { ApplicantId = applicantId }, siblingWithoutProp); + // sibling does NOT have isPrimary property + + _addressRepository.GetAsync(addressId, Arg.Any(), Arg.Any()) + .Returns(address); + _addressRepository.FindByApplicantIdAsync(applicantId) + .Returns(new List { address, sibling }); + + var payload = CreatePayload(addressId: addressId); + + // Act + await _handler.HandleAsync(payload); + + // Assert — sibling should not have been fetched for update + await _addressRepository.DidNotReceive().GetAsync(siblingWithoutProp, Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenAddressIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.AddressId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenProfileIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ProfileId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs new file mode 100644 index 0000000000..47206b4812 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactCreateHandlerTests.cs @@ -0,0 +1,439 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactCreateHandlerTests +{ + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly IApplicationFormSubmissionRepository _submissionRepository; + private readonly IApplicantAgentRepository _agentRepository; + private readonly ContactCreateHandler _handler; + + public ContactCreateHandlerTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + _submissionRepository = Substitute.For(); + _agentRepository = Substitute.For(); + + // Default: no existing contact, no submissions, no agents + _contactRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Contact?)null); + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + _contactLinkRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + _agentRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + + _handler = new ContactCreateHandler( + _contactRepository, + _contactLinkRepository, + _submissionRepository, + _agentRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? contactId = null, + string? profileId = null, + string? subject = null, + JObject? data = null) + { + contactId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid().ToString(); + + data ??= JObject.FromObject(new + { + name = "Jane Doe", + email = "jane@example.com", + title = "Director", + contactType = "ApplicantProfile", + homePhoneNumber = "111-1111", + mobilePhoneNumber = "222-2222", + workPhoneNumber = "333-3333", + workPhoneExtension = "101", + role = "Primary Contact", + isPrimary = true + }); + + return new PluginDataPayload + { + Action = "CONTACT_CREATE_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = profileId, + Subject = subject, + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldCreateContactAndLink() + { + // Arrange + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact created successfully"); + await _contactRepository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _contactLinkRepository.Received(1).InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldSetContactFields() + { + // Arrange + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.Name.ShouldBe("Jane Doe"); + savedContact.Email.ShouldBe("jane@example.com"); + savedContact.Title.ShouldBe("Director"); + savedContact.HomePhoneNumber.ShouldBe("111-1111"); + savedContact.MobilePhoneNumber.ShouldBe("222-2222"); + savedContact.WorkPhoneNumber.ShouldBe("333-3333"); + savedContact.WorkPhoneExtension.ShouldBe("101"); + } + + [Fact] + public async Task HandleAsync_ShouldSetContactLinkFields() + { + // Arrange + var profileId = Guid.NewGuid().ToString(); + var contactId = Guid.NewGuid(); + ContactLink? savedLink = null; + + _contactLinkRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedLink = ci.ArgAt(0); + return savedLink; + }); + + var payload = CreatePayload(contactId: contactId, profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedLink.ShouldNotBeNull(); + savedLink.ContactId.ShouldBe(contactId); + savedLink.RelatedEntityType.ShouldBe("ApplicantProfile"); + savedLink.RelatedEntityId.ShouldBe(Guid.Parse(profileId)); + savedLink.Role.ShouldBe("Primary Contact"); + savedLink.IsPrimary.ShouldBeTrue(); + savedLink.IsActive.ShouldBeTrue(); + } + + #endregion + + #region Idempotency + + [Fact] + public async Task HandleAsync_WhenContactAlreadyExists_ShouldReturnIdempotentSuccess() + { + // Arrange + var contactId = Guid.NewGuid(); + _contactRepository.FindAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Contact { Name = "Existing" }, contactId)); + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact already exists"); + await _contactRepository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _contactLinkRepository.DidNotReceive().InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion + + #region Applicant agent ID lookup + + [Fact] + public async Task HandleAsync_WhenSubmissionsExistWithAgents_ShouldSetApplicantAgentIds() + { + // Arrange — subject arrives as raw IDP value; OidcSub is stored normalized + var rawSubject = "testuser@idir"; + var normalizedSub = "TESTUSER"; + var applicationId = Guid.NewGuid(); + var agentId = Guid.NewGuid(); + + var submission = new ApplicationFormSubmission + { + OidcSub = normalizedSub, + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + }; + + var agent = WithId(new ApplicantAgent { ApplicationId = applicationId }, agentId); + + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns([submission]); + _agentRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns([agent]); + + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(subject: rawSubject); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldContainKey("applicantAgentIds"); + var agentIds = (List)savedContact.ExtraProperties["applicantAgentIds"]!; + agentIds.ShouldContain(agentId.ToString()); + } + + [Fact] + public async Task HandleAsync_WhenMultipleSubmissionsAndAgents_ShouldSetDistinctAgentIds() + { + // Arrange + var rawSubject = "multiuser@idir"; + var normalizedSub = "MULTIUSER"; + var appId1 = Guid.NewGuid(); + var appId2 = Guid.NewGuid(); + var agentId1 = Guid.NewGuid(); + var agentId2 = Guid.NewGuid(); + + var submissions = new List + { + new() + { + OidcSub = normalizedSub, + ApplicationId = appId1, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + }, + new() + { + OidcSub = normalizedSub, + ApplicationId = appId2, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + } + }; + + var agents = new List + { + WithId(new ApplicantAgent { ApplicationId = appId1 }, agentId1), + WithId(new ApplicantAgent { ApplicationId = appId2 }, agentId2) + }; + + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(submissions); + _agentRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(agents); + + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(subject: rawSubject); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldContainKey("applicantAgentIds"); + var agentIds = (List)savedContact.ExtraProperties["applicantAgentIds"]!; + agentIds.Count.ShouldBe(2); + agentIds.ShouldContain(agentId1.ToString()); + agentIds.ShouldContain(agentId2.ToString()); + } + + [Fact] + public async Task HandleAsync_WhenNoSubmissions_ShouldNotSetApplicantAgentIds() + { + // Arrange — default mock returns empty submissions + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldNotContainKey("applicantAgentIds"); + } + + [Fact] + public async Task HandleAsync_WhenSubmissionsExistButNoAgents_ShouldNotSetApplicantAgentIds() + { + // Arrange + var submission = new ApplicationFormSubmission + { + OidcSub = "SOMEUSER", + ApplicationId = Guid.NewGuid(), + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid(), + ChefsSubmissionGuid = Guid.NewGuid().ToString() + }; + + _submissionRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List { submission }); + // agents remain empty (default mock) + + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(subject: "someuser@idir"); + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldNotContainKey("applicantAgentIds"); + } + + [Fact] + public async Task HandleAsync_WhenSubjectIsNull_ShouldNotSetApplicantAgentIds() + { + // Arrange — subject not provided + Contact? savedContact = null; + _contactRepository.InsertAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + savedContact = ci.ArgAt(0); + return savedContact; + }); + + var payload = CreatePayload(); + payload.Subject = null; + + // Act + await _handler.HandleAsync(payload); + + // Assert + savedContact.ShouldNotBeNull(); + savedContact.ExtraProperties.ShouldNotContainKey("applicantAgentIds"); + } + + #endregion + + #region NormalizeOidcSub + + [Theory] + [InlineData("testuser@idir", "TESTUSER")] + [InlineData("abc@bceidbusiness", "ABC")] + [InlineData("ALREADY", "ALREADY")] + [InlineData("mixedCase", "MIXEDCASE")] + [InlineData("user@", "USER")] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("@idir", null)] + public void NormalizeOidcSub_ShouldStripIdpSuffixAndUppercase(string? input, string? expected) + { + ContactCreateHandler.NormalizeOidcSub(input).ShouldBe(expected); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs new file mode 100644 index 0000000000..9c20cfe9e2 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactDeleteHandlerTests.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactDeleteHandlerTests +{ + private readonly IContactRepository _contactRepository; + private readonly IContactLinkRepository _contactLinkRepository; + private readonly ContactDeleteHandler _handler; + + public ContactDeleteHandlerTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + + // Defaults: no contact, no links + _contactRepository.FindAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Contact?)null); + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + + _handler = new ContactDeleteHandler( + _contactRepository, + _contactLinkRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload(Guid? contactId = null) + { + contactId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "CONTACT_DELETE_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldDeleteContactAndLinks() + { + // Arrange + var contactId = Guid.NewGuid(); + var contact = WithId(new Contact { Name = "To Delete" }, contactId); + var links = new List + { + WithId(new ContactLink { ContactId = contactId, RelatedEntityType = "Profile" }, Guid.NewGuid()), + WithId(new ContactLink { ContactId = contactId, RelatedEntityType = "Profile" }, Guid.NewGuid()) + }; + + _contactRepository.FindAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(contact); + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(links); + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact deleted successfully"); + await _contactLinkRepository.Received(1).DeleteManyAsync(links, Arg.Any(), Arg.Any()); + await _contactRepository.Received(1).DeleteAsync(contact, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenNoLinksExist_ShouldOnlyDeleteContact() + { + // Arrange + var contactId = Guid.NewGuid(); + var contact = WithId(new Contact { Name = "No Links" }, contactId); + + _contactRepository.FindAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(contact); + // links default to empty list + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact deleted successfully"); + await _contactLinkRepository.DidNotReceive().DeleteManyAsync(Arg.Any>(), Arg.Any(), Arg.Any()); + await _contactRepository.Received(1).DeleteAsync(contact, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenContactDoesNotExist_ShouldNotThrow() + { + // Arrange — contact not found (default mock returns null) + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert — should still return success (idempotent delete) + result.ShouldBe("Contact deleted successfully"); + await _contactRepository.DidNotReceive().DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs new file mode 100644 index 0000000000..cf9ddc0e73 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactEditHandlerTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactEditHandlerTests +{ + private readonly IContactRepository _contactRepository; + private readonly ContactEditHandler _handler; + + public ContactEditHandlerTests() + { + _contactRepository = Substitute.For(); + + _contactRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new ContactEditHandler( + _contactRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? contactId = null, + JObject? data = null) + { + contactId ??= Guid.NewGuid(); + + data ??= JObject.FromObject(new + { + name = "Updated Name", + email = "updated@example.com", + title = "Manager", + homePhoneNumber = "444-4444", + mobilePhoneNumber = "555-5555", + workPhoneNumber = "666-6666", + workPhoneExtension = "202" + }); + + return new PluginDataPayload + { + Action = "CONTACT_EDIT_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldUpdateContactFields() + { + // Arrange + var contactId = Guid.NewGuid(); + var existingContact = WithId(new Contact + { + Name = "Old Name", + Email = "old@example.com" + }, contactId); + + _contactRepository.GetAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(existingContact); + + Contact? updatedContact = null; + _contactRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedContact = ci.ArgAt(0); + return updatedContact; + }); + + var payload = CreatePayload(contactId: contactId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact updated successfully"); + updatedContact.ShouldNotBeNull(); + updatedContact.Name.ShouldBe("Updated Name"); + updatedContact.Email.ShouldBe("updated@example.com"); + updatedContact.Title.ShouldBe("Manager"); + updatedContact.HomePhoneNumber.ShouldBe("444-4444"); + updatedContact.MobilePhoneNumber.ShouldBe("555-5555"); + updatedContact.WorkPhoneNumber.ShouldBe("666-6666"); + updatedContact.WorkPhoneExtension.ShouldBe("202"); + } + + [Fact] + public async Task HandleAsync_ShouldCallUpdateOnRepository() + { + // Arrange + var contactId = Guid.NewGuid(); + _contactRepository.GetAsync(contactId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Contact { Name = "Old" }, contactId)); + + var payload = CreatePayload(contactId: contactId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _contactRepository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs new file mode 100644 index 0000000000..799dabb18e --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/ContactSetPrimaryHandlerTests.cs @@ -0,0 +1,172 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class ContactSetPrimaryHandlerTests +{ + private readonly IContactLinkRepository _contactLinkRepository; + private readonly ContactSetPrimaryHandler _handler; + + public ContactSetPrimaryHandlerTests() + { + _contactLinkRepository = Substitute.For(); + + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List()); + + _contactLinkRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new ContactSetPrimaryHandler( + _contactLinkRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? contactId = null, + Guid? profileId = null) + { + contactId ??= Guid.NewGuid(); + profileId ??= Guid.NewGuid(); + + return new PluginDataPayload + { + Action = "CONTACT_SET_PRIMARY_COMMAND", + ContactId = contactId.Value.ToString(), + ProfileId = profileId.Value.ToString(), + Provider = Guid.NewGuid().ToString() + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldSetMatchingContactAsPrimary() + { + // Arrange + var contactId = Guid.NewGuid(); + var otherContactId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + + var targetLink = WithId(new ContactLink + { + ContactId = contactId, + RelatedEntityId = profileId, + IsPrimary = false, + IsActive = true + }, Guid.NewGuid()); + + var otherLink = WithId(new ContactLink + { + ContactId = otherContactId, + RelatedEntityId = profileId, + IsPrimary = true, + IsActive = true + }, Guid.NewGuid()); + + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(new List { targetLink, otherLink }); + + var payload = CreatePayload(contactId: contactId, profileId: profileId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact set as primary"); + targetLink.IsPrimary.ShouldBeTrue(); + otherLink.IsPrimary.ShouldBeFalse(); + await _contactLinkRepository.Received(2).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenNoLinksExist_ShouldReturnSuccess() + { + // Arrange — default mock returns empty list + var payload = CreatePayload(); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Contact set as primary"); + await _contactLinkRepository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ShouldOnlySetTargetAsPrimary() + { + // Arrange — three links, only the target should be primary + var contactId = Guid.NewGuid(); + var profileId = Guid.NewGuid(); + + var links = new List + { + WithId(new ContactLink { ContactId = contactId, RelatedEntityId = profileId, IsPrimary = false, IsActive = true }, Guid.NewGuid()), + WithId(new ContactLink { ContactId = Guid.NewGuid(), RelatedEntityId = profileId, IsPrimary = true, IsActive = true }, Guid.NewGuid()), + WithId(new ContactLink { ContactId = Guid.NewGuid(), RelatedEntityId = profileId, IsPrimary = true, IsActive = true }, Guid.NewGuid()) + }; + + _contactLinkRepository + .GetListAsync(Arg.Any>>(), Arg.Any(), Arg.Any()) + .Returns(links); + + var payload = CreatePayload(contactId: contactId, profileId: profileId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + links.Single(l => l.ContactId == contactId).IsPrimary.ShouldBeTrue(); + links.Where(l => l.ContactId != contactId).ShouldAllBe(l => !l.IsPrimary); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenContactIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ContactId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenProfileIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.ProfileId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs new file mode 100644 index 0000000000..f30098b8c4 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/GrantsPortal/OrganizationEditHandlerTests.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using Shouldly; +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.Domain.Entities; +using Xunit; + +namespace Unity.GrantManager.GrantsPortal; + +public class OrganizationEditHandlerTests +{ + private readonly IApplicantRepository _applicantRepository; + private readonly OrganizationEditHandler _handler; + + public OrganizationEditHandlerTests() + { + _applicantRepository = Substitute.For(); + + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => ci.ArgAt(0)); + + _handler = new OrganizationEditHandler( + _applicantRepository, + NullLogger.Instance); + } + + private static T WithId(T entity, Guid id) where T : Entity + { + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + private static PluginDataPayload CreatePayload( + Guid? organizationId = null, + JObject? data = null) + { + organizationId ??= Guid.NewGuid(); + + data ??= JObject.FromObject(new + { + name = "Updated Org", + organizationType = "Non-Profit", + organizationNumber = "ORG-12345", + status = "Active", + nonRegOrgName = "Friendly Name", + fiscalMonth = "April", + fiscalDay = "15", + organizationSize = "Medium" + }); + + return new PluginDataPayload + { + Action = "ORGANIZATION_EDIT_COMMAND", + OrganizationId = organizationId.Value.ToString(), + ProfileId = Guid.NewGuid().ToString(), + Provider = Guid.NewGuid().ToString(), + Data = data + }; + } + + #region Happy path + + [Fact] + public async Task HandleAsync_ShouldUpdateAllApplicantFields() + { + // Arrange + var orgId = Guid.NewGuid(); + var existingApplicant = WithId(new Applicant + { + OrgName = "Old Org", + OrganizationType = "For-Profit" + }, orgId); + + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(existingApplicant); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var payload = CreatePayload(organizationId: orgId); + + // Act + var result = await _handler.HandleAsync(payload); + + // Assert + result.ShouldBe("Organization updated successfully"); + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.OrgName.ShouldBe("Updated Org"); + updatedApplicant.OrganizationType.ShouldBe("Non-Profit"); + updatedApplicant.OrgNumber.ShouldBe("ORG-12345"); + updatedApplicant.OrgStatus.ShouldBe("Active"); + updatedApplicant.NonRegOrgName.ShouldBe("Friendly Name"); + updatedApplicant.FiscalMonth.ShouldBe("April"); + updatedApplicant.FiscalDay.ShouldBe(15); + updatedApplicant.OrganizationSize.ShouldBe("Medium"); + } + + [Fact] + public async Task HandleAsync_ShouldCallUpdateOnRepository() + { + // Arrange + var orgId = Guid.NewGuid(); + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Applicant(), orgId)); + + var payload = CreatePayload(organizationId: orgId); + + // Act + await _handler.HandleAsync(payload); + + // Assert + await _applicantRepository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + #endregion + + #region Fiscal day parsing + + [Fact] + public async Task HandleAsync_WhenFiscalDayIsValidInt_ShouldParseFiscalDay() + { + // Arrange + var orgId = Guid.NewGuid(); + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(WithId(new Applicant(), orgId)); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var data = JObject.FromObject(new { name = "Org", fiscalDay = "28" }); + var payload = CreatePayload(organizationId: orgId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.FiscalDay.ShouldBe(28); + } + + [Fact] + public async Task HandleAsync_WhenFiscalDayIsNotNumeric_ShouldNotSetFiscalDay() + { + // Arrange + var orgId = Guid.NewGuid(); + var existingApplicant = WithId(new Applicant { FiscalDay = 10 }, orgId); + + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(existingApplicant); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var data = JObject.FromObject(new { name = "Org", fiscalDay = "not-a-number" }); + var payload = CreatePayload(organizationId: orgId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert — FiscalDay should remain unchanged (still 10 from initial) + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.FiscalDay.ShouldBe(10); + } + + [Fact] + public async Task HandleAsync_WhenFiscalDayIsNull_ShouldNotSetFiscalDay() + { + // Arrange + var orgId = Guid.NewGuid(); + var existingApplicant = WithId(new Applicant { FiscalDay = 5 }, orgId); + + _applicantRepository.GetAsync(orgId, Arg.Any(), Arg.Any()) + .Returns(existingApplicant); + + Applicant? updatedApplicant = null; + _applicantRepository.UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => + { + updatedApplicant = ci.ArgAt(0); + return updatedApplicant; + }); + + var data = JObject.FromObject(new { name = "Org" }); + var payload = CreatePayload(organizationId: orgId, data: data); + + // Act + await _handler.HandleAsync(payload); + + // Assert — FiscalDay remains unchanged + updatedApplicant.ShouldNotBeNull(); + updatedApplicant.FiscalDay.ShouldBe(5); + } + + #endregion + + #region Validation + + [Fact] + public async Task HandleAsync_WhenOrganizationIdMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.OrganizationId = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + [Fact] + public async Task HandleAsync_WhenDataMissing_ShouldThrow() + { + // Arrange + var payload = CreatePayload(); + payload.Data = null; + + // Act & Assert + await Should.ThrowAsync(() => _handler.HandleAsync(payload)); + } + + #endregion +} From 6ccd73f646d301df8adbf45eaffef8991d011d23 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 12:03:05 -0700 Subject: [PATCH 06/32] AB#32297 add dev prompt version selection and prompt I/O capture --- .../AI/Requests/ApplicationAnalysisRequest.cs | 9 +++ .../AI/Requests/AttachmentSummaryRequest.cs | 9 +++ .../AI/Requests/ScoresheetSectionRequest.cs | 9 +++ .../AI/Responses/AIPromptIoCaptureResponse.cs | 34 +++++++++ .../Attachments/IAttachmentAppService.cs | 4 +- .../IApplicationAIAnalysisAppService.cs | 2 +- .../IApplicationAIPromptCaptureAppService.cs | 13 ++++ .../IApplicationAIScoringAppService.cs | 2 +- .../AI/AIPromptIoCaptureStore.cs | 49 ++++++++++++ .../AI/ApplicationAnalysisService.cs | 7 +- .../ApplicationScoresheetAnalysisService.cs | 7 +- .../AI/IAIPromptIoCaptureStore.cs | 11 +++ .../AI/IApplicationAnalysisService.cs | 2 +- .../IApplicationScoresheetAnalysisService.cs | 2 +- .../AI/OpenAIService.cs | 75 +++++++++++++------ .../Attachments/AttachmentAppService.cs | 11 ++- .../ApplicationAIAnalysisAppService.cs | 4 +- .../ApplicationAIPromptCaptureAppService.cs | 31 ++++++++ .../ApplicationAIScoringAppService.cs | 4 +- .../Pages/GrantApplications/Details.cshtml | 32 +++++++- .../Pages/GrantApplications/Details.cshtml.cs | 15 +++- .../Pages/GrantApplications/Details.js | 65 ++++++++++++++++ .../Pages/GrantApplications/ai-analysis.js | 17 ++++- .../AssessmentScoresWidgetViewComponent.cs | 13 +++- .../AssessmentScoresWidgetViewModel.cs | 5 +- .../AssessmentScoresWidget/Default.cshtml | 30 ++++++++ .../AssessmentScoresWidget/Default.js | 19 ++++- .../ChefsAttachments/ChefsAttachments.cs | 19 ++++- .../ChefsAttachments/ChefsAttachments.js | 24 +++++- .../ChefsAttachments/Default.cshtml | 32 +++++++- 30 files changed, 506 insertions(+), 50 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs index 7e3f594e16..3d9aaf789f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs @@ -14,5 +14,14 @@ public class ApplicationAnalysisRequest [JsonPropertyName("attachments")] public List Attachments { get; set; } = new(); + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + + [JsonPropertyName("capturePromptIo")] + public bool CapturePromptIo { get; set; } + + [JsonPropertyName("captureContextId")] + public string? CaptureContextId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs index c0e1bfd1ee..d3eb7fe217 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs @@ -12,5 +12,14 @@ public class AttachmentSummaryRequest [JsonPropertyName("contentType")] public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + + [JsonPropertyName("capturePromptIo")] + public bool CapturePromptIo { get; set; } + + [JsonPropertyName("captureContextId")] + public string? CaptureContextId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs index 870412d079..7f904ea77a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs @@ -17,5 +17,14 @@ public class ScoresheetSectionRequest [JsonPropertyName("sectionSchema")] public JsonElement SectionSchema { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } + + [JsonPropertyName("capturePromptIo")] + public bool CapturePromptIo { get; set; } + + [JsonPropertyName("captureContextId")] + public string? CaptureContextId { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs new file mode 100644 index 0000000000..142c27335e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs @@ -0,0 +1,34 @@ +using System; +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.AI +{ + public class AIPromptIoCaptureResponse + { + [JsonPropertyName("contextId")] + public string ContextId { get; set; } = string.Empty; + + [JsonPropertyName("promptType")] + public string PromptType { get; set; } = string.Empty; + + [JsonPropertyName("promptVersion")] + public string PromptVersion { get; set; } = string.Empty; + + [JsonPropertyName("captureLabel")] + public string CaptureLabel { get; set; } = string.Empty; + + [JsonPropertyName("systemPrompt")] + public string SystemPrompt { get; set; } = string.Empty; + + [JsonPropertyName("userPrompt")] + public string UserPrompt { get; set; } = string.Empty; + + [JsonPropertyName("rawOutput")] + public string RawOutput { get; set; } = string.Empty; + + [JsonPropertyName("formattedOutput")] + public string FormattedOutput { get; set; } = string.Empty; + + [JsonPropertyName("capturedAt")] + public DateTime CapturedAt { get; set; } + } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs index 3bf233769b..b6bb290746 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs @@ -13,6 +13,6 @@ public interface IAttachmentAppService : IApplicationService Task> GetAttachmentsAsync(AttachmentParametersDto attachmentParametersDto); Task GetAttachmentMetadataAsync(AttachmentType attachmentType, Guid attachmentId); Task UpdateAttachmentMetadataAsync(UpdateAttachmentMetadataDto updateAttachment); - Task GenerateAISummaryAttachmentAsync(Guid attachmentId); - Task> GenerateAISummariesAttachmentsAsync(List attachmentIds); + Task GenerateAISummaryAttachmentAsync(Guid attachmentId, string? promptVersion = null, bool capturePromptIo = false); + Task> GenerateAISummariesAttachmentsAsync(List attachmentIds, string? promptVersion = null, bool capturePromptIo = false); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs index c14c38d1bd..cfb21ea57c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIAnalysisAppService.cs @@ -6,6 +6,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationAIAnalysisAppService : IApplicationService { - Task GenerateAIAnalysisAsync(Guid applicationId); + Task GenerateAIAnalysisAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs new file mode 100644 index 0000000000..70fb436f23 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System; +using System.Threading.Tasks; +using Unity.GrantManager.AI; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IApplicationAIPromptCaptureAppService : IApplicationService + { + Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs index 9f18a4f4dd..e3f54c8f2e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIScoringAppService.cs @@ -6,6 +6,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationAIScoringAppService : IApplicationService { - Task GenerateAIScoresheetAnswersAsync(Guid applicationId); + Task GenerateAIScoresheetAnswersAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs new file mode 100644 index 0000000000..5d152817df --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI +{ + public class AIPromptIoCaptureStore : IAIPromptIoCaptureStore, ISingletonDependency + { + private const int MaxCapturesPerKey = 50; + private readonly ConcurrentDictionary> _captures = new(StringComparer.OrdinalIgnoreCase); + + public void Save(AIPromptIoCaptureResponse capture) + { + var key = BuildKey(capture.ContextId, capture.PromptType, capture.PromptVersion); + var queue = _captures.GetOrAdd(key, _ => new ConcurrentQueue()); + queue.Enqueue(capture); + + while (queue.Count > MaxCapturesPerKey) + { + queue.TryDequeue(out _); + } + } + + public IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20) + { + if (!string.IsNullOrWhiteSpace(promptVersion)) + { + var key = BuildKey(contextId, promptType, promptVersion); + return _captures.TryGetValue(key, out var captures) + ? captures.OrderByDescending(item => item.CapturedAt).Take(maxResults).ToList() + : Array.Empty(); + } + + return _captures.Values + .SelectMany(queue => queue) + .Where(item => string.Equals(item.ContextId, contextId, StringComparison.OrdinalIgnoreCase) + && string.Equals(item.PromptType, promptType, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(item => item.CapturedAt) + .Take(maxResults) + .ToList(); + } + + private static string BuildKey(string contextId, string promptType, string promptVersion) + { + return $"{contextId.Trim()}::{promptType.Trim()}::{promptVersion.Trim()}"; + } + } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs index 2234f9ef8d..7eb9badea4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs @@ -25,7 +25,7 @@ public class ApplicationAnalysisService( private const string ComponentsKey = "components"; - public async Task RegenerateAndSaveAsync(Guid applicationId) + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { var application = await applicationRepository.GetAsync(applicationId); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); @@ -68,7 +68,10 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) { Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), - Attachments = attachmentSummaries + Attachments = attachmentSummaries, + PromptVersion = promptVersion, + CapturePromptIo = capturePromptIo, + CaptureContextId = applicationId.ToString() }); var analysisJson = JsonSerializer.Serialize(analysis, _jsonOptionsIndented); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs index dcef966ea1..affbbdba3a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs @@ -30,7 +30,7 @@ public class ApplicationScoresheetAnalysisService( WriteIndented = true }; - public async Task RegenerateAndSaveAsync(Guid applicationId) + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { var application = await applicationRepository.GetAsync(applicationId); var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); @@ -98,7 +98,10 @@ public async Task RegenerateAndSaveAsync(Guid applicationId) Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), Attachments = attachmentSummaries, SectionName = section.Name, - SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions) + SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions), + PromptVersion = promptVersion, + CapturePromptIo = capturePromptIo, + CaptureContextId = applicationId.ToString() }; var sectionAnswers = await aiService.GenerateScoresheetSectionAsync(sectionRequest); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs new file mode 100644 index 0000000000..1827c90157 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Unity.GrantManager.AI; + +namespace Unity.GrantManager.AI +{ + public interface IAIPromptIoCaptureStore + { + void Save(AIPromptIoCaptureResponse capture); + IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs index cdb31bf8b2..172a3b9c5a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationAnalysisService.cs @@ -5,6 +5,6 @@ namespace Unity.GrantManager.AI { public interface IApplicationAnalysisService { - Task RegenerateAndSaveAsync(Guid applicationId); + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs index 73a272fd3f..1cc4ef1ffa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IApplicationScoresheetAnalysisService.cs @@ -5,6 +5,6 @@ namespace Unity.GrantManager.AI { public interface IApplicationScoresheetAnalysisService { - Task RegenerateAndSaveAsync(Guid applicationId); + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 063968a272..ec4ee3e3e0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -19,6 +19,7 @@ public class OpenAIService : IAIService, ITransientDependency private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; + private readonly IAIPromptIoCaptureStore _promptIoCaptureStore; private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; private const string AttachmentSummaryPromptType = "AttachmentSummary"; private const string ScoresheetSectionPromptType = "ScoresheetSection"; @@ -62,12 +63,14 @@ public OpenAIService( HttpClient httpClient, IConfiguration configuration, ILogger logger, - ITextExtractionService textExtractionService) + ITextExtractionService textExtractionService, + IAIPromptIoCaptureStore promptIoCaptureStore) { _httpClient = httpClient; _configuration = configuration; _logger = logger; _textExtractionService = textExtractionService; + _promptIoCaptureStore = promptIoCaptureStore; } public Task IsAvailableAsync() @@ -93,6 +96,8 @@ public async Task GenerateCompletionAsync(AICompletionRequ public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { + var promptVersion = ResolvePromptVersion(request?.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request?.CapturePromptIo ?? false; var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); @@ -105,15 +110,16 @@ public async Task GenerateApplicationAnalysisAsync( .Cast(); var attachments = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); - var systemPrompt = BuildAnalysisSystemPrompt(SelectedPromptVersion); + var systemPrompt = BuildAnalysisSystemPrompt(promptVersion); var analysisContent = BuildAnalysisUserPrompt( - SelectedPromptVersion, + promptVersion, schema, data, attachments); - await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); + await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, analysisContent); var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, raw); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, raw); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ApplicationAnalysisPromptType, promptVersion, "Application Analysis", systemPrompt, analysisContent, raw); return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); } @@ -196,11 +202,13 @@ public async Task GenerateAttachmentSummaryAsync(Atta var fileName = request?.FileName ?? string.Empty; var fileContent = request?.FileContent ?? Array.Empty(); var contentType = request?.ContentType ?? "application/octet-stream"; + var promptVersion = ResolvePromptVersion(request?.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request?.CapturePromptIo ?? false; try { var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - var prompt = BuildAttachmentSystemPrompt(SelectedPromptVersion); + var prompt = BuildAttachmentSystemPrompt(promptVersion); var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; if (attachmentText != null) @@ -220,11 +228,12 @@ public async Task GenerateAttachmentSummaryAsync(Atta text = attachmentText }; var attachment = JsonSerializer.Serialize(attachmentPayload, JsonLogOptions); - var contentToAnalyze = BuildAttachmentUserPrompt(SelectedPromptVersion, attachment); + var contentToAnalyze = BuildAttachmentUserPrompt(promptVersion, attachment); - await LogPromptInputAsync(AttachmentSummaryPromptType, prompt, contentToAnalyze); + await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutputAsync(AttachmentSummaryPromptType, modelOutput); + await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, modelOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, AttachmentSummaryPromptType, promptVersion, fileName, prompt, contentToAnalyze, modelOutput); return new AttachmentSummaryResponse { @@ -314,6 +323,8 @@ private string AddIdsToAnalysisItems(string analysisJson) public async Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request) { + var promptVersion = ResolvePromptVersion(request?.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request?.CapturePromptIo ?? false; var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); @@ -363,16 +374,17 @@ public async Task GenerateScoresheetSectionAsync(Scor } var analysisContent = BuildScoresheetSectionUserPrompt( - SelectedPromptVersion, + promptVersion, dataJson, attachments, section, response); - var systemPrompt = BuildScoresheetSectionSystemPrompt(SelectedPromptVersion); + var systemPrompt = BuildScoresheetSectionSystemPrompt(promptVersion); - await LogPromptInputAsync(ScoresheetSectionPromptType, systemPrompt, analysisContent); + await LogPromptInputAsync(ScoresheetSectionPromptType, promptVersion, systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetSectionPromptType, modelOutput); + await LogPromptOutputAsync(ScoresheetSectionPromptType, promptVersion, modelOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ScoresheetSectionPromptType, promptVersion, request.SectionName, systemPrompt, analysisContent, modelOutput); return ParseScoresheetSectionResponse(modelOutput); } @@ -596,21 +608,21 @@ private static string BuildScoresheetSectionResponseTemplate(string sectionPaylo } } - private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) + private async Task LogPromptInputAsync(string promptType, string promptVersion, string? systemPrompt, string userPrompt) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, SelectedPromptVersion, formattedInput); - await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, promptVersion, formattedInput); + await WritePromptLogFileAsync(promptType, promptVersion, "INPUT", formattedInput); } - private async Task LogPromptOutputAsync(string promptType, string output) + private async Task LogPromptOutputAsync(string promptType, string promptVersion, string output) { var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, SelectedPromptVersion, formattedOutput); - await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, promptVersion, formattedOutput); + await WritePromptLogFileAsync(promptType, promptVersion, "OUTPUT", formattedOutput); } - private async Task WritePromptLogFileAsync(string promptType, string payloadType, string payload) + private async Task WritePromptLogFileAsync(string promptType, string promptVersion, string payloadType, string payload) { if (!CanWritePromptFileLog()) { @@ -624,7 +636,7 @@ private async Task WritePromptLogFileAsync(string promptType, string payloadType Directory.CreateDirectory(logDirectory); var logPath = Path.Combine(logDirectory, PromptLogFileName); - var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + var entry = $"{now} [{promptType}] [{promptVersion}] {payloadType}\n{payload}\n\n"; await File.AppendAllTextAsync(logPath, entry); } catch (Exception ex) @@ -638,6 +650,27 @@ private bool CanWritePromptFileLog() return IsPromptFileLoggingEnabled; } + private void SavePromptCapture(bool capturePromptIo, string? contextId, string promptType, string promptVersion, string captureLabel, string? systemPrompt, string userPrompt, string rawOutput) + { + if (!capturePromptIo || string.IsNullOrWhiteSpace(contextId)) + { + return; + } + + _promptIoCaptureStore.Save(new AIPromptIoCaptureResponse + { + ContextId = contextId, + PromptType = promptType, + PromptVersion = promptVersion, + CaptureLabel = captureLabel?.Trim() ?? string.Empty, + SystemPrompt = systemPrompt?.Trim() ?? string.Empty, + UserPrompt = userPrompt?.Trim() ?? string.Empty, + RawOutput = rawOutput?.Trim() ?? string.Empty, + FormattedOutput = FormatPromptOutputForLog(rawOutput), + CapturedAt = DateTime.UtcNow + }); + } + private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) { var normalizedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? string.Empty : systemPrompt.Trim(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs index 029466de6c..bd12f4410f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs @@ -189,7 +189,7 @@ protected internal static async Task UpdateMetadataIntern return attachment.CreatorId; } - public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) + public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId, string? promptVersion = null, bool capturePromptIo = false) { if (!await aiService.IsAvailableAsync()) { @@ -205,7 +205,10 @@ public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) { FileName = fileName, FileContent = fileContent, - ContentType = contentType + ContentType = contentType, + PromptVersion = promptVersion, + CapturePromptIo = capturePromptIo, + CaptureContextId = attachment.ApplicationId.ToString() }); attachment.AISummary = summaryResponse.Summary; @@ -214,7 +217,7 @@ public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId) return summaryResponse.Summary; } - public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds) + public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds, string? promptVersion = null, bool capturePromptIo = false) { if (!await aiService.IsAvailableAsync()) { @@ -228,7 +231,7 @@ public async Task> GenerateAISummariesAttachmentsAsync(List a { try { - var summary = await GenerateAISummaryAttachmentAsync(attachmentId); + var summary = await GenerateAISummaryAttachmentAsync(attachmentId, promptVersion, capturePromptIo); summaries.Add(summary); } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs index 8f7bb06130..9858838ff8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs @@ -10,11 +10,11 @@ public class ApplicationAIAnalysisAppService( IApplicationAnalysisService applicationAnalysisService) : GrantManagerAppService, IApplicationAIAnalysisAppService { - public async Task GenerateAIAnalysisAsync(Guid applicationId) + public async Task GenerateAIAnalysisAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { - return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId); + return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs new file mode 100644 index 0000000000..5028495ade --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Hosting; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.GrantManager.AI; +using Volo.Abp; + +namespace Unity.GrantManager.GrantApplications +{ + public class ApplicationAIPromptCaptureAppService( + IAIPromptIoCaptureStore promptIoCaptureStore, + IWebHostEnvironment webHostEnvironment) + : GrantManagerAppService, IApplicationAIPromptCaptureAppService + { + public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) + { + if (!webHostEnvironment.IsDevelopment()) + { + throw new UserFriendlyException("Prompt capture is only available in development."); + } + + if (string.IsNullOrWhiteSpace(promptType)) + { + return Task.FromResult(new List()); + } + + var captures = promptIoCaptureStore.GetRecent(applicationId.ToString(), promptType, promptVersion); + return Task.FromResult(new List(captures)); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs index c777fd61f4..577dc6c6f7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs @@ -13,11 +13,11 @@ public class ApplicationAIScoringAppService( IApplicationScoresheetAnalysisService applicationScoresheetAnalysisService) : GrantManagerAppService, IApplicationAIScoringAppService { - public async Task GenerateAIScoresheetAnswersAsync(Guid applicationId) + public async Task GenerateAIScoresheetAnswersAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { - return await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId); + return await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 838523702e..57e41ed383 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -66,7 +66,7 @@ - + @functions { @@ -406,6 +406,36 @@ Refresh Analysis + @if (Model.IsDevPromptControlsEnabled) + { +
+
+ + +
+
+ + +
+
+
+
+
Captured Prompt I/O
+

+                            
+
+ }
@* Default message when no analysis data is available *@
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index 80015854cd..bcc8a0a86f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -87,6 +88,12 @@ public class DetailsModel : AbpPageModel [BindProperty] public HashSet ZoneStateSet { get; set; } = []; + [BindProperty(SupportsGet = true)] + public bool IsDevPromptControlsEnabled { get; set; } + + [BindProperty(SupportsGet = true)] + public string DefaultPromptVersion { get; set; } = "v1"; + public DetailsModel( GrantApplicationAppService grantApplicationAppService, IWorksheetLinkAppService worksheetLinkAppService, @@ -94,6 +101,7 @@ public DetailsModel( IFeatureChecker featureChecker, ICurrentUser currentUser, IConfiguration configuration, + IWebHostEnvironment webHostEnvironment, IZoneManagementAppService zoneManagementAppService) { _grantApplicationAppService = grantApplicationAppService; @@ -106,6 +114,10 @@ public DetailsModel( CurrentUserName = currentUser.SurName + ", " + currentUser.Name; Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; + IsDevPromptControlsEnabled = webHostEnvironment.IsDevelopment(); + DefaultPromptVersion = string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) + ? "v1" + : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); } public async Task OnGetAsync() @@ -167,3 +179,4 @@ public class BoundWorksheet public uint? Order { get; set; } = 0; } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index c173d70141..0300aad934 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -4,6 +4,71 @@ */ $(function () { + globalThis.hideAIPromptCapture = function(containerSelector, outputSelector) { + $(outputSelector).text(''); + $(containerSelector).addClass('d-none'); + }; + + function formatAIPromptCaptureBlock(capture) { + const parts = []; + + parts.push(`PROMPT TYPE: ${capture.promptType || ''}`); + parts.push(`PROMPT VERSION: ${capture.promptVersion || ''}`); + + if (capture.captureLabel) { + parts.push(`LABEL: ${capture.captureLabel}`); + } + + if (capture.capturedAt) { + parts.push(`CAPTURED AT: ${capture.capturedAt}`); + } + + parts.push(''); + parts.push('SYSTEM PROMPT'); + parts.push(capture.systemPrompt || ''); + parts.push(''); + parts.push('USER PROMPT'); + parts.push(capture.userPrompt || ''); + parts.push(''); + parts.push('RAW OUTPUT'); + parts.push(capture.rawOutput || ''); + parts.push(''); + parts.push('FORMATTED OUTPUT'); + parts.push(capture.formattedOutput || ''); + + return parts.join('\n'); + } + + globalThis.renderAIPromptCapture = function(containerSelector, outputSelector, captures) { + if (!Array.isArray(captures) || captures.length === 0) { + globalThis.hideAIPromptCapture(containerSelector, outputSelector); + return; + } + + const formatted = captures + .map((capture) => formatAIPromptCaptureBlock(capture)) + .join('\n\n----------------------------------------\n\n'); + + $(outputSelector).text(formatted); + $(containerSelector).removeClass('d-none'); + }; + + globalThis.loadAIPromptCapture = function(applicationId, promptType, promptVersion, containerSelector, outputSelector) { + if (!applicationId || !promptType) { + globalThis.hideAIPromptCapture(containerSelector, outputSelector); + return Promise.resolve(); + } + + return unity.grantManager.grantApplications.applicationAIPromptCapture + .getRecent(applicationId, promptType, promptVersion || null) + .then(function(captures) { + globalThis.renderAIPromptCapture(containerSelector, outputSelector, captures || []); + }) + .catch(function() { + globalThis.hideAIPromptCapture(containerSelector, outputSelector); + }); + }; + let selectedReviewDetails = null; let renderFormIoToHtml = document.getElementById('RenderFormIoToHtml').value; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 8abd07a076..993ca60783 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -395,20 +395,35 @@ globalThis.regenerateAIAnalysis = function() { const applicationId = $('#DetailsViewApplicationId').val(); const $button = $('#regenerateAiAnalysis'); const existingHtml = $button.html(); + const promptVersion = $('#aiAnalysisPromptVersion').val() || null; + const capturePromptIo = $('#aiAnalysisCapturePromptIo').is(':checked'); if (!applicationId || $button.prop('disabled')) { return; } + if (!capturePromptIo && globalThis.hideAIPromptCapture) { + globalThis.hideAIPromptCapture('#aiAnalysisPromptCaptureContainer', '#aiAnalysisPromptCaptureOutput'); + } + $button .html(' Refreshing Analysis...') .prop('disabled', true); unity.grantManager.grantApplications.applicationAIAnalysis - .generateAIAnalysis(applicationId) + .generateAIAnalysis(applicationId, promptVersion, capturePromptIo) .then(function() { abp.notify.success('AI analysis refreshed successfully.'); loadAIAnalysis(); + if (capturePromptIo && globalThis.loadAIPromptCapture) { + return globalThis.loadAIPromptCapture( + applicationId, + 'ApplicationAnalysis', + promptVersion, + '#aiAnalysisPromptCaptureContainer', + '#aiAnalysisPromptCaptureOutput' + ); + } }) .catch(function() { abp.message.error('Failed to refresh AI analysis. Please try again.'); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index bbcab59471..c7719e3bb0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.AspNetCore.Mvc; using System.Collections.Generic; @@ -28,7 +30,9 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmentRepository, IScoresheetRepository scoresheetRepository, IScoresheetInstanceRepository scoresheetInstanceRepository, - IApplicationRepository applicationRepository) : AbpViewComponent + IApplicationRepository applicationRepository, + IWebHostEnvironment webHostEnvironment, + IConfiguration configuration) : AbpViewComponent { public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) { @@ -94,6 +98,10 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, + IsDevPromptControlsEnabled = webHostEnvironment.IsDevelopment(), + DefaultPromptVersion = string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) + ? "v1" + : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(), }; return View(model); @@ -294,3 +302,4 @@ public override void ConfigureBundle(BundleConfigurationContext context) } } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs index 4a74d0c5b3..d89998815f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel.DataAnnotations; using Unity.Flex.Scoresheets; using Unity.GrantManager.Assessments; @@ -25,6 +25,8 @@ public class AssessmentScoresWidgetViewModel public Guid CurrentUserId { get; set; } public Guid AssessorId { get; set; } public ScoresheetDto? Scoresheet { get; set; } + public bool IsDevPromptControlsEnabled { get; set; } + public string DefaultPromptVersion { get; set; } = "v1"; public bool IsDisabled() { if(CurrentUserId != AssessorId) @@ -47,3 +49,4 @@ public bool IsDisabled() } } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index b613bd2d39..0344c6fb41 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -24,6 +24,36 @@
+ @if (Model.IsDevPromptControlsEnabled) + { +
+
+ + +
+
+ + +
+
+
+
+
Captured Prompt I/O
+

+                    
+
+ } }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 9b1194b2a1..af7bb993cc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -581,11 +581,17 @@ function regenerateAIScoresheetAnswers() { const applicationId = $('#DetailsViewApplicationId').val(); const $button = $('#regenerateAiScoresheetBtn'); const existingHtml = $button.html(); + const promptVersion = $('#aiScoringPromptVersion').val() || null; + const capturePromptIo = $('#aiScoringCapturePromptIo').is(':checked'); if (!applicationId || $button.prop('disabled')) { return; } + if (!capturePromptIo && globalThis.hideAIPromptCapture) { + globalThis.hideAIPromptCapture('#aiScoringPromptCaptureContainer', '#aiScoringPromptCaptureOutput'); + } + $button .html( 'Refreshing Scoring...' @@ -593,10 +599,21 @@ function regenerateAIScoresheetAnswers() { .prop('disabled', true); unity.grantManager.grantApplications.applicationAIScoring - .generateAIScoresheetAnswers(applicationId) + .generateAIScoresheetAnswers(applicationId, promptVersion, capturePromptIo) .done(function () { abp.notify.success('AI scoring refreshed successfully.'); PubSub.publish('refresh_assessment_scores', null); + if (capturePromptIo && globalThis.loadAIPromptCapture) { + setTimeout(function () { + globalThis.loadAIPromptCapture( + applicationId, + 'ScoresheetSection', + promptVersion, + '#aiScoringPromptCaptureContainer', + '#aiScoringPromptCaptureOutput' + ); + }, 750); + } }) .fail(function () { abp.message.error( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 530a0338fd..8aa44797e6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.AspNetCore.Mvc; @@ -18,11 +20,19 @@ public class ChefsAttachments : AbpViewComponent { private readonly IFeatureChecker _featureChecker; private readonly IPermissionChecker _permissionChecker; + private readonly IWebHostEnvironment _webHostEnvironment; + private readonly IConfiguration _configuration; - public ChefsAttachments(IFeatureChecker featureChecker, IPermissionChecker permissionChecker) + public ChefsAttachments( + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker, + IWebHostEnvironment webHostEnvironment, + IConfiguration configuration) { _featureChecker = featureChecker; _permissionChecker = permissionChecker; + _webHostEnvironment = webHostEnvironment; + _configuration = configuration; } public async Task InvokeAsync() @@ -31,6 +41,10 @@ public async Task InvokeAsync() await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") && await _permissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); ViewBag.IsAIAttachmentSummariesEnabled = isAIAttachmentSummariesEnabled; + ViewBag.IsDevPromptControlsEnabled = _webHostEnvironment.IsDevelopment(); + ViewBag.DefaultPromptVersion = string.IsNullOrWhiteSpace(_configuration["Azure:OpenAI:PromptVersion"]) + ? "v1" + : _configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); return View(); } } @@ -55,3 +69,4 @@ public override void ConfigureBundle(BundleConfigurationContext context) } } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index c5ac30b12a..1e05d774de 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -203,6 +203,9 @@ $(function () { $generateAISummariesButton.on('click', function () { const $button = $(this); const selectedRows = chefsDataTable.rows({ selected: true }).data(); + const promptVersion = $('#attachmentPromptVersion').val() || null; + const capturePromptIo = $('#attachmentCapturePromptIo').is(':checked'); + const applicationId = $('#DetailsViewApplicationId').val(); if (selectedRows.length === 0) { abp.message.warn( @@ -216,9 +219,18 @@ $(function () { const existingHTML = $button.html(); + if (!capturePromptIo && globalThis.hideAIPromptCapture) { + globalThis.hideAIPromptCapture('#attachmentPromptCaptureContainer', '#attachmentPromptCaptureOutput'); + } + // Call the backend API $.ajax({ - url: '/api/app/attachment/generate-aISummaries-attachments', + url: + '/api/app/attachment/generate-aISummaries-attachments' + + '?promptVersion=' + + encodeURIComponent(promptVersion || '') + + '&capturePromptIo=' + + encodeURIComponent(String(capturePromptIo)), data: JSON.stringify(attachmentIds), contentType: 'application/json', type: 'POST', @@ -242,6 +254,16 @@ $(function () { // Enable the toggle button now that we have summaries $('#toggleAllAISummaries').prop('disabled', false); + if (capturePromptIo && globalThis.loadAIPromptCapture) { + globalThis.loadAIPromptCapture( + applicationId, + 'AttachmentSummary', + promptVersion, + '#attachmentPromptCaptureContainer', + '#attachmentPromptCaptureOutput' + ); + } + $button.html(existingHTML).prop('disabled', false); }, error: function (error) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 1b25d70409..899539df6d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -22,6 +22,36 @@ button-type="Light" /> *@
+@if (ViewBag.IsAIAttachmentSummariesEnabled && ViewBag.IsDevPromptControlsEnabled) +{ +
+
+ + +
+
+ + +
+
+
+
+
Captured Prompt I/O
+

+        
+
+}
-
\ No newline at end of file +
From 971245b5099c49b05e827091abdaaa95a1f2bd54 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 14:56:48 -0700 Subject: [PATCH 07/32] AB#32297 tighten prompt capture refresh and attachment selection flow --- .../Pages/GrantApplications/Details.js | 70 ++++++++++++++++++- .../ChefsAttachments/ChefsAttachments.js | 10 +++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 0300aad934..386b4fef71 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -4,8 +4,12 @@ */ $(function () { + function setPromptCaptureOutput(outputSelector, value) { + $(outputSelector).val(value); + } + globalThis.hideAIPromptCapture = function(containerSelector, outputSelector) { - $(outputSelector).text(''); + setPromptCaptureOutput(outputSelector, ''); $(containerSelector).addClass('d-none'); }; @@ -49,7 +53,7 @@ $(function () { .map((capture) => formatAIPromptCaptureBlock(capture)) .join('\n\n----------------------------------------\n\n'); - $(outputSelector).text(formatted); + setPromptCaptureOutput(outputSelector, formatted); $(containerSelector).removeClass('d-none'); }; @@ -69,6 +73,24 @@ $(function () { }); }; + $(document).on('click', '.ai-prompt-capture-copy-btn', async function () { + const targetSelector = $(this).data('target'); + const text = $(targetSelector).val(); + + if (!targetSelector || !text) { + return; + } + + try { + await navigator.clipboard.writeText(text); + abp.notify.success('Copied prompt capture.'); + } catch { + const output = $(targetSelector); + output.trigger('focus'); + output.trigger('select'); + } + }); + let selectedReviewDetails = null; let renderFormIoToHtml = document.getElementById('RenderFormIoToHtml').value; @@ -376,9 +398,53 @@ $(function () { }, }); + function restoreScoringPromptControls(promptVersion, capturePromptIo) { + if (promptVersion) { + $('#aiScoringPromptVersion').val(promptVersion); + } + + $('#aiScoringCapturePromptIo').prop('checked', !!capturePromptIo); + } + + function finalizeScoringPromptRefresh(data) { + restoreScoringPromptControls(data?.promptVersion || null, data?.capturePromptIo || false); + + if (data?.capturePromptIo && globalThis.loadAIPromptCapture) { + globalThis.loadAIPromptCapture( + data.applicationId, + 'ScoresheetSection', + data.promptVersion || null, + '#aiScoringPromptCaptureContainer', + '#aiScoringPromptCaptureOutput' + ); + return; + } + + globalThis.hideAIPromptCapture?.('#aiScoringPromptCaptureContainer', '#aiScoringPromptCaptureOutput'); + } + + function waitForScoringPromptControls(callback, retries = 20) { + if ($('#aiScoringPromptVersion').length > 0 && $('#aiScoringCapturePromptIo').length > 0) { + callback(); + return; + } + + if (retries <= 0) { + return; + } + + setTimeout(() => waitForScoringPromptControls(callback, retries - 1), 50); + } + PubSub.subscribe('refresh_assessment_scores', (msg, data) => { assessmentScoresWidgetManager.refresh(); updateSubtotal(); + + if (!data) { + return; + } + + waitForScoringPromptControls(() => finalizeScoringPromptRefresh(data)); }); PubSub.subscribe('select_application_review', (msg, data) => { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 1e05d774de..1e2e68dbc8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -200,6 +200,14 @@ $(function () { //Generate AI summaries for attachments const $generateAISummariesButton = $('#generateAiSummaries'); if ($generateAISummariesButton.length > 0) { + function resetAttachmentSelectionState() { + selectedAtttachments = []; + $('.select-all-chefs-files').prop('checked', false); + $('.chkbox').prop('checked', false); + $(downloadAll).prop('disabled', true); + $generateAISummariesButton.prop('disabled', true); + } + $generateAISummariesButton.on('click', function () { const $button = $(this); const selectedRows = chefsDataTable.rows({ selected: true }).data(); @@ -248,6 +256,8 @@ $(function () { ' attachment(s).' ); + resetAttachmentSelectionState(); + // Reload the table to show new summaries chefsDataTable.ajax.reload(); From 27ad48d2273e852c0a3f270f277a0178869eca0b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 14:59:32 -0700 Subject: [PATCH 08/32] AB#32297 tighten prompt capture compile and environment checks --- .../AI/Responses/AIPromptIoCaptureResponse.cs | 1 + .../AI/AIPromptIoCaptureStore.cs | 1 + .../AI/OpenAIService.cs | 23 +++++++++++-------- .../ApplicationAIPromptCaptureAppService.cs | 2 +- .../Pages/GrantApplications/Details.cshtml.cs | 2 +- .../AssessmentScoresWidgetViewComponent.cs | 2 +- .../ChefsAttachments/ChefsAttachments.cs | 3 ++- 7 files changed, 20 insertions(+), 14 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs index 142c27335e..40d2c77a90 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs @@ -32,3 +32,4 @@ public class AIPromptIoCaptureResponse [JsonPropertyName("capturedAt")] public DateTime CapturedAt { get; set; } } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs index 5d152817df..f67e35e403 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs @@ -47,3 +47,4 @@ private static string BuildKey(string contextId, string promptType, string promp return $"{contextId.Trim()}::{promptType.Trim()}::{promptVersion.Trim()}"; } } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index ec4ee3e3e0..d24795d496 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -96,8 +96,9 @@ public async Task GenerateCompletionAsync(AICompletionRequ public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { - var promptVersion = ResolvePromptVersion(request?.PromptVersion ?? SelectedPromptVersion); - var capturePromptIo = request?.CapturePromptIo ?? false; + ArgumentNullException.ThrowIfNull(request); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request.CapturePromptIo; var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); @@ -199,11 +200,12 @@ private async Task GenerateSummaryAsync( public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) { - var fileName = request?.FileName ?? string.Empty; - var fileContent = request?.FileContent ?? Array.Empty(); - var contentType = request?.ContentType ?? "application/octet-stream"; - var promptVersion = ResolvePromptVersion(request?.PromptVersion ?? SelectedPromptVersion); - var capturePromptIo = request?.CapturePromptIo ?? false; + ArgumentNullException.ThrowIfNull(request); + var fileName = request.FileName ?? string.Empty; + var fileContent = request.FileContent ?? Array.Empty(); + var contentType = request.ContentType ?? "application/octet-stream"; + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request.CapturePromptIo; try { @@ -323,8 +325,9 @@ private string AddIdsToAnalysisItems(string analysisJson) public async Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request) { - var promptVersion = ResolvePromptVersion(request?.PromptVersion ?? SelectedPromptVersion); - var capturePromptIo = request?.CapturePromptIo ?? false; + ArgumentNullException.ThrowIfNull(request); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var capturePromptIo = request.CapturePromptIo; var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); @@ -666,7 +669,7 @@ private void SavePromptCapture(bool capturePromptIo, string? contextId, string p SystemPrompt = systemPrompt?.Trim() ?? string.Empty, UserPrompt = userPrompt?.Trim() ?? string.Empty, RawOutput = rawOutput?.Trim() ?? string.Empty, - FormattedOutput = FormatPromptOutputForLog(rawOutput), + FormattedOutput = FormatPromptOutputForLog(rawOutput ?? string.Empty), CapturedAt = DateTime.UtcNow }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs index 5028495ade..071cc81ba3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs @@ -14,7 +14,7 @@ public class ApplicationAIPromptCaptureAppService( { public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) { - if (!webHostEnvironment.IsDevelopment()) + if (!string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase)) { throw new UserFriendlyException("Prompt capture is only available in development."); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index bcc8a0a86f..55b436466a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -114,7 +114,7 @@ public DetailsModel( CurrentUserName = currentUser.SurName + ", " + currentUser.Name; Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; - IsDevPromptControlsEnabled = webHostEnvironment.IsDevelopment(); + IsDevPromptControlsEnabled = string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); DefaultPromptVersion = string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) ? "v1" : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index c7719e3bb0..f1d197c86c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -98,7 +98,7 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, - IsDevPromptControlsEnabled = webHostEnvironment.IsDevelopment(), + IsDevPromptControlsEnabled = string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase), DefaultPromptVersion = string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) ? "v1" : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(), diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 8aa44797e6..1eb193c3c0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using System; using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.AspNetCore.Mvc; @@ -41,7 +42,7 @@ public async Task InvokeAsync() await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") && await _permissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); ViewBag.IsAIAttachmentSummariesEnabled = isAIAttachmentSummariesEnabled; - ViewBag.IsDevPromptControlsEnabled = _webHostEnvironment.IsDevelopment(); + ViewBag.IsDevPromptControlsEnabled = string.Equals(_webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); ViewBag.DefaultPromptVersion = string.IsNullOrWhiteSpace(_configuration["Azure:OpenAI:PromptVersion"]) ? "v1" : _configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); From 9e97e2eabb31ac5c8928d4666f5aef36fb6489e1 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 14:59:41 -0700 Subject: [PATCH 09/32] AB#32297 align scoresheet prompt schema with regenerate flow --- .../ApplicationScoresheetAnalysisService.cs | 26 ++++++++++++++++--- .../Prompts/Versions/v1/scoresheet.rules.txt | 4 +-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs index affbbdba3a..0b9fd03bef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs @@ -82,14 +82,15 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var sectionQuestionsData = new List(); foreach (var field in section.Fields.OrderBy(f => f.Order)) { + var options = ExtractSelectListOptions(field); sectionQuestionsData.Add(new { id = field.Id.ToString(), question = field.Label, description = field.Description, type = field.Type.ToString(), - definition = field.Definition, - availableOptions = ExtractSelectListOptions(field) + options, + allowed_answers = ExtractSelectListOptionNumbers(options) }); } @@ -147,7 +148,7 @@ private static string ValidateScoresheetJson(string scoresheetAnswers) return "{}"; } - private static (int number, string value, long numericValue)[]? ExtractSelectListOptions(Question field) + private static object[]? ExtractSelectListOptions(Question field) { if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) return null; @@ -158,7 +159,12 @@ private static (int number, string value, long numericValue)[]? ExtractSelectLis if (definition?.Options != null && definition.Options.Count > 0) { return definition.Options - .Select((option, index) => (number: index, value: option.Value, numericValue: option.NumericValue)) + .Select((option, index) => + (object)new + { + number = index + 1, + value = option.Value + }) .ToArray(); } } @@ -169,5 +175,17 @@ private static (int number, string value, long numericValue)[]? ExtractSelectLis return null; } + + private static string[]? ExtractSelectListOptionNumbers(object[]? options) + { + if (options == null || options.Length == 0) + { + return null; + } + + return options + .Select((_, index) => (index + 1).ToString()) + .ToArray(); + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index 41c8d580f4..d1269a700a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -20,8 +20,8 @@ - For numeric questions, answer must never be blank. - If evidence is insufficient for a numeric question, return the minimum allowed numeric value and explain uncertainty in rationale. - If a required value is explicitly missing in DATA/ATTACHMENTS, set confidence high (80-100) when selecting the conservative minimum. -- For select list questions, return only the selected options.number as a string (the option index shown in options), never label text or points. -- For select list questions, the "answer" value must be one of question.allowed_answers exactly. +- For select list questions, use the matching SECTION.questions[].options entries and return only the selected options[].number as a string. +- For select list questions, the "answer" value must be one of the matching question.allowed_answers values exactly. - Never return 0 for select list answers unless 0 exists as an explicit option number. - For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. - For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. From a81681de2728877439edda094fe35f54793a1111 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 14:59:52 -0700 Subject: [PATCH 10/32] AB#32297 polish prompt capture panels and prompt control layout --- .../Pages/GrantApplications/Details.cshtml | 20 ++++++----- .../Pages/GrantApplications/Details.css | 26 +++++++++++--- .../AssessmentScoresWidget/Default.cshtml | 35 +++++++++++++++---- .../AssessmentScoresWidget/Default.css | 2 +- .../AssessmentScoresWidget/Default.js | 17 +++------ .../ChefsAttachments/Default.cshtml | 28 ++++++++++++--- 6 files changed, 90 insertions(+), 38 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 57e41ed383..ef1847fda2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -301,11 +301,9 @@ @await Component.InvokeAsync("UserInfoWidget", new { displayName = "", badge = "", title = "" }) -
Assessment Scores
- -
- @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId }) -
+
+ @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId }) +
Scoring Attachments
@@ -403,7 +401,10 @@
AI Application Analysis
@if (Model.IsDevPromptControlsEnabled) @@ -431,8 +432,11 @@
-
Captured Prompt I/O
-

+                                
+
Captured Prompt I/O
+ +
+
} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index 4ad7bd5371..e4c3be4d61 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -60,11 +60,27 @@ border-bottom: 3px solid #003366; } -.spinner-loader { - display: flex; - align-items: center; - justify-content: center; -} +.spinner-loader { + display: flex; + align-items: center; + justify-content: center; +} + +.ai-button-content { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.ai-prompt-capture-output { + min-height: 18rem; + max-height: 32rem; + overflow: auto; + resize: vertical; + white-space: pre; + font-family: Consolas, "Courier New", monospace; + line-height: 1.35; +} .left-card { border-right: 1px solid #dddddd; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index 0344c6fb41..f6773f8ec6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -19,14 +19,32 @@
@if (Model.Scoresheet.Sections.Any()) { -
- - - +
+
Assessment Scores
+
+ + + +
@if (Model.IsDevPromptControlsEnabled) { -
+
} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css index aedd7ed609..17cc777f35 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.css @@ -51,7 +51,7 @@ input { } .scoresheet-top-btn-group { - margin-top: -45px; + margin-top: 0; } /* AI-generated answer styling (blue text) */ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index af7bb993cc..bca2f5a644 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -602,18 +602,11 @@ function regenerateAIScoresheetAnswers() { .generateAIScoresheetAnswers(applicationId, promptVersion, capturePromptIo) .done(function () { abp.notify.success('AI scoring refreshed successfully.'); - PubSub.publish('refresh_assessment_scores', null); - if (capturePromptIo && globalThis.loadAIPromptCapture) { - setTimeout(function () { - globalThis.loadAIPromptCapture( - applicationId, - 'ScoresheetSection', - promptVersion, - '#aiScoringPromptCaptureContainer', - '#aiScoringPromptCaptureOutput' - ); - }, 750); - } + PubSub.publish('refresh_assessment_scores', { + promptVersion: promptVersion, + capturePromptIo: capturePromptIo, + applicationId: applicationId, + }); }) .fail(function () { abp.message.error( diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 899539df6d..7285d81d9e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -9,10 +9,25 @@
@if (ViewBag.IsAIAttachmentSummariesEnabled) { - - + + } - + @*
-
Captured Prompt I/O
-

+            
+
Captured Prompt I/O
+ +
+
} From 21e03f3c6997279a6313397242be941c64523379 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 15:06:11 -0700 Subject: [PATCH 11/32] AB#32297 align prompt capture labels with UI casing conventions --- .../Pages/GrantApplications/Details.cshtml | 4 ++-- .../Shared/Components/AssessmentScoresWidget/Default.cshtml | 4 ++-- .../Views/Shared/Components/ChefsAttachments/Default.cshtml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index ef1847fda2..99a7cfec69 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -427,13 +427,13 @@
- +
-
Captured Prompt I/O
+
Captured Prompt Input/Output
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index f6773f8ec6..5a803da99a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -62,13 +62,13 @@
- +
-
Captured Prompt I/O
+
Captured Prompt Input/Output
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index 7285d81d9e..c4cfa070a5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -57,13 +57,13 @@
- +
-
Captured Prompt I/O
+
Captured Prompt Input/Output
From a88604cc371953c9608265b8e9451a31b7f0d49e Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 15:17:30 -0700 Subject: [PATCH 12/32] AB#32297 simplify prompt capture type naming --- ...CaptureResponse.cs => AIPromptCaptureResponse.cs} | 2 +- .../IApplicationAIPromptCaptureAppService.cs | 2 +- ...omptIoCaptureStore.cs => AIPromptCaptureStore.cs} | 12 ++++++------ .../AI/IAIPromptCaptureStore.cs | 11 +++++++++++ .../AI/IAIPromptIoCaptureStore.cs | 11 ----------- .../AI/OpenAIService.cs | 6 +++--- .../ApplicationAIPromptCaptureAppService.cs | 8 ++++---- 7 files changed, 26 insertions(+), 26 deletions(-) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/{AIPromptIoCaptureResponse.cs => AIPromptCaptureResponse.cs} (96%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/{AIPromptIoCaptureStore.cs => AIPromptCaptureStore.cs} (76%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs similarity index 96% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs index 40d2c77a90..fc1fac75f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptIoCaptureResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs @@ -3,7 +3,7 @@ namespace Unity.GrantManager.AI { - public class AIPromptIoCaptureResponse + public class AIPromptCaptureResponse { [JsonPropertyName("contextId")] public string ContextId { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs index 70fb436f23..c25d04ee9b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAIPromptCaptureAppService.cs @@ -8,6 +8,6 @@ namespace Unity.GrantManager.GrantApplications { public interface IApplicationAIPromptCaptureAppService : IApplicationService { - Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null); + Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptCaptureStore.cs similarity index 76% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptCaptureStore.cs index f67e35e403..ec69a9de20 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptIoCaptureStore.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptCaptureStore.cs @@ -6,15 +6,15 @@ namespace Unity.GrantManager.AI { - public class AIPromptIoCaptureStore : IAIPromptIoCaptureStore, ISingletonDependency + public class AIPromptCaptureStore : IAIPromptCaptureStore, ISingletonDependency { private const int MaxCapturesPerKey = 50; - private readonly ConcurrentDictionary> _captures = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _captures = new(StringComparer.OrdinalIgnoreCase); - public void Save(AIPromptIoCaptureResponse capture) + public void Save(AIPromptCaptureResponse capture) { var key = BuildKey(capture.ContextId, capture.PromptType, capture.PromptVersion); - var queue = _captures.GetOrAdd(key, _ => new ConcurrentQueue()); + var queue = _captures.GetOrAdd(key, _ => new ConcurrentQueue()); queue.Enqueue(capture); while (queue.Count > MaxCapturesPerKey) @@ -23,14 +23,14 @@ public void Save(AIPromptIoCaptureResponse capture) } } - public IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20) + public IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20) { if (!string.IsNullOrWhiteSpace(promptVersion)) { var key = BuildKey(contextId, promptType, promptVersion); return _captures.TryGetValue(key, out var captures) ? captures.OrderByDescending(item => item.CapturedAt).Take(maxResults).ToList() - : Array.Empty(); + : Array.Empty(); } return _captures.Values diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs new file mode 100644 index 0000000000..48d68f0bb6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptCaptureStore.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Unity.GrantManager.AI; + +namespace Unity.GrantManager.AI +{ + public interface IAIPromptCaptureStore + { + void Save(AIPromptCaptureResponse capture); + IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs deleted file mode 100644 index 1827c90157..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/IAIPromptIoCaptureStore.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using Unity.GrantManager.AI; - -namespace Unity.GrantManager.AI -{ - public interface IAIPromptIoCaptureStore - { - void Save(AIPromptIoCaptureResponse capture); - IReadOnlyList GetRecent(string contextId, string promptType, string? promptVersion = null, int maxResults = 20); - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index d24795d496..5869449641 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -19,7 +19,7 @@ public class OpenAIService : IAIService, ITransientDependency private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; - private readonly IAIPromptIoCaptureStore _promptIoCaptureStore; + private readonly IAIPromptCaptureStore _promptIoCaptureStore; private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; private const string AttachmentSummaryPromptType = "AttachmentSummary"; private const string ScoresheetSectionPromptType = "ScoresheetSection"; @@ -64,7 +64,7 @@ public OpenAIService( IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService, - IAIPromptIoCaptureStore promptIoCaptureStore) + IAIPromptCaptureStore promptIoCaptureStore) { _httpClient = httpClient; _configuration = configuration; @@ -660,7 +660,7 @@ private void SavePromptCapture(bool capturePromptIo, string? contextId, string p return; } - _promptIoCaptureStore.Save(new AIPromptIoCaptureResponse + _promptIoCaptureStore.Save(new AIPromptCaptureResponse { ContextId = contextId, PromptType = promptType, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs index 071cc81ba3..8780c833f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs @@ -8,11 +8,11 @@ namespace Unity.GrantManager.GrantApplications { public class ApplicationAIPromptCaptureAppService( - IAIPromptIoCaptureStore promptIoCaptureStore, + IAIPromptCaptureStore promptIoCaptureStore, IWebHostEnvironment webHostEnvironment) : GrantManagerAppService, IApplicationAIPromptCaptureAppService { - public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) + public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) { if (!string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase)) { @@ -21,11 +21,11 @@ public Task> GetRecentAsync(Guid applicationId, if (string.IsNullOrWhiteSpace(promptType)) { - return Task.FromResult(new List()); + return Task.FromResult(new List()); } var captures = promptIoCaptureStore.GetRecent(applicationId.ToString(), promptType, promptVersion); - return Task.FromResult(new List(captures)); + return Task.FromResult(new List(captures)); } } } From ed48260ca1e545820d20cbff5432980223dc44bd Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 15:34:04 -0700 Subject: [PATCH 13/32] AB#32297 move dev prompt view state out of components --- .../Pages/GrantApplications/Details.cshtml | 22 ++++++++++--------- .../Pages/GrantApplications/Details.js | 2 ++ .../AssessmentScoresWidgetViewComponent.cs | 16 +++++--------- .../ChefsAttachments/ChefsAttachments.cs | 19 +++++----------- 4 files changed, 24 insertions(+), 35 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 99a7cfec69..81f76e4461 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -63,10 +63,12 @@ - - - - + + + + + + @functions { @@ -302,7 +304,7 @@
- @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId }) + @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId, currentUserId = Model.CurrentUserId, isDevPromptControlsEnabled = Model.IsDevPromptControlsEnabled, defaultPromptVersion = Model.DefaultPromptVersion })
Scoring Attachments
@@ -371,11 +373,11 @@ @*-------- Comments Tab Section END ---------*@ @*-------- Attachments Tab Section ---------*@ -
- @await Component.InvokeAsync("ApplicationAttachments") - - @await Component.InvokeAsync("ChefsAttachments") -
+
+ @await Component.InvokeAsync("ApplicationAttachments") + + @await Component.InvokeAsync("ChefsAttachments", new { isDevPromptControlsEnabled = Model.IsDevPromptControlsEnabled, defaultPromptVersion = Model.DefaultPromptVersion }) +
@*-------- Attachments Tab Section END ---------*@ @*-------- Links Tab Section ---------*@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 386b4fef71..ba7538ed31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -394,6 +394,8 @@ $(function () { return { assessmentId: decodeURIComponent($('#AssessmentId').val()), currentUserId: decodeURIComponent(abp.currentUser.id), + isDevPromptControlsEnabled: $('#IsDevPromptControlsEnabled').val() === 'True', + defaultPromptVersion: $('#DefaultPromptVersion').val() || 'v1', }; }, }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index f1d197c86c..bfb360ceff 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.AspNetCore.Mvc; +using System; using System.Collections.Generic; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; -using System; using System.Threading.Tasks; using System.Globalization; using Unity.GrantManager.Assessments; @@ -30,11 +28,9 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmentRepository, IScoresheetRepository scoresheetRepository, IScoresheetInstanceRepository scoresheetInstanceRepository, - IApplicationRepository applicationRepository, - IWebHostEnvironment webHostEnvironment, - IConfiguration configuration) : AbpViewComponent + IApplicationRepository applicationRepository) : AbpViewComponent { - public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) + public async Task InvokeAsync(Guid assessmentId, Guid currentUserId, bool isDevPromptControlsEnabled = false, string? defaultPromptVersion = null) { if (assessmentId == Guid.Empty) { @@ -98,10 +94,8 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, - IsDevPromptControlsEnabled = string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase), - DefaultPromptVersion = string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) - ? "v1" - : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(), + IsDevPromptControlsEnabled = isDevPromptControlsEnabled, + DefaultPromptVersion = string.IsNullOrWhiteSpace(defaultPromptVersion) ? "v1" : defaultPromptVersion.Trim().ToLowerInvariant(), }; return View(model); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 1eb193c3c0..0a88845130 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -1,7 +1,4 @@ -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using System; using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.AspNetCore.Mvc; @@ -21,31 +18,25 @@ public class ChefsAttachments : AbpViewComponent { private readonly IFeatureChecker _featureChecker; private readonly IPermissionChecker _permissionChecker; - private readonly IWebHostEnvironment _webHostEnvironment; - private readonly IConfiguration _configuration; public ChefsAttachments( IFeatureChecker featureChecker, - IPermissionChecker permissionChecker, - IWebHostEnvironment webHostEnvironment, - IConfiguration configuration) + IPermissionChecker permissionChecker) { _featureChecker = featureChecker; _permissionChecker = permissionChecker; - _webHostEnvironment = webHostEnvironment; - _configuration = configuration; } - public async Task InvokeAsync() + public async Task InvokeAsync(bool isDevPromptControlsEnabled = false, string? defaultPromptVersion = null) { var isAIAttachmentSummariesEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") && await _permissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); ViewBag.IsAIAttachmentSummariesEnabled = isAIAttachmentSummariesEnabled; - ViewBag.IsDevPromptControlsEnabled = string.Equals(_webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); - ViewBag.DefaultPromptVersion = string.IsNullOrWhiteSpace(_configuration["Azure:OpenAI:PromptVersion"]) + ViewBag.IsDevPromptControlsEnabled = isDevPromptControlsEnabled; + ViewBag.DefaultPromptVersion = string.IsNullOrWhiteSpace(defaultPromptVersion) ? "v1" - : _configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); + : defaultPromptVersion.Trim().ToLowerInvariant(); return View(); } } From 17a0a2b6f170c1d9a0c7f91a644841a6dbd41118 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 12 Mar 2026 15:49:40 -0700 Subject: [PATCH 14/32] AB#31677 sonarQube cleanup --- .../GrantsPortal/GrantsPortalCommandConsumerService.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index 9df874d52e..73b1b443c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -58,7 +58,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (OperationCanceledException ex) { - logger.LogInformation("Grants Portal command consumer stopping... {Ex}", ex.Message); + logger.LogInformation(ex, "Grants Portal command consumer stopping..."); } } @@ -90,7 +90,6 @@ private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) catch (Exception ex) { logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts : {Ex}", MaxRetries, ex); - throw; } } } @@ -254,7 +253,7 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e { // Another pod inserted the same MessageId between our check and insert (unique index). // This is expected in multi-pod environments on RabbitMQ redelivery — treat as success. - logger.LogInformation("Message {MessageId} was concurrently inserted by another pod. Treating as idempotent success.", messageId); + logger.LogInformation(ex, "Message {MessageId} was concurrently inserted by another pod. Treating as idempotent success.", messageId); } catch (Exception ex) { From bd9c3f0e052a78b6fb777343ac462ef324d8256c Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 12 Mar 2026 16:35:58 -0700 Subject: [PATCH 15/32] AB#31677 codeQL fixes --- .../AddressInfoDataProvider.cs | 6 ++---- .../ApplicantProfileAppService.cs | 5 ++--- .../ContactInfoDataProvider.cs | 7 +++---- .../ApplicantProfile/OrgInfoDataProvider.cs | 6 ++---- .../ApplicantProfile/SubjectNormalizer.cs | 21 +++++++++++++++++++ .../SubmissionInfoDataProvider.cs | 6 ++---- .../GrantsPortalRabbitMqOptions.cs | 2 +- .../GrantsPortalCommandConsumerService.cs | 8 +++++++ .../Unity.GrantManager.Domain.csproj | 5 ----- .../20260307013604_Add_InboxOutboxMessages.cs | 3 ++- .../GrantManagerDbContextModelSnapshot.cs | 3 ++- 11 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs index cee6f516ee..c047e7e4fb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -39,10 +39,8 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Addresses = [] }; - var subject = request.Subject ?? string.Empty; - var normalizedSubject = subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() - : subject.ToUpperInvariant(); + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; using (currentTenant.Change(request.TenantId)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs index df8617813d..e7146685ee 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileAppService.cs @@ -67,9 +67,8 @@ public async Task GetApplicantProfileAsync(ApplicantProfile public async Task> GetApplicantTenantsAsync(ApplicantProfileRequest request) { // Extract the username part from the OIDC sub (part before '@') - var subUsername = request.Subject.Contains('@') - ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() - : request.Subject.ToUpperInvariant(); + var subUsername = SubjectNormalizer.Normalize(request.Subject); + if (subUsername is null) return []; // Query the ApplicantTenantMaps table in the host database using (currentTenant.Change(null)) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs index e028bb1b27..5062536063 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs @@ -26,6 +26,9 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Contacts = [] }; + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; + var tenantId = request.TenantId; using (currentTenant.Change(tenantId)) @@ -33,10 +36,6 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ var profileContacts = await applicantProfileContactService.GetProfileContactsAsync(request.ProfileId); dto.Contacts.AddRange(profileContacts); - var normalizedSubject = request.Subject.Contains('@') - ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() - : request.Subject.ToUpperInvariant(); - var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(normalizedSubject); dto.Contacts.AddRange(applicationContacts); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs index 45ff3298ae..0534e2f0a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/OrgInfoDataProvider.cs @@ -32,10 +32,8 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Organizations = [] }; - var subject = request.Subject ?? string.Empty; - var normalizedSubject = subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() - : subject.ToUpperInvariant(); + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; using (currentTenant.Change(request.TenantId)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs new file mode 100644 index 0000000000..179ccbb0b1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs @@ -0,0 +1,21 @@ +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Centralises OIDC subject normalization used by applicant-profile providers. + /// Strips the domain portion after '@' and upper-cases the result. + /// Returns null when the input is null, empty, or whitespace so + /// callers can short-circuit before hitting the database. + /// + public static class SubjectNormalizer + { + public static string? Normalize(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) + return null; + + return subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs index 22a37c1687..6d67a53a31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -40,10 +40,8 @@ public async Task GetDataAsync(ApplicantProfileInfoRequ Submissions = [] }; - var subject = request.Subject ?? string.Empty; - var normalizedSubject = subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() - : subject.ToUpperInvariant(); + var normalizedSubject = SubjectNormalizer.Normalize(request.Subject); + if (normalizedSubject is null) return dto; dto.LinkSource = await ResolveFormViewUrlAsync(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs index 95e30b2a99..773251f722 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs @@ -5,7 +5,7 @@ public class GrantsPortalRabbitMqOptions public const string SectionName = "RabbitMQ:GrantsPortal"; /// - /// The integration source identifier used in the IntegrationMessages table. + /// The integration source identifier used for Grants Portal inbox/outbox messages.. /// public const string SourceName = "GrantsPortal"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index 73b1b443c9..dcfa80a856 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -212,6 +212,14 @@ private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs e if (string.IsNullOrEmpty(messageId)) messageId = envelope.MessageId; if (string.IsNullOrEmpty(correlationId)) correlationId = envelope.CorrelationId; + // Validate MessageId after applying all fallbacks + if (string.IsNullOrWhiteSpace(messageId)) + { + logger.LogError("Received message with missing/blank MessageId. Discarding. CorrelationId={CorrelationId}", correlationId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + // Resolve tenant from the data.provider field (stored for later use by processors) var payload = envelope.Data?.ToObject(); var tenantId = ResolveTenantId(payload?.Provider); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index 5552818a00..18ffd18ae2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -32,9 +32,4 @@ **/Assessments/Assessment.cs, **/Assessments/AssessmentWithAssessorQueryResultItem.cs - - - - - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs index 36a7cefc34..389f387d5b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307013604_Add_InboxOutboxMessages.cs @@ -71,7 +71,8 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.CreateIndex( name: "IX_InboxMessages_MessageId", table: "InboxMessages", - column: "MessageId"); + column: "MessageId", + unique: true); migrationBuilder.CreateIndex( name: "IX_InboxMessages_Source_Status", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index 88c683517e..e3fd3ba2df 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -1028,7 +1028,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("MessageId"); + b.HasIndex("MessageId") + .IsUnique(); b.HasIndex("Source", "Status"); From 9b3ca50ec3eadf2b7c44325fc58129bcf5dd0e37 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 16:52:27 -0700 Subject: [PATCH 16/32] AB#32297 move prompt tooling into dedicated dev tab --- .../AI/AIPromptToolViewOptionsProvider.cs | 20 +++ .../AI/IAIPromptToolViewOptionsProvider.cs | 9 ++ .../Pages/GrantApplications/Details.cshtml | 131 ++++++++++++------ .../Pages/GrantApplications/Details.cshtml.cs | 10 +- .../Pages/GrantApplications/Details.css | 67 +++++++++ .../Pages/GrantApplications/Details.js | 49 ++----- .../Pages/GrantApplications/ai-analysis.js | 5 +- .../AssessmentScoresWidgetViewComponent.cs | 4 +- .../AssessmentScoresWidgetViewModel.cs | 2 - .../AssessmentScoresWidget/Default.cshtml | 33 ----- .../AssessmentScoresWidget/Default.js | 5 +- .../ChefsAttachments/ChefsAttachments.cs | 6 +- .../ChefsAttachments/ChefsAttachments.js | 10 +- .../ChefsAttachments/Default.cshtml | 33 ----- 14 files changed, 216 insertions(+), 168 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs new file mode 100644 index 0000000000..650eee29ee --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.Web.AI +{ + public class AIPromptToolViewOptionsProvider( + IWebHostEnvironment webHostEnvironment, + IConfiguration configuration) : IAIPromptToolViewOptionsProvider, ITransientDependency + { + public bool IsDevPromptControlsEnabled => + string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); + + public string DefaultPromptVersion => + string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) + ? "v1" + : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs new file mode 100644 index 0000000000..f6e2242194 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/IAIPromptToolViewOptionsProvider.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.Web.AI +{ + public interface IAIPromptToolViewOptionsProvider + { + bool IsDevPromptControlsEnabled { get; } + + string DefaultPromptVersion { get; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 81f76e4461..697bb24cab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -67,8 +67,6 @@ - - @functions { @@ -270,7 +268,13 @@ } - + @if (Model.IsDevPromptControlsEnabled) + { + + } +
@@ -304,7 +308,7 @@
- @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId, currentUserId = Model.CurrentUserId, isDevPromptControlsEnabled = Model.IsDevPromptControlsEnabled, defaultPromptVersion = Model.DefaultPromptVersion }) + @await Component.InvokeAsync("AssessmentScoresWidget", new { assessmentId = Model.AssessmentId, currentUserId = Model.CurrentUserId })
Scoring Attachments
@@ -376,7 +380,7 @@
@await Component.InvokeAsync("ApplicationAttachments") - @await Component.InvokeAsync("ChefsAttachments", new { isDevPromptControlsEnabled = Model.IsDevPromptControlsEnabled, defaultPromptVersion = Model.DefaultPromptVersion }) + @await Component.InvokeAsync("ChefsAttachments")
@*-------- Attachments Tab Section END ---------*@ @@ -409,39 +413,6 @@
- @if (Model.IsDevPromptControlsEnabled) - { -
-
- - -
-
- - -
-
-
-
-
-
Captured Prompt Input/Output
- -
- -
-
- }
@* Default message when no analysis data is available *@
@@ -494,8 +465,88 @@
} - @*-------- AI Analysis Tab Section END ---------*@ -
+ @*-------- AI Analysis Tab Section END ---------*@ + @if (Model.IsDevPromptControlsEnabled) + { +
+
+
AI Dev Tools
+
+
+ +
+
+
+
+ +
+
+
Application Analysis
+ +
+
+ + +
+
+ +
+
+
Assessment Scoring
+ +
+
+ + +
+
+ +
+
+
Attachment Summaries
+ +
+
+ + +
+
+
+
+ } +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index 55b436466a..6fe2bd04cf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -14,6 +13,7 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Flex; using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Web.AI; using Unity.GrantManager.Zones; using Unity.Modules.Shared.Correlation; using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; @@ -101,7 +101,7 @@ public DetailsModel( IFeatureChecker featureChecker, ICurrentUser currentUser, IConfiguration configuration, - IWebHostEnvironment webHostEnvironment, + IAIPromptToolViewOptionsProvider aiPromptToolViewOptionsProvider, IZoneManagementAppService zoneManagementAppService) { _grantApplicationAppService = grantApplicationAppService; @@ -114,10 +114,8 @@ public DetailsModel( CurrentUserName = currentUser.SurName + ", " + currentUser.Name; Extensions = configuration["S3:DisallowedFileTypes"] ?? ""; MaxFileSize = configuration["S3:MaxFileSize"] ?? ""; - IsDevPromptControlsEnabled = string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); - DefaultPromptVersion = string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) - ? "v1" - : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); + IsDevPromptControlsEnabled = aiPromptToolViewOptionsProvider.IsDevPromptControlsEnabled; + DefaultPromptVersion = aiPromptToolViewOptionsProvider.DefaultPromptVersion; } public async Task OnGetAsync() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index e4c3be4d61..d7e53c1fd6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -81,6 +81,73 @@ font-family: Consolas, "Courier New", monospace; line-height: 1.35; } + +.ai-prompt-capture-container { + position: relative; +} + +.ai-prompt-capture-container .ai-prompt-capture-copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 1; + width: 2rem; + height: 2rem; + padding: 0; + color: #5c6b7a; +} + +.dev-prompt-section { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #e7ebef; +} + +.dev-tools-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.dev-prompt-toolbar-row { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.dev-prompt-toolbar-inline { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.dev-prompt-toolbar-row .form-select { + width: auto; + min-width: 5rem; +} + +.dev-prompt-section:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 0; +} + +.dev-prompt-section-header { + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.dev-prompt-section .ai-prompt-capture-output { + min-height: 4.5rem; + max-height: 16rem; + padding-top: 2.25rem; +} .left-card { border-right: 1px solid #dddddd; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index ba7538ed31..2e78d0df13 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -4,13 +4,16 @@ */ $(function () { + globalThis.getSelectedPromptVersion = function() { + return $('#devPromptVersion').val() || null; + }; + function setPromptCaptureOutput(outputSelector, value) { $(outputSelector).val(value); } globalThis.hideAIPromptCapture = function(containerSelector, outputSelector) { setPromptCaptureOutput(outputSelector, ''); - $(containerSelector).addClass('d-none'); }; function formatAIPromptCaptureBlock(capture) { @@ -54,7 +57,6 @@ $(function () { .join('\n\n----------------------------------------\n\n'); setPromptCaptureOutput(outputSelector, formatted); - $(containerSelector).removeClass('d-none'); }; globalThis.loadAIPromptCapture = function(applicationId, promptType, promptVersion, containerSelector, outputSelector) { @@ -394,24 +396,19 @@ $(function () { return { assessmentId: decodeURIComponent($('#AssessmentId').val()), currentUserId: decodeURIComponent(abp.currentUser.id), - isDevPromptControlsEnabled: $('#IsDevPromptControlsEnabled').val() === 'True', - defaultPromptVersion: $('#DefaultPromptVersion').val() || 'v1', }; }, }); - function restoreScoringPromptControls(promptVersion, capturePromptIo) { - if (promptVersion) { - $('#aiScoringPromptVersion').val(promptVersion); - } - - $('#aiScoringCapturePromptIo').prop('checked', !!capturePromptIo); - } + PubSub.subscribe('refresh_assessment_scores', (msg, data) => { + assessmentScoresWidgetManager.refresh(); + updateSubtotal(); - function finalizeScoringPromptRefresh(data) { - restoreScoringPromptControls(data?.promptVersion || null, data?.capturePromptIo || false); + if (!data) { + return; + } - if (data?.capturePromptIo && globalThis.loadAIPromptCapture) { + if (data.capturePromptIo && globalThis.loadAIPromptCapture) { globalThis.loadAIPromptCapture( data.applicationId, 'ScoresheetSection', @@ -423,30 +420,6 @@ $(function () { } globalThis.hideAIPromptCapture?.('#aiScoringPromptCaptureContainer', '#aiScoringPromptCaptureOutput'); - } - - function waitForScoringPromptControls(callback, retries = 20) { - if ($('#aiScoringPromptVersion').length > 0 && $('#aiScoringCapturePromptIo').length > 0) { - callback(); - return; - } - - if (retries <= 0) { - return; - } - - setTimeout(() => waitForScoringPromptControls(callback, retries - 1), 50); - } - - PubSub.subscribe('refresh_assessment_scores', (msg, data) => { - assessmentScoresWidgetManager.refresh(); - updateSubtotal(); - - if (!data) { - return; - } - - waitForScoringPromptControls(() => finalizeScoringPromptRefresh(data)); }); PubSub.subscribe('select_application_review', (msg, data) => { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 993ca60783..5f52f1b299 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -391,12 +391,11 @@ function tryParseRawAnalysis(analysisJson) { } } -globalThis.regenerateAIAnalysis = function() { +globalThis.regenerateAIAnalysis = function(capturePromptIo = false) { const applicationId = $('#DetailsViewApplicationId').val(); const $button = $('#regenerateAiAnalysis'); const existingHtml = $button.html(); - const promptVersion = $('#aiAnalysisPromptVersion').val() || null; - const capturePromptIo = $('#aiAnalysisCapturePromptIo').is(':checked'); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; if (!applicationId || $button.prop('disabled')) { return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index bfb360ceff..2cb22c375a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -30,7 +30,7 @@ public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmen IScoresheetInstanceRepository scoresheetInstanceRepository, IApplicationRepository applicationRepository) : AbpViewComponent { - public async Task InvokeAsync(Guid assessmentId, Guid currentUserId, bool isDevPromptControlsEnabled = false, string? defaultPromptVersion = null) + public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) { if (assessmentId == Guid.Empty) { @@ -94,8 +94,6 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, - IsDevPromptControlsEnabled = isDevPromptControlsEnabled, - DefaultPromptVersion = string.IsNullOrWhiteSpace(defaultPromptVersion) ? "v1" : defaultPromptVersion.Trim().ToLowerInvariant(), }; return View(model); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs index d89998815f..7ddef85560 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs @@ -25,8 +25,6 @@ public class AssessmentScoresWidgetViewModel public Guid CurrentUserId { get; set; } public Guid AssessorId { get; set; } public ScoresheetDto? Scoresheet { get; set; } - public bool IsDevPromptControlsEnabled { get; set; } - public string DefaultPromptVersion { get; set; } = "v1"; public bool IsDisabled() { if(CurrentUserId != AssessorId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index 5a803da99a..ed9838bd29 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -42,39 +42,6 @@ - @if (Model.IsDevPromptControlsEnabled) - { -
-
- - -
-
- - -
-
-
-
-
-
Captured Prompt Input/Output
- -
- -
-
- } }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index bca2f5a644..18619afcea 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -577,12 +577,11 @@ function collapseAllAccordions(divId) { }); } -function regenerateAIScoresheetAnswers() { +function regenerateAIScoresheetAnswers(capturePromptIo = false) { const applicationId = $('#DetailsViewApplicationId').val(); const $button = $('#regenerateAiScoresheetBtn'); const existingHtml = $button.html(); - const promptVersion = $('#aiScoringPromptVersion').val() || null; - const capturePromptIo = $('#aiScoringCapturePromptIo').is(':checked'); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; if (!applicationId || $button.prop('disabled')) { return; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 0a88845130..420b499a85 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -27,16 +27,12 @@ public ChefsAttachments( _permissionChecker = permissionChecker; } - public async Task InvokeAsync(bool isDevPromptControlsEnabled = false, string? defaultPromptVersion = null) + public async Task InvokeAsync() { var isAIAttachmentSummariesEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") && await _permissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); ViewBag.IsAIAttachmentSummariesEnabled = isAIAttachmentSummariesEnabled; - ViewBag.IsDevPromptControlsEnabled = isDevPromptControlsEnabled; - ViewBag.DefaultPromptVersion = string.IsNullOrWhiteSpace(defaultPromptVersion) - ? "v1" - : defaultPromptVersion.Trim().ToLowerInvariant(); return View(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 1e2e68dbc8..0a97fbc9cd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -1,5 +1,9 @@ // Note: File depends on Unity.GrantManager.Web\Views\Shared\Components\_Shared\Attachments.js $(function () { + globalThis.generateAIAttachmentSummaries = function(capturePromptIo = false) { + $('#generateAiSummaries').data('capture-prompt-io', capturePromptIo).trigger('click'); + }; + const downloadAll = $('#downloadAll'); const dt = $('#ChefsAttachmentsTable'); let chefsDataTable; @@ -211,10 +215,12 @@ $(function () { $generateAISummariesButton.on('click', function () { const $button = $(this); const selectedRows = chefsDataTable.rows({ selected: true }).data(); - const promptVersion = $('#attachmentPromptVersion').val() || null; - const capturePromptIo = $('#attachmentCapturePromptIo').is(':checked'); + const promptVersion = globalThis.getSelectedPromptVersion?.() || null; + const capturePromptIo = $button.data('capture-prompt-io') === true; const applicationId = $('#DetailsViewApplicationId').val(); + $button.removeData('capture-prompt-io'); + if (selectedRows.length === 0) { abp.message.warn( 'Please select at least one attachment to generate summaries.' diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml index c4cfa070a5..6e56c53e13 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/Default.cshtml @@ -37,39 +37,6 @@ button-type="Light" /> *@
-@if (ViewBag.IsAIAttachmentSummariesEnabled && ViewBag.IsDevPromptControlsEnabled) -{ -
-
- - -
-
- - -
-
-
-
-
-
Captured Prompt Input/Output
- -
- -
-
-}
From 20af7f20a9461a9bc05970a45874e2be08d27d2f Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Thu, 12 Mar 2026 16:54:20 -0700 Subject: [PATCH 17/32] AB#31677 more codeQL --- .../ApplicantProfile/SubjectNormalizer.cs | 9 +++- .../GrantsPortalCommandConsumerService.cs | 43 ++++++++++++++++++- .../Handlers/ContactCreateHandler.cs | 5 ++- .../Messaging/InboxWorkerBase.cs | 32 ++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs index 179ccbb0b1..11c4d29b20 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubjectNormalizer.cs @@ -13,8 +13,13 @@ public static class SubjectNormalizer if (string.IsNullOrWhiteSpace(subject)) return null; - return subject.Contains('@') - ? subject[..subject.IndexOf('@')].ToUpperInvariant() + var atIndex = subject.IndexOf('@'); + + if (atIndex == 0) + return null; + + return atIndex > 0 + ? subject[..atIndex].ToUpperInvariant() : subject.ToUpperInvariant(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs index dcfa80a856..c4c9ed0f6a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -42,6 +42,7 @@ public class GrantsPortalCommandConsumerService( private const int MaxRetries = 5; private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); + private static readonly TimeSpan SlowRetryInterval = TimeSpan.FromSeconds(60); private CancellationToken _stoppingToken; protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -89,7 +90,47 @@ private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) } catch (Exception ex) { - logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts : {Ex}", MaxRetries, ex); + logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts. Entering slow reconnect loop (every {Interval}s).", + MaxRetries, SlowRetryInterval.TotalSeconds); + await SlowReconnectLoopAsync(cancellationToken); + } + } + } + + /// + /// Long-lived reconnect loop entered after the fast exponential-backoff retries are exhausted. + /// Retries at a fixed interval until the connection succeeds or the service is stopped. + /// This avoids leaving the pod alive-but-idle when the broker is down for an extended period. + /// + private async Task SlowReconnectLoopAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(SlowRetryInterval, cancellationToken); + + try + { + logger.LogInformation("Slow reconnect: attempting to connect to RabbitMQ..."); + CleanupConnection(); + + _connection = connectionFactory.CreateConnection(); + _connection.ConnectionShutdown += OnConnectionShutdown; + _channel = _connection.CreateModel(); + _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + DeclareTopology(); + StartConsuming(); + + logger.LogInformation("Slow reconnect: successfully reconnected. Listening on queue {Queue}", _options.InboundQueue); + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Shutting down — expected + } + catch (Exception ex) + { + logger.LogWarning(ex, "Slow reconnect: still unable to connect to RabbitMQ. Will retry in {Interval}s.", SlowRetryInterval.TotalSeconds); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs index 2a3a886006..1e618a100b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -27,6 +27,7 @@ public class ContactCreateHandler( public virtual async Task HandleAsync(PluginDataPayload payload) { var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); var innerData = payload.Data?.ToObject() ?? throw new ArgumentException("Contact data is required"); @@ -38,7 +39,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) return "Contact already exists"; } - logger.LogInformation("Creating contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + logger.LogInformation("Creating contact {ContactId} for profile {ProfileId}", contactId, profileId); var contact = new Contact { @@ -68,7 +69,7 @@ public virtual async Task HandleAsync(PluginDataPayload payload) { ContactId = contactId, RelatedEntityType = innerData.ContactType ?? "PORTAL", - RelatedEntityId = Guid.Parse(payload.ProfileId ?? Guid.Empty.ToString()), + RelatedEntityId = profileId, Role = innerData.Role, IsPrimary = innerData.IsPrimary, IsActive = true diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs index 07091e398d..b758652a65 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Messaging/InboxWorkerBase.cs @@ -48,6 +48,21 @@ public abstract class InboxWorkerBase : QuartzBackgroundWorkerBase { "AbpDbConcurrencyException", "The record was modified by another process. Please try again." } }; + /// + /// Exception types whose is safe to surface verbatim + /// in outbox acknowledgments. These are input/validation errors thrown by handlers + /// (e.g., missing required fields, malformed GUIDs, deserialization failures). + /// + private static readonly HashSet s_validationExceptionTypes = new(StringComparer.OrdinalIgnoreCase) + { + "ArgumentException", + "ArgumentNullException", + "FormatException", + "JsonException", + "JsonReaderException", + "JsonSerializationException" + }; + protected InboxWorkerBase(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; @@ -203,9 +218,26 @@ protected virtual string ToUserFriendlyMessage(Exception ex) return innerFriendly; } + // Validation / input errors — surface ex.Message so callers get actionable feedback + if (IsValidationException(ex)) + return ex.Message; + return "An unexpected error occurred while processing your request. Please try again or contact support."; } + /// + /// Returns true when the exception (or its inner exception) is a validation/input error + /// whose message is safe to include in outbox acknowledgments. + /// + private static bool IsValidationException(Exception ex) + { + if (s_validationExceptionTypes.Contains(ex.GetType().Name)) + return true; + + return ex.InnerException != null + && s_validationExceptionTypes.Contains(ex.InnerException.GetType().Name); + } + /// /// Determines if an error is transient (eligible for retry). Override to add integration-specific checks. /// From 4cc34a77b5e08691a812f0318e4301fd1fd8e729 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 16:56:57 -0700 Subject: [PATCH 18/32] AB#32297 polish dev tab prompt actions and section order --- .../Pages/GrantApplications/Details.cshtml | 30 +++++++++---------- .../Pages/GrantApplications/ai-analysis.js | 4 +-- .../AssessmentScoresWidget/Default.js | 4 +-- .../ChefsAttachments/ChefsAttachments.js | 18 +++++++---- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 697bb24cab..2be572329a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -492,55 +492,55 @@
-
Application Analysis
-
-
- - +
-
Assessment Scoring
-
-
- - +
-
Attachment Summaries
-
-
- - +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 5f52f1b299..be22ad8a12 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -391,9 +391,9 @@ function tryParseRawAnalysis(analysisJson) { } } -globalThis.regenerateAIAnalysis = function(capturePromptIo = false) { +globalThis.regenerateAIAnalysis = function(capturePromptIo = false, triggerButton = null) { const applicationId = $('#DetailsViewApplicationId').val(); - const $button = $('#regenerateAiAnalysis'); + const $button = triggerButton ? $(triggerButton) : $('#regenerateAiAnalysis'); const existingHtml = $button.html(); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 18619afcea..2309fc6abf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -577,9 +577,9 @@ function collapseAllAccordions(divId) { }); } -function regenerateAIScoresheetAnswers(capturePromptIo = false) { +function regenerateAIScoresheetAnswers(capturePromptIo = false, triggerButton = null) { const applicationId = $('#DetailsViewApplicationId').val(); - const $button = $('#regenerateAiScoresheetBtn'); + const $button = triggerButton ? $(triggerButton) : $('#regenerateAiScoresheetBtn'); const existingHtml = $button.html(); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 0a97fbc9cd..b010fd551c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -1,7 +1,10 @@ // Note: File depends on Unity.GrantManager.Web\Views\Shared\Components\_Shared\Attachments.js $(function () { - globalThis.generateAIAttachmentSummaries = function(capturePromptIo = false) { - $('#generateAiSummaries').data('capture-prompt-io', capturePromptIo).trigger('click'); + globalThis.generateAIAttachmentSummaries = function(capturePromptIo = false, triggerButton = null) { + $('#generateAiSummaries') + .data('capture-prompt-io', capturePromptIo) + .data('trigger-button', triggerButton || null) + .trigger('click'); }; const downloadAll = $('#downloadAll'); @@ -214,12 +217,15 @@ $(function () { $generateAISummariesButton.on('click', function () { const $button = $(this); + const triggerButton = $button.data('trigger-button'); + const $activeButton = triggerButton ? $(triggerButton) : $button; const selectedRows = chefsDataTable.rows({ selected: true }).data(); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; const capturePromptIo = $button.data('capture-prompt-io') === true; const applicationId = $('#DetailsViewApplicationId').val(); $button.removeData('capture-prompt-io'); + $button.removeData('trigger-button'); if (selectedRows.length === 0) { abp.message.warn( @@ -231,7 +237,7 @@ $(function () { // Get attachment IDs from selected rows const attachmentIds = selectedRows.toArray().map((row) => row.id); - const existingHTML = $button.html(); + const existingHTML = $activeButton.html(); if (!capturePromptIo && globalThis.hideAIPromptCapture) { globalThis.hideAIPromptCapture('#attachmentPromptCaptureContainer', '#attachmentPromptCaptureOutput'); @@ -249,7 +255,7 @@ $(function () { contentType: 'application/json', type: 'POST', beforeSend: function () { - $button + $activeButton .html( ' Generating...' ) @@ -280,14 +286,14 @@ $(function () { ); } - $button.html(existingHTML).prop('disabled', false); + $activeButton.html(existingHTML).prop('disabled', false); }, error: function (error) { console.error('Error generating AI summaries:', error); abp.notify.error( 'An error occurred while generating AI summaries. Please try again.' ); - $button.html(existingHTML).prop('disabled', false); + $activeButton.html(existingHTML).prop('disabled', false); }, }); }); From 30a2482a08d88bcc8b75de309bb60e318d8e3239 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 18:05:35 -0700 Subject: [PATCH 19/32] AB#32297 share prompt data payload shaping across AI flows --- .../AI/ApplicationAnalysisService.cs | 26 +--- .../ApplicationScoresheetAnalysisService.cs | 20 +-- .../AI/PromptDataPayloadBuilder.cs | 136 ++++++++++++++++++ .../Handlers/GenerateAIContentHandler.cs | 102 +------------ 4 files changed, 146 insertions(+), 138 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs index 7eb9badea4..7d38190c80 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs @@ -24,6 +24,10 @@ public class ApplicationAnalysisService( }; private const string ComponentsKey = "components"; + private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) + { + "applicantAgent" + }; public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { @@ -40,24 +44,6 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro }) .ToList(); - var notSpecified = "Not specified"; - var applicationContent = $@" -Project Name: {application.ProjectName} -Reference Number: {application.ReferenceNo} -Requested Amount: ${application.RequestedAmount:N2} -Total Project Budget: ${application.TotalProjectBudget:N2} -Project Summary: {application.ProjectSummary ?? "Not provided"} -City: {application.City ?? notSpecified} -Economic Region: {application.EconomicRegion ?? notSpecified} -Community: {application.Community ?? notSpecified} -Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} -Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} -Submission Date: {application.SubmissionDate.ToShortDateString()} - -FULL APPLICATION FORM SUBMISSION: -{formSubmission?.RenderedHTML ?? "Form submission content not available"} -"; - object formFieldConfiguration = new { message = "Form configuration not available." }; if (formSubmission?.ApplicationFormVersionId != null) { @@ -67,7 +53,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest { Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), - Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), + Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, logger), Attachments = attachmentSummaries, PromptVersion = promptVersion, CapturePromptIo = capturePromptIo, @@ -123,7 +109,7 @@ private static void ExtractFieldRequirements(JArray components, List req var type = component["type"]?.ToString(); var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; - if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type)) + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) { ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); continue; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs index 0b9fd03bef..d7037bbb4a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs @@ -56,23 +56,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro .ToList(); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); - var notSpecified = "Not specified"; - var applicationContent = $@" -Project Name: {application.ProjectName} -Reference Number: {application.ReferenceNo} -Requested Amount: ${application.RequestedAmount:N2} -Total Project Budget: ${application.TotalProjectBudget:N2} -Project Summary: {application.ProjectSummary ?? "Not provided"} -City: {application.City ?? notSpecified} -Economic Region: {application.EconomicRegion ?? notSpecified} -Community: {application.Community ?? notSpecified} -Project Start Date: {application.ProjectStartDate?.ToShortDateString() ?? notSpecified} -Project End Date: {application.ProjectEndDate?.ToShortDateString() ?? notSpecified} -Submission Date: {application.SubmissionDate.ToShortDateString()} - -FULL APPLICATION FORM SUBMISSION: -{formSubmission?.RenderedHTML ?? "Form submission content not available"} -"; + var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, logger); var allSectionResults = new Dictionary(); foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) @@ -96,7 +80,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var sectionRequest = new ScoresheetSectionRequest { - Data = JsonSerializer.SerializeToElement(new { submission_content = applicationContent }), + Data = promptData, Attachments = attachmentSummaries, SectionName = section.Name, SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions), diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs new file mode 100644 index 0000000000..b0a3e2a5d9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Text.Json; +using Unity.GrantManager.Applications; + +namespace Unity.GrantManager.AI +{ + internal static class PromptDataPayloadBuilder + { + private static readonly string[] ExcludedPromptDataKeys = + { + "simplefile", + "applicantAgent", + "submit", + "lateEntry", + "metadata", + "full_application_form_submission", + "files", + "file", + "attachments" + }; + + public static JsonElement BuildPromptDataPayload( + Application application, + ApplicationFormSubmission? formSubmission, + ILogger logger) + { + var fallbackPayload = BuildFallbackPromptDataPayload(application); + if (TryBuildPromptDataValues(formSubmission?.Submission, out var values, out var exception)) + { + return JsonSerializer.SerializeToElement(values); + } + + if (exception != null) + { + logger.LogWarning( + exception, + "Failed to parse form submission JSON for prompt payload generation for application {ApplicationId}.", + application.Id); + } + + return JsonSerializer.SerializeToElement(fallbackPayload); + } + + private static object BuildFallbackPromptDataPayload(Application application) + { + var notSpecified = "Not specified"; + return new + { + project_name = application.ProjectName, + reference_number = application.ReferenceNo, + requested_amount = application.RequestedAmount, + total_project_budget = application.TotalProjectBudget, + project_summary = application.ProjectSummary ?? "Not provided", + city = application.City ?? notSpecified, + economic_region = application.EconomicRegion ?? notSpecified, + community = application.Community ?? notSpecified, + project_start_date = application.ProjectStartDate, + project_end_date = application.ProjectEndDate, + submission_date = application.SubmissionDate + }; + } + + private static bool TryBuildPromptDataValues( + string? submissionJson, + out Dictionary values, + out Exception? exception) + { + values = new Dictionary(StringComparer.OrdinalIgnoreCase); + exception = null; + + if (string.IsNullOrWhiteSpace(submissionJson)) + { + return false; + } + + try + { + using var submissionDoc = JsonDocument.Parse(submissionJson); + if (!TryExtractSubmissionDataObject(submissionDoc.RootElement, out var submissionData)) + { + return false; + } + + values = BuildPromptDataValues(submissionData); + return true; + } + catch (Exception ex) + { + exception = ex; + return false; + } + } + + private static bool TryExtractSubmissionDataObject(JsonElement root, out JsonElement submissionData) + { + submissionData = root; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Object) + { + submissionData = dataElement; + return true; + } + + if (root.TryGetProperty("submission", out var submissionElement) && + submissionElement.ValueKind == JsonValueKind.Object && + submissionElement.TryGetProperty("data", out var nestedDataElement) && + nestedDataElement.ValueKind == JsonValueKind.Object) + { + submissionData = nestedDataElement; + return true; + } + + return true; + } + + private static Dictionary BuildPromptDataValues(JsonElement submissionData) + { + var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? + new Dictionary(); + var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); + + foreach (var excludedKey in ExcludedPromptDataKeys) + { + values.Remove(excludedKey); + } + + return values; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index a4d07dd2aa..d0f00b53a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -287,7 +287,7 @@ private async Task GenerateApplicationAnalysisAsync(Application application, Lis application.Id); } - var analysisData = BuildPromptDataPayload(application, formSubmission); + var analysisData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, _logger); _logger.LogInformation("Generating analysis for application {ApplicationId}", application.Id); _logger.LogDebug("Generating AI analysis for application {ApplicationId} with {AttachmentCount} attachment summaries", @@ -333,25 +333,6 @@ private static List BuildAnalysisAttachments(List attachments) { try @@ -400,7 +381,7 @@ private async Task GenerateScoresheetAnalysisAsync(Application application, List var allSectionResults = new Dictionary(); var scoresheetAttachments = BuildScoresheetAttachments(attachments); var formSubmission = await _applicationFormSubmissionRepository.GetByApplicationAsync(application.Id); - var scoresheetData = BuildPromptDataPayload(application, formSubmission); + var scoresheetData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, _logger); LogFormSubmissionPreview(formSubmission?.RenderedHTML); foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) @@ -443,85 +424,6 @@ private static List BuildScoresheetAttachments(List values) - { - values = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrWhiteSpace(formSubmission?.Submission)) - { - return false; - } - - try - { - using var submissionDoc = JsonDocument.Parse(formSubmission.Submission); - if (!TryExtractSubmissionDataObject(submissionDoc.RootElement, out var submissionData)) - { - return false; - } - - values = BuildPromptDataValues(submissionData); - return true; - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "Failed to parse form submission JSON for prompt payload generation for application {ApplicationId}.", - applicationId); - return false; - } - } - - private static bool TryExtractSubmissionDataObject(JsonElement root, out JsonElement submissionData) - { - submissionData = root; - if (root.ValueKind != JsonValueKind.Object) - { - return false; - } - - if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Object) - { - submissionData = dataElement; - return true; - } - - if (root.TryGetProperty("submission", out var submissionElement) && - submissionElement.ValueKind == JsonValueKind.Object && - submissionElement.TryGetProperty("data", out var nestedDataElement) && - nestedDataElement.ValueKind == JsonValueKind.Object) - { - submissionData = nestedDataElement; - return true; - } - - return root.ValueKind == JsonValueKind.Object; - } - - private static Dictionary BuildPromptDataValues(JsonElement submissionData) - { - var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? - new Dictionary(); - var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); - - foreach (var excludedKey in ExcludedPromptDataKeys) - { - values.Remove(excludedKey); - } - - return values; - } - private void LogFormSubmissionPreview(string? renderedFormHtml) { _logger.LogInformation("Form submission HTML length: {HtmlLength} characters", renderedFormHtml?.Length ?? 0); From 9441a67a3f94216110a20b4c84e04254e5b1ebfd Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 18:05:45 -0700 Subject: [PATCH 20/32] AB#32297 tighten and consolidate v1 prompt rules --- .../src/Unity.GrantManager.Application/AI/OpenAIService.cs | 2 +- .../AI/Prompts/Versions/v1/analysis.rules.txt | 3 +++ .../AI/Prompts/Versions/v1/attachment.rules.txt | 6 ++---- .../AI/Prompts/Versions/v1/scoresheet.rules.txt | 7 +++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 5869449641..bdc4d248e5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -927,7 +927,7 @@ private static string RenderPromptTemplateInternal( version, fragmentTemplateName, new Dictionary(StringComparer.Ordinal), - resolutionStack); + resolutionStack).TrimEnd(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt index 124a84a4e2..dbaafa9e4e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt @@ -5,10 +5,13 @@ - Use 3-6 words for title. - Each detail must be 1-2 complete sentences. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. +- Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. - If ATTACHMENTS evidence is used, cite the attachment by name in detail. +- Summaries and nextSteps must be concrete, distinct, and reviewer-relevant; avoid generic praise, generic checklist language, and repeated conclusions across lists. - If no findings exist, return empty arrays. - Rating must be HIGH, MEDIUM, or LOW. - Use summaries for overall application quality/readiness synthesis. - Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. +- Only include nextSteps when there is a specific evidence gap, inconsistency, or verification need; otherwise return an empty array. - recommendation.decision must be PROCEED or HOLD. - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt index 63427ac0e5..0daf08fd98 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt @@ -1,7 +1,5 @@ -- Use only ATTACHMENT as evidence. -- If ATTACHMENT.text is present, summarize actual content. -- If ATTACHMENT.text is null or empty, provide a conservative file-level summary. -- Do not invent missing details. +- Use only ATTACHMENT as evidence: summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary, and do not invent missing details. +- Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. - Write 1-2 complete sentences. - Summary must be grounded in concrete ATTACHMENT evidence. - Return exactly one object with only the key: summary. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index d1269a700a..2333f2083e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -8,11 +8,10 @@ - Each answer object must include: "answer", "rationale", and "confidence". - Never omit "answer", "rationale", or "confidence" for any question type. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. -- The "rationale" field must be 1-2 complete sentences and grounded in concrete DATA/ATTACHMENTS evidence. -- In "rationale", cite concrete source evidence from the provided input content; do not cite prompt section headers. +- The "rationale" field must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence, and must cite concrete source evidence from the provided input content rather than prompt section headers. - For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. - If explicit evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. -- Do not treat missing or non-contradictory information as evidence. +- Do not infer unsupported claims from partial, indirect, missing, or non-contradictory evidence. - The "confidence" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. - Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. - For yes/no questions, the "answer" field must be exactly "Yes" or "No". @@ -25,7 +24,7 @@ - Never return 0 for select list answers unless 0 exists as an explicit option number. - For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. - For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. -- For comment fields, summarize key evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable. +- For comment fields, summarize only evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable; keep comment strength aligned with those answers, and avoid adding recommendations or stronger conclusions not explicitly requested by the question. - Do not leave rationale empty when answer is populated. - Final self-check before responding: every question ID in RESPONSE must have a non-empty "answer", non-empty "rationale", and "confidence". - If any answer object is incomplete, regenerate the full JSON response before returning it. From 91d7442f55c402b5bf6399e941ccdaf9557a9d5a Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Thu, 12 Mar 2026 18:05:52 -0700 Subject: [PATCH 21/32] AB#32297 polish dev prompt capture UI and actions --- .../Pages/GrantApplications/Details.cshtml | 24 ++++++++++++------- .../Pages/GrantApplications/Details.css | 16 ++++++++++--- .../Pages/GrantApplications/ai-analysis.js | 2 +- .../AssessmentScoresWidget/Default.js | 2 +- .../ChefsAttachments/ChefsAttachments.js | 13 ++++++---- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 2be572329a..f4e2f2278b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -501,9 +501,11 @@
- +
+ +
@@ -519,9 +521,11 @@
- +
+ +
@@ -537,9 +541,11 @@
- +
+ +
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index d7e53c1fd6..ce6bb2eb3b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -86,11 +86,20 @@ position: relative; } -.ai-prompt-capture-container .ai-prompt-capture-copy-btn { +.ai-prompt-capture-actions { position: absolute; - top: 0.5rem; + top: 0.375rem; right: 0.5rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; z-index: 1; + padding: 0 0.25rem; + background: #ffffff; + border-radius: 999px; +} + +.ai-prompt-capture-actions .btn-icon { width: 2rem; height: 2rem; padding: 0; @@ -146,7 +155,8 @@ .dev-prompt-section .ai-prompt-capture-output { min-height: 4.5rem; max-height: 16rem; - padding-top: 2.25rem; + padding-top: 0.5rem; + padding-right: 2.5rem; } .left-card { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index be22ad8a12..af42ccb7ab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -406,7 +406,7 @@ globalThis.regenerateAIAnalysis = function(capturePromptIo = false, triggerButto } $button - .html(' Refreshing Analysis...') + .html(' Generating...') .prop('disabled', true); unity.grantManager.grantApplications.applicationAIAnalysis diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 2309fc6abf..69ef64dba5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -593,7 +593,7 @@ function regenerateAIScoresheetAnswers(capturePromptIo = false, triggerButton = $button .html( - 'Refreshing Scoring...' + ' Generating...' ) .prop('disabled', true); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index b010fd551c..1c4b7f9329 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -219,7 +219,9 @@ $(function () { const $button = $(this); const triggerButton = $button.data('trigger-button'); const $activeButton = triggerButton ? $(triggerButton) : $button; - const selectedRows = chefsDataTable.rows({ selected: true }).data(); + const rowsToProcess = triggerButton + ? chefsDataTable.rows().data() + : chefsDataTable.rows({ selected: true }).data(); const promptVersion = globalThis.getSelectedPromptVersion?.() || null; const capturePromptIo = $button.data('capture-prompt-io') === true; const applicationId = $('#DetailsViewApplicationId').val(); @@ -227,15 +229,16 @@ $(function () { $button.removeData('capture-prompt-io'); $button.removeData('trigger-button'); - if (selectedRows.length === 0) { + if (rowsToProcess.length === 0) { abp.message.warn( - 'Please select at least one attachment to generate summaries.' + triggerButton + ? 'No attachments were found to generate summaries for.' + : 'Please select at least one attachment to generate summaries.' ); return; } - // Get attachment IDs from selected rows - const attachmentIds = selectedRows.toArray().map((row) => row.id); + const attachmentIds = rowsToProcess.toArray().map((row) => row.id); const existingHTML = $activeButton.html(); From e28d1481ad8d05c59ad65e240a6793057aa53f52 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 13 Mar 2026 12:11:06 -0700 Subject: [PATCH 22/32] AB#32297 Filter AI prompt data by form schema --- .../AI/ApplicationAnalysisService.cs | 22 ++++- .../ApplicationScoresheetAnalysisService.cs | 24 ++++- .../AI/PromptDataPayloadBuilder.cs | 96 ++++++++++++++++++- .../Handlers/GenerateAIContentHandler.cs | 25 ++++- 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs index 7d38190c80..4b633cfd82 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationAnalysisService.cs @@ -34,6 +34,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var application = await applicationRepository.GetAsync(applicationId); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); var attachmentSummaries = attachments .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) @@ -53,7 +54,7 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest { Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), - Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, logger), + Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger), Attachments = attachmentSummaries, PromptVersion = promptVersion, CapturePromptIo = capturePromptIo, @@ -66,6 +67,25 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro return analysisJson; } + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) { try diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs index d7037bbb4a..82b7c12ae4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/ApplicationScoresheetAnalysisService.cs @@ -14,6 +14,7 @@ public class ApplicationScoresheetAnalysisService( IApplicationRepository applicationRepository, IApplicationFormRepository applicationFormRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IScoresheetRepository scoresheetRepository, IAIService aiService, @@ -56,7 +57,8 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro .ToList(); var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); - var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, logger); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger); var allSectionResults = new Dictionary(); foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) @@ -110,10 +112,28 @@ public async Task RegenerateAndSaveAsync(Guid applicationId, string? pro var validatedJson = ValidateScoresheetJson(combinedResults); application.AIScoresheetAnswers = validatedJson; await applicationRepository.UpdateAsync(application); - return validatedJson; } + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for scoresheet prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + private static string ValidateScoresheetJson(string scoresheetAnswers) { try diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs index b0a3e2a5d9..8bea35cab0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using Unity.GrantManager.Applications; @@ -21,13 +23,24 @@ internal static class PromptDataPayloadBuilder "attachments" }; + private static readonly HashSet NonDataComponentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "button", + "simplebuttonadvanced", + "html", + "htmlelement", + "content", + "simpleseparator" + }; + public static JsonElement BuildPromptDataPayload( Application application, ApplicationFormSubmission? formSubmission, + string? formSchema, ILogger logger) { var fallbackPayload = BuildFallbackPromptDataPayload(application); - if (TryBuildPromptDataValues(formSubmission?.Submission, out var values, out var exception)) + if (TryBuildPromptDataValues(formSubmission?.Submission, formSchema, out var values, out var exception)) { return JsonSerializer.SerializeToElement(values); } @@ -64,6 +77,7 @@ private static object BuildFallbackPromptDataPayload(Application application) private static bool TryBuildPromptDataValues( string? submissionJson, + string? formSchema, out Dictionary values, out Exception? exception) { @@ -83,7 +97,7 @@ private static bool TryBuildPromptDataValues( return false; } - values = BuildPromptDataValues(submissionData); + values = BuildPromptDataValues(submissionData, formSchema); return true; } catch (Exception ex) @@ -119,18 +133,94 @@ private static bool TryExtractSubmissionDataObject(JsonElement root, out JsonEle return true; } - private static Dictionary BuildPromptDataValues(JsonElement submissionData) + private static Dictionary BuildPromptDataValues(JsonElement submissionData, string? formSchema) { var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? new Dictionary(); var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); + var allowedSchemaKeys = ExtractAllowedSchemaKeys(formSchema); foreach (var excludedKey in ExcludedPromptDataKeys) { values.Remove(excludedKey); } + if (allowedSchemaKeys.Count > 0) + { + foreach (var key in values.Keys.ToList()) + { + if (!allowedSchemaKeys.Contains(key)) + { + values.Remove(key); + } + } + } + return values; } + + private static HashSet ExtractAllowedSchemaKeys(string? formSchema) + { + if (string.IsNullOrWhiteSpace(formSchema)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + try + { + var schema = JObject.Parse(formSchema); + if (schema["components"] is not JArray components) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + ExtractSchemaKeys(components, keys); + return keys; + } + catch + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private static void ExtractSchemaKeys(JArray components, HashSet keys) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var type = component["type"]?.ToString(); + var isInput = component["input"]?.Value() == true; + + if (!string.IsNullOrWhiteSpace(key) && + !string.IsNullOrWhiteSpace(type) && + !NonDataComponentTypes.Contains(type) && + isInput) + { + keys.Add(key); + } + + ProcessNestedSchemaComponents(component, keys); + } + } + + private static void ProcessNestedSchemaComponents(JObject component, HashSet keys) + { + if (component["components"] is JArray nestedComponents) + { + ExtractSchemaKeys(nestedComponents, keys); + } + + if (component["columns"] is JArray columns) + { + foreach (var column in columns.OfType()) + { + if (column["components"] is JArray columnComponents) + { + ExtractSchemaKeys(columnComponents, keys); + } + } + } + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index d0f00b53a7..bab7b490e8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -287,7 +287,8 @@ private async Task GenerateApplicationAnalysisAsync(Application application, Lis application.Id); } - var analysisData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, _logger); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var analysisData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, _logger); _logger.LogInformation("Generating analysis for application {ApplicationId}", application.Id); _logger.LogDebug("Generating AI analysis for application {ApplicationId} with {AttachmentCount} attachment summaries", @@ -381,7 +382,8 @@ private async Task GenerateScoresheetAnalysisAsync(Application application, List var allSectionResults = new Dictionary(); var scoresheetAttachments = BuildScoresheetAttachments(attachments); var formSubmission = await _applicationFormSubmissionRepository.GetByApplicationAsync(application.Id); - var scoresheetData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, _logger); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var scoresheetData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, _logger); LogFormSubmissionPreview(formSubmission?.RenderedHTML); foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) @@ -639,6 +641,25 @@ private async Task ExtractFormFieldConfigurationSchemaAsync(Guid fo } } + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await _applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + private static JsonElement BuildEmptyFormFieldSchema() { return JsonSerializer.SerializeToElement(new Dictionary()); From 290e4d323e951eca38a0bbfaa40a2d63846ffaf9 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 13 Mar 2026 12:11:06 -0700 Subject: [PATCH 23/32] AB#32297 Refine v1 AI prompt instructions --- .../AI/Prompts/Versions/v1/analysis.rules.txt | 17 +++++++++- .../Prompts/Versions/v1/analysis.system.txt | 9 +++-- .../Prompts/Versions/v1/attachment.rules.txt | 7 +++- .../Prompts/Versions/v1/attachment.system.txt | 9 +++-- .../Prompts/Versions/v1/scoresheet.rules.txt | 33 ++++++++++++++++--- .../Prompts/Versions/v1/scoresheet.system.txt | 11 +++++-- 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt index dbaafa9e4e..b13f42bddb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt @@ -2,16 +2,31 @@ - Do not invent fields, documents, requirements, or facts. - Treat missing or empty values as findings only when they weaken rubric evidence. - Prefer material issues; avoid nitpicking. +- Ignore evidence that is not relevant to a reviewer-facing conclusion. +- Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. +- Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. +- Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. - Use 3-6 words for title. +- Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. +- When citing a positive conclusion, explain why that evidence matters for readiness, feasibility, or funding confidence. +- Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. +- Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. +- Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. +- Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. - If ATTACHMENTS evidence is used, cite the attachment by name in detail. -- Summaries and nextSteps must be concrete, distinct, and reviewer-relevant; avoid generic praise, generic checklist language, and repeated conclusions across lists. +- Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. +- Avoid generic praise, generic checklist language, and repeated conclusions across lists. +- Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. +- Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - If no findings exist, return empty arrays. - Rating must be HIGH, MEDIUM, or LOW. - Use summaries for overall application quality/readiness synthesis. - Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. - Only include nextSteps when there is a specific evidence gap, inconsistency, or verification need; otherwise return an empty array. - recommendation.decision must be PROCEED or HOLD. +- Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. +- recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt index 26953aa2f3..a35dd58acf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt @@ -1,5 +1,10 @@ ROLE -You are an expert grant analyst assistant for human reviewers. +You are a careful grant analyst assistant for human reviewers. You do not fill gaps or turn weak signals into strong reviewer conclusions. TASK -Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES, return review findings. +Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: +1. Identify the strongest reviewer-relevant evidence in the application and attachments. +2. Determine which conclusions are directly supported by that evidence. +3. Exclude weak, repetitive, or loosely supported conclusions. +4. Before finalizing each conclusion, ask whether the evidence directly supports it and whether a more neutral description would be more accurate. +5. Return only the strongest evidence-backed reviewer conclusions. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt index 0daf08fd98..8008ef059a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt @@ -1,4 +1,9 @@ -- Use only ATTACHMENT as evidence: summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary, and do not invent missing details. +- Use only ATTACHMENT as evidence. +- Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. +- Ignore attachment details that are not relevant to describing what the file contains or contributes. +- Describe the attachment itself, including its apparent function or content type when supported by the evidence, rather than summarizing the overall project. +- If ATTACHMENT.text is primarily structured application, contact, organization, budget, or date fields, summarize it as a metadata-style attachment rather than rewriting it as a generic project summary. +- Do not invent missing details. - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. - Write 1-2 complete sentences. - Summary must be grounded in concrete ATTACHMENT evidence. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt index cb59d46c27..525825366e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt @@ -1,5 +1,10 @@ ROLE -You are a professional grant analyst for the BC Government. +You are a careful grant analyst assistant for human reviewers. You do not fill gaps or summarize the overall project when the attachment itself is the evidence. TASK -Produce a concise reviewer-facing summary of the provided attachment context. +Using ATTACHMENT, OUTPUT, and RULES: +1. Identify what the attachment contains. +2. Determine what type of attachment it appears to be, when the evidence supports that. +3. Summarize only the attachment-specific content or evidence it provides. +4. Before finalizing the summary, check that it describes the attachment itself and not the overall project. +5. Return a concise reviewer-facing summary. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index 2333f2083e..bbdaaf55c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -3,17 +3,25 @@ - Return exactly one answer object per question ID in SECTION.questions. - Do not omit any question IDs from SECTION.questions. - Do not add keys that are not question IDs from SECTION.questions. +- Use the exact question IDs from RESPONSE and SECTION.questions without alteration; never rewrite, normalize, or regenerate a question ID. - Use RESPONSE as the output contract and fill every placeholder value. -- Follow this process in order: (1) copy RESPONSE, (2) iterate SECTION.questions in order, (3) fill answer+rationale+confidence for each matching question ID, (4) run final completeness check. - Each answer object must include: "answer", "rationale", and "confidence". - Never omit "answer", "rationale", or "confidence" for any question type. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. -- The "rationale" field must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence, and must cite concrete source evidence from the provided input content rather than prompt section headers. +- The "rationale" field must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence. +- In rationale, cite concrete source evidence from the provided input content rather than prompt section headers. - For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. -- If explicit evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. -- Do not infer unsupported claims from partial, indirect, missing, or non-contradictory evidence. +- If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. +- Ignore fields or details that are not relevant to the specific question being answered. +- Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. +- Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. +- Treat prefilled labels, ratings, rankings, or statuses in DATA as background context only; do not use them as evidence unless the question explicitly asks you to report that same item. +- Do not use one field's prior classification, rating, or judgment as evidence for a different question unless the question explicitly asks for that same classification, rating, or judgment. +- Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. +- Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. - The "confidence" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. - Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. +- Do not use maximum or near-maximum confidence when the answer depends on inference rather than an explicit statement of the exact condition. - For yes/no questions, the "answer" field must be exactly "Yes" or "No". - For numeric questions, answer must be a numeric value within the allowed range. - For numeric questions, answer must never be blank. @@ -21,10 +29,25 @@ - If a required value is explicitly missing in DATA/ATTACHMENTS, set confidence high (80-100) when selecting the conservative minimum. - For select list questions, use the matching SECTION.questions[].options entries and return only the selected options[].number as a string. - For select list questions, the "answer" value must be one of the matching question.allowed_answers values exactly. +- For select list questions, return only the option number string, never the option label text such as "Yes", "No", or "N/A". - Never return 0 for select list answers unless 0 exists as an explicit option number. +- For select list questions, choose the lowest option fully supported by the evidence; use a higher option only when the specific condition and required strength are directly supported. +- If evidence supports the existence of a topic but not the required strength, completeness, or specificity, choose the lowest option consistent with that evidence. +- If evidence is insufficient for a select list question, choose the lowest allowed answer value from question.allowed_answers and explain the uncertainty. +- Do not treat broad project descriptions, general goals, high-level timelines, budget presence, or a single indirect reference as sufficient evidence for a higher-scored select-list answer. +- Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. +- If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. +- If a question asks whether something is eligible, complete, appropriate, or satisfied, require direct evidence of that exact condition rather than general relevance or presence. - For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. - For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. -- For comment fields, summarize only evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable; keep comment strength aligned with those answers, and avoid adding recommendations or stronger conclusions not explicitly requested by the question. +- If no concerns are identified for a text or text area question, return a short non-empty evidence-based comment rather than leaving answer blank. +- For comment fields, summarize only the evidence-based conclusions supported by the scored answers, including uncertainty where applicable, and do not introduce stronger claims. +- For narrative comment fields, keep wording aligned with the scored answers and evidence; do not use stronger certainty or impact language than the evidence supports. +- For comment fields, describe the evidence and resulting answer without elevating it into an overall assessment unless the question explicitly asks for one. +- Do not treat the presence of a named person, partner, organization, location, document, or field as proof of a separate requirement, condition, or relationship unless that exact point is explicitly evidenced. +- Do not add recommendations or stronger conclusions unless the question explicitly asks for them. +- For comment fields, always provide a concise evidence-based summary even when no concerns are identified. +- For comment fields, do not leave answer empty even when all other answers are positive. - Do not leave rationale empty when answer is populated. - Final self-check before responding: every question ID in RESPONSE must have a non-empty "answer", non-empty "rationale", and "confidence". - If any answer object is incomplete, regenerate the full JSON response before returning it. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt index 3b180cb289..855286f824 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt @@ -1,5 +1,12 @@ ROLE -You are an expert grant application reviewer for the BC Government. +You are a careful grant review assistant for human reviewers. You do not fill gaps, assume compliance, or treat relevance as proof. TASK -Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES, answer only the questions in SECTION. +Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: +1. Review each question in SECTION one at a time. +2. Identify the exact condition the question asks about. +3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. +4. Choose the most conservative valid answer supported by that evidence. +5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. +6. Before finalizing each answer, ask: "What exact evidence supports this condition?" If no direct evidence exists, choose the most conservative valid answer. +7. Repeat for every question in SECTION. From f6f6205e52e776e7350b8a6c39898958863a256b Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 13 Mar 2026 12:11:07 -0700 Subject: [PATCH 24/32] AB#32297 Fix AI button loading spacing --- .../Pages/GrantApplications/ai-analysis.js | 2 +- .../Views/Shared/Components/AssessmentScoresWidget/Default.js | 2 +- .../Shared/Components/ChefsAttachments/ChefsAttachments.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index af42ccb7ab..3c16d8b86a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -406,7 +406,7 @@ globalThis.regenerateAIAnalysis = function(capturePromptIo = false, triggerButto } $button - .html(' Generating...') + .html('Generating...') .prop('disabled', true); unity.grantManager.grantApplications.applicationAIAnalysis diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index 69ef64dba5..992aeefffa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -593,7 +593,7 @@ function regenerateAIScoresheetAnswers(capturePromptIo = false, triggerButton = $button .html( - ' Generating...' + 'Generating...' ) .prop('disabled', true); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 1c4b7f9329..e9d1d60177 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -260,7 +260,7 @@ $(function () { beforeSend: function () { $activeButton .html( - ' Generating...' + 'Generating...' ) .prop('disabled', true); }, From c68fa535d195528a5acfbc80d260aea3f9a00e3c Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 13 Mar 2026 12:20:02 -0700 Subject: [PATCH 25/32] AB#32297 Update AI action labels and indicator color --- .../Pages/GrantApplications/Details.cshtml | 2 +- .../Unity.GrantManager.Web/Pages/GrantApplications/Details.css | 2 +- .../Shared/Components/AssessmentScoresWidget/Default.cshtml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index f4e2f2278b..860a412d4f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -409,7 +409,7 @@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css index ce6bb2eb3b..8b16531fd1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.css @@ -726,7 +726,7 @@ form label.error { } .ai-analysis-tab-indicator.hold { - background-color: #ffc107; + background-color: #dc3545; } .ai-analysis-action-btn { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index ed9838bd29..ab766c0069 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -25,7 +25,7 @@