diff --git a/MyApp.ServiceInterface/App/AppDbPeriodicTasksCommand.cs b/MyApp.ServiceInterface/App/AppDbPeriodicTasksCommand.cs new file mode 100644 index 0000000..dac9033 --- /dev/null +++ b/MyApp.ServiceInterface/App/AppDbPeriodicTasksCommand.cs @@ -0,0 +1,191 @@ +using System.Data; +using CreatorKit.ServiceInterface; +using CreatorKit.ServiceModel; +using CreatorKit.ServiceModel.Types; +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.Messaging; +using ServiceStack.OrmLite; +using Microsoft.Extensions.Logging; +using MyApp.Data; +using ServiceStack.Data; +using ServiceStack.Script; + +namespace MyApp.ServiceInterface.App; + +public class AppDbPeriodicTasksCommand(ILogger log, + AppConfig appConfig, IDbConnectionFactory dbFactory, IMessageProducer mq, EmailRenderer renderer) + : IAsyncCommand +{ + public async Task ExecuteAsync(PeriodicTasks request) + { + log.LogInformation("Executing {Type} {PeriodicFrequency} PeriodicTasks...", GetType().Name, + request.PeriodicFrequency); + + await SendWatchedTagEmails(); + } + + private async Task SendWatchedTagEmails() + { + var yesterday = DateTime.UtcNow.AddDays(-1).Date; + var day = yesterday.ToString("yyyy-MM-dd"); + using var db = await dbFactory.OpenDbConnectionAsync(); + if (await db.ExistsAsync(db.From().Where(x => x.Date == day))) + return; + + var newPosts = await db.SelectAsync(db.From().Where(x => + x.CreationDate >= yesterday && x.CreationDate < yesterday.AddDays(1))); + if (newPosts.Count == 0) + { + log.LogInformation("No new posts found for {Date}", day); + return; + } + + var tagGroups = new Dictionary>(); + foreach (var post in newPosts) + { + foreach (var tag in post.Tags) + { + if (!tagGroups.TryGetValue(tag, out var posts)) + tagGroups[tag] = posts = new List(); + posts.Add(post); + } + } + + var uniqueTags = tagGroups.Keys.ToSet(); + var watchTags = await db.SelectAsync(db.From().Where(x => uniqueTags.Contains(x.Tag))); + if (watchTags.Count == 0) + { + log.LogInformation("No Tag Watchers found for {Date}", day); + return; + } + + var uniqueUserNames = watchTags.Select(x => x.UserName).ToSet(); + var users = await db.SelectAsync(x => uniqueUserNames.Contains(x.UserName!)); + + using var dbCreatorKit = await dbFactory.OpenDbConnectionAsync(Databases.CreatorKit); + + var mailRuns = 0; + var orderedTags = uniqueTags.OrderBy(x => x).ToList(); + foreach (var tag in orderedTags) + { + if (!tagGroups.TryGetValue(tag, out var posts)) + continue; + + var tagWatchers = watchTags.Where(x => x.Tag == tag).ToList(); + if (tagWatchers.Count == 0) + continue; + + var postIds = posts.ConvertAll(x => x.Id); + + var userNames = tagWatchers.Map(x => x.UserName); + var watchPostMail = new WatchPostMail + { + Date = day, + Tag = tag, + UserNames = userNames, + PostIds = postIds, + CreatedDate = DateTime.UtcNow, + }; + watchPostMail.Id = (int)await db.InsertAsync(watchPostMail, selectIdentity: true); + log.LogInformation( + "Created {Day} WatchPostMail {Id} for {Tag} with posts:{PostIds} for users:{UserNames}", + day, watchPostMail.Id, tag, postIds.Join(","), userNames.Join(",")); + + var layout = "tags"; + var template = "tagged-questions"; + var context = renderer.CreateMailContext(layout: layout, page: template); + var monthDay = yesterday.ToString("MMMM dd"); + var args = new Dictionary + { + ["tag"] = tag, + ["date"] = monthDay, + ["posts"] = posts, + }; + var html = await new PageResult(context.GetPage("content")) + { + Layout = "layout", + Args = args, + }.RenderToStringAsync(); + + args.Remove("model"); + + var externalRef = $"{nameof(WatchPostMail)}:{watchPostMail.Id}"; + var mailRun = new MailRun + { + MailingList = MailingList.WatchedTags, + CreatedDate = DateTime.UtcNow, + Layout = layout, + Generator = nameof(RenderTagQuestionsEmail), + Template = template, + GeneratorArgs = args, + ExternalRef = externalRef, + }; + mailRun.Id = (int)await dbCreatorKit.InsertAsync(mailRun, selectIdentity: true); + mailRuns++; + + await db.UpdateOnlyAsync(() => new WatchPostMail + { + MailRunId = mailRun.Id, + }, where: x => x.Id == watchPostMail.Id); + + var emails = 0; + foreach (var tagWatcher in tagWatchers) + { + var user = users.Find(x => x.UserName == tagWatcher.UserName); + if (user == null) + { + log.LogInformation("User {UserName} not found for WatchTag {Tag}", + tagWatcher.UserName, tagWatcher.Tag); + continue; + } + + var message = new EmailMessage + { + To = [new() { Email = user.Email!, Name = user.UserName! }], + Subject = $"New {tag} questions for {monthDay} - pvq.app", + BodyHtml = html, + }; + + var contact = await dbCreatorKit.GetOrCreateContact(user); + + var mailMessage = new MailMessageRun + { + MailRunId = mailRun.Id, + ContactId = contact.Id, + Contact = contact, + Renderer = nameof(RenderTagQuestionsEmail), + RendererArgs = args, + Message = message, + CreatedDate = DateTime.UtcNow, + ExternalRef = externalRef, + }; + mailMessage.Id = (int)await dbCreatorKit.InsertAsync(mailMessage, selectIdentity: true); + emails++; + } + + var generatedDate = DateTime.UtcNow; + await db.UpdateOnlyAsync(() => new WatchPostMail + { + GeneratedDate = generatedDate, + }, where: x => x.Id == watchPostMail.Id); + await dbCreatorKit.UpdateOnlyAsync(() => new MailRun + { + EmailsCount = emails, + GeneratedDate = generatedDate, + }, where: x => x.Id == mailRun.Id); + + log.LogInformation("Generated {Count} in {Day} MailRun {Id} for {Tag}", + emails, day, mailRun.Id, tag); + + mq.Publish(new CreatorKitTasks + { + SendMailRun = new() { + Id = mailRun.Id + } + }); + } + + log.LogInformation("Generated {Count} MailRuns for {Day}", mailRuns, day); + } +} diff --git a/MyApp.ServiceInterface/CreatorKit/CreatorKitTasksServices.cs b/MyApp.ServiceInterface/CreatorKit/CreatorKitTasksServices.cs index 5cf8c2e..fa1406b 100644 --- a/MyApp.ServiceInterface/CreatorKit/CreatorKitTasksServices.cs +++ b/MyApp.ServiceInterface/CreatorKit/CreatorKitTasksServices.cs @@ -1,50 +1,11 @@ -using ServiceStack; -using CreatorKit.ServiceModel.Types; +using CreatorKit.ServiceModel; +using ServiceStack; using MyApp.Data; using MyApp.ServiceInterface; -using MyApp.ServiceModel; -using ServiceStack.Data; -using ServiceStack.OrmLite; namespace CreatorKit.ServiceInterface; public class CreatorKitTasksServices : Service { public object Any(CreatorKitTasks request) => Request.ExecuteCommandsAsync(request); -} - -public class SendMessagesCommand(IDbConnectionFactory dbFactory, EmailProvider emailProvider) : IAsyncCommand -{ - public async Task ExecuteAsync(SendMailMessages request) - { - using var db = await dbFactory.OpenDbConnectionAsync(Databases.CreatorKit); - - foreach (var msg in request.Messages.Safe()) - { - if (msg.CompletedDate != null) - throw new Exception($"Message {msg.Id} has already been sent"); - - msg.Id = (int) await db.InsertAsync(msg, selectIdentity:true); - - // ensure message is only sent once - if (await db.UpdateOnlyAsync(() => new MailMessage { StartedDate = DateTime.UtcNow, Draft = false }, - where: x => x.Id == msg.Id && (x.StartedDate == null)) == 1) - { - try - { - emailProvider.Send(msg.Message); - } - catch (Exception e) - { - var error = e.ToResponseStatus(); - await db.UpdateOnlyAsync(() => new MailMessage { Error = error }, - where: x => x.Id == msg.Id); - throw; - } - - await db.UpdateOnlyAsync(() => new MailMessage { CompletedDate = DateTime.UtcNow }, - where: x => x.Id == msg.Id); - } - } - } } \ No newline at end of file diff --git a/MyApp.ServiceInterface/CreatorKit/EmailRenderersServices.cs b/MyApp.ServiceInterface/CreatorKit/EmailRenderersServices.cs index 6a7f90c..7af3d60 100644 --- a/MyApp.ServiceInterface/CreatorKit/EmailRenderersServices.cs +++ b/MyApp.ServiceInterface/CreatorKit/EmailRenderersServices.cs @@ -68,7 +68,6 @@ public async Task Any(RenderCustomHtml request) public async Task Any(RenderTagQuestionsEmail request) { - OrmLiteUtils.PrintSql(); var context = renderer.CreateMailContext(layout:"tags", page:"tagged-questions"); var posts = await Db.SelectAsync(Db.From() diff --git a/MyApp.ServiceInterface/CreatorKit/SendMailRunCommand.cs b/MyApp.ServiceInterface/CreatorKit/SendMailRunCommand.cs new file mode 100644 index 0000000..d50a8be --- /dev/null +++ b/MyApp.ServiceInterface/CreatorKit/SendMailRunCommand.cs @@ -0,0 +1,78 @@ +using CreatorKit.ServiceInterface; +using CreatorKit.ServiceModel; +using CreatorKit.ServiceModel.Types; +using Microsoft.Extensions.Logging; +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.Data; +using ServiceStack.OrmLite; + +namespace MyApp.ServiceInterface.CreatorKit; + +public class SendMailRunCommand( + ILogger log, + IDbConnectionFactory dbFactory, + EmailProvider emailProvider) + : IAsyncCommand +{ + public async Task ExecuteAsync(SendMailRun request) + { + using var db = HostContext.AppHost.GetDbConnection(Databases.CreatorKit); + var msgIdsToSend = await db.ColumnAsync(db.From() + .Where(x => x.MailRunId == request.Id && x.CompletedDate == null && x.StartedDate == null) + .Select(x => x.Id)); + + if (msgIdsToSend.Count == 0) + { + log.LogInformation("No remaining unsent Messages to send for MailRun {Id}", request.Id); + return; + } + + await db.UpdateOnlyAsync(() => new MailRun { SentDate = DateTime.UtcNow }, + where:x => x.Id == request.Id && x.SentDate == null); + + log.LogInformation("Sending {Count} Messages for MailRun {Id}", msgIdsToSend.Count, request.Id); + + foreach (var msgId in msgIdsToSend) + { + try + { + var msg = await db.SingleByIdAsync(msgId); + if (msg.CompletedDate != null) + { + log.LogWarning("MailMessageRun {Id} has already been sent", msg.Id); + continue; + } + + // ensure message is only sent once + if (await db.UpdateOnlyAsync(() => new MailMessageRun { StartedDate = DateTime.UtcNow }, + where: x => x.Id == request.Id && x.StartedDate == null) == 1) + { + try + { + emailProvider.Send(msg.Message); + + await db.UpdateOnlyAsync(() => new MailMessageRun { CompletedDate = DateTime.UtcNow }, + where: x => x.Id == request.Id); + } + catch (Exception e) + { + var error = e.ToResponseStatus(); + await db.UpdateOnlyAsync(() => new MailMessageRun { Error = error }, + where: x => x.Id == request.Id); + } + } + } + catch (Exception e) + { + var error = e.ToResponseStatus(); + await db.UpdateOnlyAsync(() => new MailMessageRun + { + Error = error + }, where: x => x.Id == msgId); + + log.LogError(e, "Error sending MailMessageRun {Id}: {Message}", msgId, e.Message); + } + } + } +} diff --git a/MyApp.ServiceInterface/CreatorKit/SendMessagesCommand.cs b/MyApp.ServiceInterface/CreatorKit/SendMessagesCommand.cs new file mode 100644 index 0000000..2ff860c --- /dev/null +++ b/MyApp.ServiceInterface/CreatorKit/SendMessagesCommand.cs @@ -0,0 +1,44 @@ +using CreatorKit.ServiceModel.Types; +using MyApp.Data; +using MyApp.ServiceModel; +using ServiceStack; +using ServiceStack.Data; +using ServiceStack.OrmLite; + +namespace CreatorKit.ServiceInterface; + +public class SendMessagesCommand(IDbConnectionFactory dbFactory, EmailProvider emailProvider) : IAsyncCommand +{ + public async Task ExecuteAsync(SendMailMessages request) + { + using var db = await dbFactory.OpenDbConnectionAsync(Databases.CreatorKit); + + foreach (var msg in request.Messages.Safe()) + { + if (msg.CompletedDate != null) + throw new Exception($"Message {msg.Id} has already been sent"); + + msg.Id = (int) await db.InsertAsync(msg, selectIdentity:true); + + // ensure message is only sent once + if (await db.UpdateOnlyAsync(() => new MailMessage { StartedDate = DateTime.UtcNow, Draft = false }, + where: x => x.Id == msg.Id && (x.StartedDate == null)) == 1) + { + try + { + emailProvider.Send(msg.Message); + } + catch (Exception e) + { + var error = e.ToResponseStatus(); + await db.UpdateOnlyAsync(() => new MailMessage { Error = error }, + where: x => x.Id == msg.Id); + throw; + } + + await db.UpdateOnlyAsync(() => new MailMessage { CompletedDate = DateTime.UtcNow }, + where: x => x.Id == msg.Id); + } + } + } +} \ No newline at end of file diff --git a/MyApp.ServiceInterface/Data/CreatorKitTasks.cs b/MyApp.ServiceInterface/Data/CreatorKitTasks.cs index ecc813c..f23d965 100644 --- a/MyApp.ServiceInterface/Data/CreatorKitTasks.cs +++ b/MyApp.ServiceInterface/Data/CreatorKitTasks.cs @@ -1,5 +1,7 @@ using CreatorKit.ServiceInterface; +using CreatorKit.ServiceModel; using CreatorKit.ServiceModel.Types; +using MyApp.ServiceInterface.CreatorKit; using ServiceStack; namespace MyApp.Data; @@ -14,4 +16,7 @@ public class CreatorKitTasks { [Command] public SendMailMessages? SendMessages { get; set; } -} + + [Command] + public SendMailRun? SendMailRun { get; set; } +} \ No newline at end of file diff --git a/MyApp.ServiceInterface/Data/Tasks.cs b/MyApp.ServiceInterface/Data/Tasks.cs index 38a7758..875771c 100644 --- a/MyApp.ServiceInterface/Data/Tasks.cs +++ b/MyApp.ServiceInterface/Data/Tasks.cs @@ -159,6 +159,9 @@ public class DbWrites : IGet, IReturn [Command] public StatTotals? SaveStartingUpVotes { get; set; } + + [Command] + public PeriodicTasks? PeriodicTasks { get; set; } } public class AiServerTasks diff --git a/MyApp.ServiceInterface/LeaderboardServices.cs b/MyApp.ServiceInterface/LeaderboardServices.cs index 3bf19e4..b11ba06 100644 --- a/MyApp.ServiceInterface/LeaderboardServices.cs +++ b/MyApp.ServiceInterface/LeaderboardServices.cs @@ -62,7 +62,7 @@ public async Task Any(CalculateTop1KLeaderboard request) var topQuestions = await Db.SelectAsync(Db.From().OrderByDescending(x => x.Score).Limit(1000)); var postIds = topQuestions.Select(x => x.Id).ToList(); - var statTotals = await Db.SelectAsync(Db.From() + var statTotals = await Db.SelectAsync(Db.From() .Where(x => Sql.In(x.PostId,postIds))); // filter to answers only @@ -150,10 +150,7 @@ private CalculateLeaderboardResponse CalculateLeaderboardResponse(List id is "accepted" or "most-voted"; /// @@ -184,7 +181,6 @@ double CalculateWinRate(List answers, string name) { PostId = g.Key, TopScores = g.GroupBy(x => x.GetScore()) - .OrderByDescending(x => x.Key) .Take(2) .Select(x => new { Score = x.Key, Count = x.Count() }) @@ -216,8 +212,12 @@ double CalculateWinRate(List answers, string name) public async Task Any(GetLeaderboardStatsByTag request) { var allStatsForTag = await Db.SelectAsync(@"SELECT st.* -FROM main.StatTotals st -WHERE st.PostId in (select Id from post p where p.Tags LIKE @TagMiddle OR p.Tags LIKE @TagLeft OR p.Tags LIKE @TagRight OR p.Tags = @TagSolo)", new { TagSolo = $"[{request.Tag}]", + FROM main.StatTotals st WHERE st.PostId in + (SELECT Id + FROM post p + WHERE p.Tags LIKE @TagMiddle OR p.Tags LIKE @TagLeft OR + p.Tags LIKE @TagRight OR p.Tags = @TagSolo)", + new { TagSolo = $"[{request.Tag}]", TagRight = $"%,{request.Tag}]", TagLeft = $"[{request.Tag},%", TagMiddle = $"%,{request.Tag},%", diff --git a/MyApp.ServiceInterface/TimeHostedService.cs b/MyApp.ServiceInterface/TimeHostedService.cs new file mode 100644 index 0000000..d849ba2 --- /dev/null +++ b/MyApp.ServiceInterface/TimeHostedService.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MyApp.Data; +using MyApp.ServiceModel; +using ServiceStack.Messaging; + +namespace MyApp.ServiceInterface; + +public class TimedHostedService(ILogger logger, IMessageService mqServer) : IHostedService, IDisposable +{ + private int EveryMins = 60; + private int executionCount = 0; + private Timer? timer = null; + + public Task StartAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Timed Hosted Service running"); + + timer = new Timer(DoWork, null, TimeSpan.Zero, + TimeSpan.FromMinutes(EveryMins)); + + return Task.CompletedTask; + } + + private void DoWork(object? state) + { + var count = Interlocked.Increment(ref executionCount); + logger.LogInformation("Timed Hosted Service is working. Count: {Count}", count); + + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogInformation("MQ Worker running at: {Stats}\n", mqServer.GetStatsDescription()); + + var frequentTasks = new PeriodicTasks { PeriodicFrequency = PeriodicFrequency.Hourly }; + using var mq = mqServer.MessageFactory.CreateMessageProducer(); + mq.Publish(new DbWrites { PeriodicTasks = frequentTasks }); + } + + public Task StopAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Timed Hosted Service is stopping"); + + timer?.Change(Timeout.Infinite, 0); + + return Task.CompletedTask; + } + + public void Dispose() + { + timer?.Dispose(); + } +} diff --git a/MyApp.ServiceModel/CreatorKit/Types/MailingList.cs b/MyApp.ServiceModel/CreatorKit/Types/MailingList.cs index be91419..fc150d6 100644 --- a/MyApp.ServiceModel/CreatorKit/Types/MailingList.cs +++ b/MyApp.ServiceModel/CreatorKit/Types/MailingList.cs @@ -20,4 +20,6 @@ public enum MailingList ProductReleases = 1 << 4, //16 [Description("Yearly Updates")] YearlyUpdates = 1 << 5, //32 + [Description("Watched Tags")] + WatchedTags = 1 << 6, //64 } diff --git a/MyApp.ServiceModel/PeriodicTasks.cs b/MyApp.ServiceModel/PeriodicTasks.cs new file mode 100644 index 0000000..f8d3cf7 --- /dev/null +++ b/MyApp.ServiceModel/PeriodicTasks.cs @@ -0,0 +1,13 @@ +namespace MyApp.ServiceModel; + +public class PeriodicTasks +{ + public PeriodicFrequency PeriodicFrequency { get; set; } +} +public enum PeriodicFrequency +{ + Minute, + Hourly, + Daily, + Monthly, +} diff --git a/MyApp.ServiceModel/WatchContent.cs b/MyApp.ServiceModel/WatchContent.cs index 2cfc260..6279e6a 100644 --- a/MyApp.ServiceModel/WatchContent.cs +++ b/MyApp.ServiceModel/WatchContent.cs @@ -13,7 +13,21 @@ public class WatchPost public DateTime CreatedDate { get; set; } public DateTime? AfterDate { get; set; } // Email new answers 1hr after asking question } - + +[UniqueConstraint(nameof(Date), nameof(Tag))] +public class WatchPostMail +{ + [AutoIncrement] + public int Id { get; set; } + public string Date { get; set; } + public string Tag { get; set; } + public List UserNames { get; set; } + public List PostIds { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime GeneratedDate { get; set; } + public int? MailRunId { get; set; } +} + [UniqueConstraint(nameof(UserName), nameof(Tag))] public class WatchTag { diff --git a/MyApp/Configure.Mq.cs b/MyApp/Configure.Mq.cs index eec912a..5ee6de4 100644 --- a/MyApp/Configure.Mq.cs +++ b/MyApp/Configure.Mq.cs @@ -26,6 +26,7 @@ public void Configure(IWebHostBuilder builder) => builder services.AddSingleton(); services.AddSingleton(); services.AddPlugin(new CommandsFeature()); + services.AddHostedService(); }) .ConfigureAppHost(afterAppHostInit: appHost => { var mqService = appHost.Resolve(); diff --git a/MyApp/Migrations/Migration1006.cs b/MyApp/Migrations/Migration1006.cs new file mode 100644 index 0000000..c0d594a --- /dev/null +++ b/MyApp/Migrations/Migration1006.cs @@ -0,0 +1,31 @@ +using ServiceStack.DataAnnotations; +using ServiceStack.OrmLite; + +namespace MyApp.Migrations; + +public class Migration1006 : MigrationBase +{ + [UniqueConstraint(nameof(Date), nameof(Tag))] + public class WatchPostMail + { + [AutoIncrement] + public int Id { get; set; } + public string Date { get; set; } + public string Tag { get; set; } + public List UserNames { get; set; } + public List PostIds { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime GeneratedDate { get; set; } + public int? MailRunId { get; set; } + } + + public override void Up() + { + Db.CreateTable(); + } + + public override void Down() + { + Db.DropTable(); + } +} diff --git a/MyApp/emails/layouts/tags.html b/MyApp/emails/layouts/tags.html index 2a2f683..53265c3 100644 --- a/MyApp/emails/layouts/tags.html +++ b/MyApp/emails/layouts/tags.html @@ -212,7 +212,7 @@
 |  
{{info.Privacy}} @@ -224,7 +224,7 @@

You received this email because you are watching the - {{tag}} tag + {{tag}} tag

diff --git a/MyApp/emails/tagged-questions.html b/MyApp/emails/tagged-questions.html index 1251535..d41487b 100644 --- a/MyApp/emails/tagged-questions.html +++ b/MyApp/emails/tagged-questions.html @@ -9,7 +9,7 @@ {{#each posts }} -

{{ it.title }} →

+

{{ it.title }} →

{{ it.summary }}

{{/each}}