using PlotLine.Data; using PlotLine.Models; namespace PlotLine.Services; public interface IStoryIntelligenceEntitlementService { Task EnsureCanQueueAsync(int userId); Task EnsureCanExecuteAsync(int userId); } public sealed class StoryIntelligenceEntitlementService(ISubscriptionService subscriptions) : IStoryIntelligenceEntitlementService { public Task EnsureCanQueueAsync(int userId) => EnsureAllowedAsync(userId); public Task EnsureCanExecuteAsync(int userId) => EnsureAllowedAsync(userId); private async Task EnsureAllowedAsync(int userId) { var subscription = await subscriptions.GetCurrentSubscriptionAsync(userId); if (!IsActive(subscription, DateTime.UtcNow)) { throw new SubscriptionLimitException("Story Intelligence requires an active subscription before paid AI work can be queued or processed."); } } private static bool IsActive(UserSubscriptionDetail? subscription, DateTime utcNow) { if (subscription is null || !subscription.IsActive || !subscription.SubscriptionLevelIsActive) { return false; } if (subscription.EndDateUTC.HasValue && subscription.EndDateUTC.Value <= utcNow) { return false; } if (subscription.CurrentPeriodEndUTC.HasValue && subscription.CurrentPeriodEndUTC.Value <= utcNow && !string.IsNullOrWhiteSpace(subscription.StripeSubscriptionId)) { return false; } var status = (subscription.StripeStatus ?? string.Empty).Trim().ToLowerInvariant(); return status is not ("canceled" or "cancelled" or "incomplete_expired" or "ended" or "expired" or "inactive"); } } public interface IStoryIntelligenceCompletionNotificationService { Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default); } public sealed class StoryIntelligenceCompletionNotificationService( IStoryIntelligencePipelineRepository pipelines, IUserRepository users, IEmailService emails, ILogger logger) : IStoryIntelligenceCompletionNotificationService { public async Task NotifyIfImportTerminalAsync(StoryIntelligenceQueuedRun run, CancellationToken cancellationToken = default) { if (run.BookID is not int bookId) { return; } var target = await pipelines.TryBeginCompletionNotificationAsync(bookId, run.UserID); if (target is null) { return; } var user = await users.GetByIdAsync(target.UserID); if (user is null || string.IsNullOrWhiteSpace(user.Email)) { logger.LogWarning("Story Intelligence import notification skipped because user {UserID} has no email target.", target.UserID); return; } await emails.SendStoryIntelligenceImportNotificationAsync(user.Email, user.DisplayName, target); logger.LogInformation( "Queued Story Intelligence import {NotificationKind} notification for import session {ImportSessionID}, book {BookID}, user {UserID}.", target.NotificationKind, target.ImportSessionID, target.BookID, target.UserID); } }