From 7066edffbb09f6f62ca1fbc1b37777438d579179 Mon Sep 17 00:00:00 2001 From: Scott Galloway Date: Mon, 30 Sep 2024 18:44:44 +0100 Subject: [PATCH] Updates & Changes. --- .../20240826100422_RemoveCasing.Designer.cs | 303 -------------- ...40920234828_EmailSubscriptions.Designer.cs | 385 ------------------ .../20240920234828_EmailSubscriptions.cs | 94 ----- ...s => 20240930121416_EmailSubscriptions.cs} | 122 +++++- .../MostlylucidDbContextModelSnapshot.cs | 162 ++++---- Mostlylucid.DbContext/Setup.cs | 5 +- .../Properties/launchSettings.json | 4 +- .../Services/NewsletterSendingService.cs | 58 +-- Mostlylucid.Services/Email/EmailService.cs | 33 +- .../Email/HostedEmailService.cs | 8 +- Mostlylucid.Services/Email/IEmailService.cs | 8 +- .../EmailSubscriptionSendLogEntity.cs | 2 +- .../EmailSubscription/EmailProcessorSetup.cs | 13 +- .../EmailSubscription/NewsletterClient.cs | 11 +- .../EmailSubscription/RetryPolicyExtension.cs | 18 + Mostlylucid/Markdown/aboutme.md | 113 ++++- .../Markdown/acopybuttonforhightlightjs.md | 148 +++++++ .../Markdown/customconfigsectionextensions.md | 11 + Mostlylucid/Mostlylucid.csproj | 1 + Mostlylucid/Program.cs | 9 +- Mostlylucid/Properties/launchSettings.json | 2 +- Mostlylucid/Views/Home/_HomePartial.cshtml | 6 +- Mostlylucid/Views/Shared/_Toast.cshtml | 8 +- Mostlylucid/appsettings.json | 5 + Mostlylucid/src/css/main.css | 22 +- Mostlylucid/src/js/main.js | 18 +- docker-compose.yml | 35 +- 27 files changed, 624 insertions(+), 980 deletions(-) delete mode 100644 Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.Designer.cs delete mode 100644 Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.Designer.cs delete mode 100644 Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.cs rename Mostlylucid.DbContext/Migrations/{20240826100422_RemoveCasing.cs => 20240930121416_EmailSubscriptions.cs} (82%) create mode 100644 Mostlylucid/EmailSubscription/RetryPolicyExtension.cs create mode 100644 Mostlylucid/Markdown/acopybuttonforhightlightjs.md diff --git a/Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.Designer.cs b/Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.Designer.cs deleted file mode 100644 index 518be95..0000000 --- a/Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.Designer.cs +++ /dev/null @@ -1,303 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mostlylucid.DbContext.EntityFramework; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using NpgsqlTypes; - -#nullable disable - -namespace Mostlylucid.Migrations -{ - [DbContext(typeof(MostlylucidDbContext))] - [Migration("20240826100422_RemoveCasing")] - partial class RemoveCasing - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("mostlylucid") - .HasAnnotation("ProductVersion", "8.0.7") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ContentHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("HtmlContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("LanguageId") - .HasColumnType("integer"); - - b.Property("Markdown") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlainTextContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("PublishedDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchVector") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", true); - - b.Property("Slug") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedDate") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("WordCount") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ContentHash") - .IsUnique(); - - b.HasIndex("LanguageId"); - - b.HasIndex("PublishedDate"); - - b.HasIndex("SearchVector"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); - - b.HasIndex("Slug", "LanguageId"); - - b.ToTable("BlogPosts", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CategoryEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "GIN"); - - b.ToTable("Categories", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentClosure", b => - { - b.Property("AncestorId") - .HasColumnType("integer") - .HasColumnName("ancestor_id"); - - b.Property("DescendantId") - .HasColumnType("integer") - .HasColumnName("descendant_id"); - - b.Property("Depth") - .HasColumnType("integer") - .HasColumnName("depth"); - - b.HasKey("AncestorId", "DescendantId"); - - b.HasIndex("DescendantId"); - - b.ToTable("comment_closures", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Content") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("HtmlContent") - .HasColumnType("text"); - - b.Property("ParentCommentId") - .HasColumnType("integer") - .HasColumnName("parent_comment_id"); - - b.Property("PostId") - .HasColumnType("integer"); - - b.Property("Status") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("Author"); - - b.HasIndex("ParentCommentId"); - - b.HasIndex("PostId"); - - b.ToTable("Comments", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.LanguageEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Languages", "mostlylucid"); - }); - - modelBuilder.Entity("blogpostcategory", b => - { - b.Property("BlogPostId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.HasKey("BlogPostId", "CategoryId"); - - b.HasIndex("CategoryId"); - - b.ToTable("blogpostcategory", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.LanguageEntity", "LanguageEntity") - .WithMany("BlogPosts") - .HasForeignKey("LanguageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("LanguageEntity"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentClosure", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "Ancestor") - .WithMany("Descendants") - .HasForeignKey("AncestorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "Descendant") - .WithMany("Ancestors") - .HasForeignKey("DescendantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ancestor"); - - b.Navigation("Descendant"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "ParentComment") - .WithMany() - .HasForeignKey("ParentCommentId"); - - b.HasOne("Mostlylucid.EntityFramework.Models.BlogPostEntity", "Post") - .WithMany("Comments") - .HasForeignKey("PostId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentComment"); - - b.Navigation("Post"); - }); - - modelBuilder.Entity("blogpostcategory", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.BlogPostEntity", null) - .WithMany() - .HasForeignKey("BlogPostId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Mostlylucid.EntityFramework.Models.CategoryEntity", null) - .WithMany() - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => - { - b.Navigation("Comments"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => - { - b.Navigation("Ancestors"); - - b.Navigation("Descendants"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.LanguageEntity", b => - { - b.Navigation("BlogPosts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.Designer.cs b/Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.Designer.cs deleted file mode 100644 index 008116d..0000000 --- a/Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.Designer.cs +++ /dev/null @@ -1,385 +0,0 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Mostlylucid.DbContext.EntityFramework; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using NpgsqlTypes; - -#nullable disable - -namespace Mostlylucid.Migrations -{ - [DbContext(typeof(MostlylucidDbContext))] - [Migration("20240920234828_EmailSubscriptions")] - partial class EmailSubscriptions - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasDefaultSchema("mostlylucid") - .HasAnnotation("ProductVersion", "8.0.8") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("EmailSubscription_Category", b => - { - b.Property("CategoryId") - .HasColumnType("integer"); - - b.Property("EmailSubscriptionId") - .HasColumnType("integer"); - - b.HasKey("CategoryId", "EmailSubscriptionId"); - - b.HasIndex("EmailSubscriptionId"); - - b.ToTable("EmailSubscription_Category", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EmailSubscription.Models.Entities.EmailSubscriptionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Day") - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("DayOfMonth") - .HasColumnType("integer"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("Language") - .IsRequired() - .HasMaxLength(2) - .HasColumnType("character varying(2)"); - - b.Property("LastSent") - .HasColumnType("timestamp with time zone"); - - b.Property("SubscriptionType") - .HasColumnType("integer"); - - b.Property("Token") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("Email"); - - b.HasIndex("Token") - .IsUnique(); - - b.ToTable("EmailSubscriptions", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("ContentHash") - .IsRequired() - .HasColumnType("text"); - - b.Property("HtmlContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("LanguageId") - .HasColumnType("integer"); - - b.Property("Markdown") - .IsRequired() - .HasColumnType("text"); - - b.Property("PlainTextContent") - .IsRequired() - .HasColumnType("text"); - - b.Property("PublishedDate") - .HasColumnType("timestamp with time zone"); - - b.Property("SearchVector") - .IsRequired() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("tsvector") - .HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", true); - - b.Property("Slug") - .IsRequired() - .HasColumnType("text"); - - b.Property("Title") - .IsRequired() - .HasColumnType("text"); - - b.Property("UpdatedDate") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("WordCount") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("ContentHash") - .IsUnique(); - - b.HasIndex("LanguageId"); - - b.HasIndex("PublishedDate"); - - b.HasIndex("SearchVector"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); - - b.HasIndex("Slug", "LanguageId"); - - b.ToTable("BlogPosts", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CategoryEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("Name") - .HasAnnotation("Npgsql:TsVectorConfig", "english"); - - NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "GIN"); - - b.ToTable("Categories", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentClosure", b => - { - b.Property("AncestorId") - .HasColumnType("integer") - .HasColumnName("ancestor_id"); - - b.Property("DescendantId") - .HasColumnType("integer") - .HasColumnName("descendant_id"); - - b.Property("Depth") - .HasColumnType("integer") - .HasColumnName("depth"); - - b.HasKey("AncestorId", "DescendantId"); - - b.HasIndex("DescendantId"); - - b.ToTable("comment_closures", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Author") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Content") - .IsRequired() - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("CreatedAt") - .ValueGeneratedOnAdd() - .HasColumnType("timestamp with time zone") - .HasDefaultValueSql("CURRENT_TIMESTAMP"); - - b.Property("HtmlContent") - .HasColumnType("text"); - - b.Property("ParentCommentId") - .HasColumnType("integer") - .HasColumnName("parent_comment_id"); - - b.Property("PostId") - .HasColumnType("integer"); - - b.Property("Status") - .HasColumnType("integer"); - - b.HasKey("Id"); - - b.HasIndex("Author"); - - b.HasIndex("ParentCommentId"); - - b.HasIndex("PostId"); - - b.ToTable("Comments", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.LanguageEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("Id"); - - b.ToTable("Languages", "mostlylucid"); - }); - - modelBuilder.Entity("blogpostcategory", b => - { - b.Property("BlogPostId") - .HasColumnType("integer"); - - b.Property("CategoryId") - .HasColumnType("integer"); - - b.HasKey("BlogPostId", "CategoryId"); - - b.HasIndex("CategoryId"); - - b.ToTable("blogpostcategory", "mostlylucid"); - }); - - modelBuilder.Entity("EmailSubscription_Category", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.CategoryEntity", null) - .WithMany() - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.NoAction) - .IsRequired(); - - b.HasOne("Mostlylucid.EmailSubscription.Models.Entities.EmailSubscriptionEntity", null) - .WithMany() - .HasForeignKey("EmailSubscriptionId") - .OnDelete(DeleteBehavior.NoAction) - .IsRequired(); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.LanguageEntity", "LanguageEntity") - .WithMany("BlogPosts") - .HasForeignKey("LanguageId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("LanguageEntity"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentClosure", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "Ancestor") - .WithMany("Descendants") - .HasForeignKey("AncestorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "Descendant") - .WithMany("Ancestors") - .HasForeignKey("DescendantId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Ancestor"); - - b.Navigation("Descendant"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "ParentComment") - .WithMany() - .HasForeignKey("ParentCommentId"); - - b.HasOne("Mostlylucid.EntityFramework.Models.BlogPostEntity", "Post") - .WithMany("Comments") - .HasForeignKey("PostId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ParentComment"); - - b.Navigation("Post"); - }); - - modelBuilder.Entity("blogpostcategory", b => - { - b.HasOne("Mostlylucid.EntityFramework.Models.BlogPostEntity", null) - .WithMany() - .HasForeignKey("BlogPostId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Mostlylucid.EntityFramework.Models.CategoryEntity", null) - .WithMany() - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => - { - b.Navigation("Comments"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => - { - b.Navigation("Ancestors"); - - b.Navigation("Descendants"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.LanguageEntity", b => - { - b.Navigation("BlogPosts"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.cs b/Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.cs deleted file mode 100644 index 0b6ceee..0000000 --- a/Mostlylucid.DbContext/Migrations/20240920234828_EmailSubscriptions.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Mostlylucid.Migrations -{ - /// - public partial class EmailSubscriptions : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "EmailSubscriptions", - schema: "mostlylucid", - columns: table => new - { - Id = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - SubscriptionType = table.Column(type: "integer", nullable: false), - Language = table.Column(type: "character varying(2)", maxLength: 2, nullable: false), - Email = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - CreatedDate = table.Column(type: "timestamp with time zone", nullable: false), - LastSent = table.Column(type: "timestamp with time zone", nullable: true), - DayOfMonth = table.Column(type: "integer", nullable: true), - Day = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), - EmailConfirmed = table.Column(type: "boolean", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_EmailSubscriptions", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "EmailSubscription_Category", - schema: "mostlylucid", - columns: table => new - { - CategoryId = table.Column(type: "integer", nullable: false), - EmailSubscriptionId = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_EmailSubscription_Category", x => new { x.CategoryId, x.EmailSubscriptionId }); - table.ForeignKey( - name: "FK_EmailSubscription_Category_Categories_CategoryId", - column: x => x.CategoryId, - principalSchema: "mostlylucid", - principalTable: "Categories", - principalColumn: "Id"); - table.ForeignKey( - name: "FK_EmailSubscription_Category_EmailSubscriptions_EmailSubscrip~", - column: x => x.EmailSubscriptionId, - principalSchema: "mostlylucid", - principalTable: "EmailSubscriptions", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_EmailSubscription_Category_EmailSubscriptionId", - schema: "mostlylucid", - table: "EmailSubscription_Category", - column: "EmailSubscriptionId"); - - migrationBuilder.CreateIndex( - name: "IX_EmailSubscriptions_Email", - schema: "mostlylucid", - table: "EmailSubscriptions", - column: "Email"); - - migrationBuilder.CreateIndex( - name: "IX_EmailSubscriptions_Token", - schema: "mostlylucid", - table: "EmailSubscriptions", - column: "Token", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "EmailSubscription_Category", - schema: "mostlylucid"); - - migrationBuilder.DropTable( - name: "EmailSubscriptions", - schema: "mostlylucid"); - } - } -} diff --git a/Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.cs b/Mostlylucid.DbContext/Migrations/20240930121416_EmailSubscriptions.cs similarity index 82% rename from Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.cs rename to Mostlylucid.DbContext/Migrations/20240930121416_EmailSubscriptions.cs index 8fdb200..59c5216 100644 --- a/Mostlylucid.DbContext/Migrations/20240826100422_RemoveCasing.cs +++ b/Mostlylucid.DbContext/Migrations/20240930121416_EmailSubscriptions.cs @@ -1,12 +1,14 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using NpgsqlTypes; #nullable disable -namespace Mostlylucid.Migrations +namespace Mostlylucid.DbContext.Migrations { /// - public partial class RemoveCasing : Migration + public partial class EmailSubscriptions : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -282,6 +284,17 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "BlogPosts", newName: "IX_BlogPosts_ContentHash"); + migrationBuilder.AlterColumn( + name: "UpdatedDate", + schema: "mostlylucid", + table: "BlogPosts", + type: "timestamp with time zone", + nullable: true, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + migrationBuilder.AlterColumn( name: "SearchVector", schema: "mostlylucid", @@ -319,6 +332,85 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "BlogPosts", column: "Id"); + migrationBuilder.CreateTable( + name: "EmailSubscriptions", + schema: "mostlylucid", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Token = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Email = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SubscriptionType = table.Column(type: "integer", nullable: false), + Language = table.Column(type: "character varying(2)", maxLength: 2, nullable: false), + CreatedDate = table.Column(type: "timestamp with time zone", maxLength: 100, nullable: false), + LastSent = table.Column(type: "timestamp with time zone", nullable: true), + DayOfMonth = table.Column(type: "integer", nullable: true), + Day = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailSubscriptions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "EmailSubscriptionSendLogs", + schema: "mostlylucid", + columns: table => new + { + SubscriptionType = table.Column(type: "varchar(24)", nullable: false), + LastSent = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("PK_EmailSubscriptionSendLogs", x => x.SubscriptionType); + }); + + migrationBuilder.CreateTable( + name: "EmailSubscription_Category", + schema: "mostlylucid", + columns: table => new + { + CategoryId = table.Column(type: "integer", nullable: false), + EmailSubscriptionId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailSubscription_Category", x => new { x.CategoryId, x.EmailSubscriptionId }); + table.ForeignKey( + name: "FK_EmailSubscription_Category_Categories_CategoryId", + column: x => x.CategoryId, + principalSchema: "mostlylucid", + principalTable: "Categories", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_EmailSubscription_Category_EmailSubscriptions_EmailSubscrip~", + column: x => x.EmailSubscriptionId, + principalSchema: "mostlylucid", + principalTable: "EmailSubscriptions", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmailSubscription_Category_EmailSubscriptionId", + schema: "mostlylucid", + table: "EmailSubscription_Category", + column: "EmailSubscriptionId"); + + migrationBuilder.CreateIndex( + name: "IX_EmailSubscriptions_Email", + schema: "mostlylucid", + table: "EmailSubscriptions", + column: "Email"); + + migrationBuilder.CreateIndex( + name: "IX_EmailSubscriptions_Token", + schema: "mostlylucid", + table: "EmailSubscriptions", + column: "Token", + unique: true); + migrationBuilder.AddForeignKey( name: "FK_blogpostcategory_BlogPosts_BlogPostId", schema: "mostlylucid", @@ -427,6 +519,18 @@ protected override void Down(MigrationBuilder migrationBuilder) schema: "mostlylucid", table: "Comments"); + migrationBuilder.DropTable( + name: "EmailSubscription_Category", + schema: "mostlylucid"); + + migrationBuilder.DropTable( + name: "EmailSubscriptionSendLogs", + schema: "mostlylucid"); + + migrationBuilder.DropTable( + name: "EmailSubscriptions", + schema: "mostlylucid"); + migrationBuilder.DropPrimaryKey( name: "PK_Languages", schema: "mostlylucid", @@ -663,6 +767,18 @@ protected override void Down(MigrationBuilder migrationBuilder) table: "blogposts", newName: "IX_blogposts_content_hash"); + migrationBuilder.AlterColumn( + name: "updated_date", + schema: "mostlylucid", + table: "blogposts", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "CURRENT_TIMESTAMP", + oldClrType: typeof(DateTimeOffset), + oldType: "timestamp with time zone", + oldNullable: true, + oldDefaultValueSql: "CURRENT_TIMESTAMP"); + migrationBuilder.AlterColumn( name: "search_vector", schema: "mostlylucid", diff --git a/Mostlylucid.DbContext/Migrations/MostlylucidDbContextModelSnapshot.cs b/Mostlylucid.DbContext/Migrations/MostlylucidDbContextModelSnapshot.cs index 5b0ac6f..58979ef 100644 --- a/Mostlylucid.DbContext/Migrations/MostlylucidDbContextModelSnapshot.cs +++ b/Mostlylucid.DbContext/Migrations/MostlylucidDbContextModelSnapshot.cs @@ -39,59 +39,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EmailSubscription_Category", "mostlylucid"); }); - modelBuilder.Entity("Mostlylucid.EmailSubscription.Models.Entities.EmailSubscriptionEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("CreatedDate") - .HasColumnType("timestamp with time zone"); - - b.Property("Day") - .HasMaxLength(10) - .HasColumnType("character varying(10)"); - - b.Property("DayOfMonth") - .HasColumnType("integer"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property("EmailConfirmed") - .HasColumnType("boolean"); - - b.Property("Language") - .IsRequired() - .HasMaxLength(2) - .HasColumnType("character varying(2)"); - - b.Property("LastSent") - .HasColumnType("timestamp with time zone"); - - b.Property("SubscriptionType") - .HasColumnType("integer"); - - b.Property("Token") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("Id"); - - b.HasIndex("Email"); - - b.HasIndex("Token") - .IsUnique(); - - b.ToTable("EmailSubscriptions", "mostlylucid"); - }); - - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.BlogPostEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -135,7 +83,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); - b.Property("UpdatedDate") + b.Property("UpdatedDate") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") .HasDefaultValueSql("CURRENT_TIMESTAMP"); @@ -161,7 +109,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BlogPosts", "mostlylucid"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CategoryEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.CategoryEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -183,7 +131,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Categories", "mostlylucid"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentClosure", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.CommentClosure", b => { b.Property("AncestorId") .HasColumnType("integer") @@ -204,7 +152,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("comment_closures", "mostlylucid"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.CommentEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -251,7 +199,75 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Comments", "mostlylucid"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.LanguageEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.EmailSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasMaxLength(100) + .HasColumnType("timestamp with time zone"); + + b.Property("Day") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("DayOfMonth") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("LastSent") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionType") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("EmailSubscriptions", "mostlylucid"); + }); + + modelBuilder.Entity("Mostlylucid.Shared.Entities.EmailSubscriptionSendLogEntity", b => + { + b.Property("SubscriptionType") + .HasColumnType("varchar(24)"); + + b.Property("LastSent") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("SubscriptionType"); + + b.ToTable("EmailSubscriptionSendLogs", "mostlylucid"); + }); + + modelBuilder.Entity("Mostlylucid.Shared.Entities.LanguageEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -285,22 +301,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("EmailSubscription_Category", b => { - b.HasOne("Mostlylucid.EntityFramework.Models.CategoryEntity", null) + b.HasOne("Mostlylucid.Shared.Entities.CategoryEntity", null) .WithMany() .HasForeignKey("CategoryId") .OnDelete(DeleteBehavior.NoAction) .IsRequired(); - b.HasOne("Mostlylucid.EmailSubscription.Models.Entities.EmailSubscriptionEntity", null) + b.HasOne("Mostlylucid.Shared.Entities.EmailSubscriptionEntity", null) .WithMany() .HasForeignKey("EmailSubscriptionId") .OnDelete(DeleteBehavior.NoAction) .IsRequired(); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.BlogPostEntity", b => { - b.HasOne("Mostlylucid.EntityFramework.Models.LanguageEntity", "LanguageEntity") + b.HasOne("Mostlylucid.Shared.Entities.LanguageEntity", "LanguageEntity") .WithMany("BlogPosts") .HasForeignKey("LanguageId") .OnDelete(DeleteBehavior.Cascade) @@ -309,15 +325,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("LanguageEntity"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentClosure", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.CommentClosure", b => { - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "Ancestor") + b.HasOne("Mostlylucid.Shared.Entities.CommentEntity", "Ancestor") .WithMany("Descendants") .HasForeignKey("AncestorId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "Descendant") + b.HasOne("Mostlylucid.Shared.Entities.CommentEntity", "Descendant") .WithMany("Ancestors") .HasForeignKey("DescendantId") .OnDelete(DeleteBehavior.Cascade) @@ -328,13 +344,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Descendant"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.CommentEntity", b => { - b.HasOne("Mostlylucid.EntityFramework.Models.CommentEntity", "ParentComment") + b.HasOne("Mostlylucid.Shared.Entities.CommentEntity", "ParentComment") .WithMany() .HasForeignKey("ParentCommentId"); - b.HasOne("Mostlylucid.EntityFramework.Models.BlogPostEntity", "Post") + b.HasOne("Mostlylucid.Shared.Entities.BlogPostEntity", "Post") .WithMany("Comments") .HasForeignKey("PostId") .OnDelete(DeleteBehavior.Cascade) @@ -347,32 +363,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("blogpostcategory", b => { - b.HasOne("Mostlylucid.EntityFramework.Models.BlogPostEntity", null) + b.HasOne("Mostlylucid.Shared.Entities.BlogPostEntity", null) .WithMany() .HasForeignKey("BlogPostId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Mostlylucid.EntityFramework.Models.CategoryEntity", null) + b.HasOne("Mostlylucid.Shared.Entities.CategoryEntity", null) .WithMany() .HasForeignKey("CategoryId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.BlogPostEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.BlogPostEntity", b => { b.Navigation("Comments"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.CommentEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.CommentEntity", b => { b.Navigation("Ancestors"); b.Navigation("Descendants"); }); - modelBuilder.Entity("Mostlylucid.EntityFramework.Models.LanguageEntity", b => + modelBuilder.Entity("Mostlylucid.Shared.Entities.LanguageEntity", b => { b.Navigation("BlogPosts"); }); diff --git a/Mostlylucid.DbContext/Setup.cs b/Mostlylucid.DbContext/Setup.cs index 5a9ec41..d54f24f 100644 --- a/Mostlylucid.DbContext/Setup.cs +++ b/Mostlylucid.DbContext/Setup.cs @@ -25,7 +25,10 @@ public static void SetupDatabase(this IServiceCollection services, IConfiguratio { ApplicationName = applicationName }; - options.UseNpgsql(connectionStringBuilder.ConnectionString); + options.UseNpgsql(connectionStringBuilder.ConnectionString, npgsqlOptions => + { + npgsqlOptions.MigrationsAssembly(typeof(MostlylucidDbContext).Assembly.FullName); // Set your migration assembly here + }); }); } } \ No newline at end of file diff --git a/Mostlylucid.SchedulerService/Properties/launchSettings.json b/Mostlylucid.SchedulerService/Properties/launchSettings.json index e10ec2e..e35f963 100644 --- a/Mostlylucid.SchedulerService/Properties/launchSettings.json +++ b/Mostlylucid.SchedulerService/Properties/launchSettings.json @@ -21,7 +21,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:7106;http://localhost:5083", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -35,4 +35,4 @@ } } } -} +} \ No newline at end of file diff --git a/Mostlylucid.SchedulerService/Services/NewsletterSendingService.cs b/Mostlylucid.SchedulerService/Services/NewsletterSendingService.cs index 62a5d5e..3fa7ab3 100644 --- a/Mostlylucid.SchedulerService/Services/NewsletterSendingService.cs +++ b/Mostlylucid.SchedulerService/Services/NewsletterSendingService.cs @@ -17,13 +17,15 @@ public class NewsletterSendingService( { private string GetPostUrl(string language, string slug) { - return language == Constants.EnglishLanguage ? $"{newsletterConfig.AppHostUrl}/post/{slug}" : $"{newsletterConfig.AppHostUrl}/{language}/post/{slug}"; + return language == Constants.EnglishLanguage + ? $"{newsletterConfig.AppHostUrl}/post/{slug}" + : $"{newsletterConfig.AppHostUrl}/{language}/post/{slug}"; } public async Task SendScheduledNewsletter(SubscriptionType subscriptionType) { - using var scope = scopeFactory.CreateScope(); - var activity = Log.Logger.StartActivity("SendScheduledNewsletter"); + using var scope = scopeFactory.CreateScope(); + var activity = Log.Logger.StartActivity("SendScheduledNewsletter"); var newsletterManagementService = scope.ServiceProvider.GetRequiredService(); var subscriptions = await newsletterManagementService.GetSubscriptions(subscriptionType); foreach (var subscription in subscriptions) @@ -31,8 +33,9 @@ public async Task SendScheduledNewsletter(SubscriptionType subscriptionType) logger.LogInformation("Sending newsletter for subscription {Subscription}", subscription); await SendNewsletterForSubscription(subscription, activity); } + logger.LogInformation("Updating last send for subscription type {SubscriptionType}", subscriptionType); - await newsletterManagementService.UpdateLastSend(subscriptionType, DateTime.Now); + await newsletterManagementService.UpdateLastSend(subscriptionType, DateTime.Now); } private async Task SendNewsletterForSubscription(EmailSubscriptionModel subscription, LoggerActivity activity) @@ -40,33 +43,32 @@ private async Task SendNewsletterForSubscription(EmailSubscriptionModel su activity?.Activity?.SetTag("subscription", subscription); try { - - using var scope = scopeFactory.CreateScope(); - var newsletterManagementService = scope.ServiceProvider.GetRequiredService(); - var emailSender = scope.ServiceProvider.GetRequiredService(); - var posts = await newsletterManagementService.GetPostsToSend(subscription.SubscriptionType); - var emailModel = new EmailTemplateModel() - { - ToEmail = subscription.Email, - Subject = "mostlylucid newsletter", - Posts = posts.Select(p => new EmailPostModel() + using var scope = scopeFactory.CreateScope(); + var newsletterManagementService = scope.ServiceProvider.GetRequiredService(); + var emailSender = scope.ServiceProvider.GetRequiredService(); + var posts = await newsletterManagementService.GetPostsToSend(subscription.SubscriptionType); + var emailModel = new EmailTemplateModel() { - Title = p.Title, - Language = p.Language, - PlainTextContent = p.PlainTextContent.TruncateAtWord(200), - Url = GetPostUrl(p.Language, p.Slug), - PublishedDate = p.PublishedDate - }).ToList(), - }; - await emailSender.SendEmailAsync(emailModel); - activity?.Activity?.SetTag("email", subscription.Email); - activity?.Complete(LogEventLevel.Information); - await newsletterManagementService.UpdateLastSendForSubscription(subscription.Id, DateTime.Now); - return true; + ToEmail = subscription.Email, + Subject = "mostlylucid newsletter", + Posts = posts.Select(p => new EmailPostModel() + { + Title = p.Title, + Language = p.Language, + PlainTextContent = p.PlainTextContent.TruncateAtWord(200), + Url = GetPostUrl(p.Language, p.Slug), + PublishedDate = p.PublishedDate + }).ToList(), + }; + await emailSender.SendEmailAsync(emailModel); + activity?.Activity?.SetTag("email", subscription.Email); + activity?.Complete(LogEventLevel.Information); + await newsletterManagementService.UpdateLastSendForSubscription(subscription.Id, DateTime.Now); + return true; } catch (Exception e) { - activity?.Complete(LogEventLevel.Error, e); + activity?.Complete(LogEventLevel.Error, e); logger.LogError(e, "Error sending newsletter for subscription {Subscription}", subscription); return false; } @@ -98,6 +100,7 @@ public async Task SendImmediateEmailForSubscription(string token) { return false; } + return true; } catch (Exception e) @@ -107,5 +110,4 @@ public async Task SendImmediateEmailForSubscription(string token) return false; } } - } \ No newline at end of file diff --git a/Mostlylucid.Services/Email/EmailService.cs b/Mostlylucid.Services/Email/EmailService.cs index 42313e8..6ec2c7c 100644 --- a/Mostlylucid.Services/Email/EmailService.cs +++ b/Mostlylucid.Services/Email/EmailService.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.Net.Mail; +using System.Reflection; using FluentEmail.Core; using FluentEmail.Core.Models; using Microsoft.Extensions.Logging; @@ -16,37 +17,41 @@ public class EmailService(SmtpSettings smtpSettings, IFluentEmail fluentEmail, I { private readonly string _nameSpace = typeof(EmailService).Namespace! + ".Templates."; - public async Task SendCommentEmail(CommentEmailModel commentModel) + public async Task SendCommentEmail(CommentEmailModel commentModel) { // Load the template var templatePath = _nameSpace + "CommentMailTemplate.cshtml"; - await SendMail(commentModel, templatePath); + var response= await SendMail(commentModel, templatePath); + return response?.Successful ?? false; } - public async Task SendContactEmail(ContactEmailModel contactModel) + public async Task SendContactEmail(ContactEmailModel contactModel) { var templatePath = _nameSpace + "ContactEmailModel.cshtml"; - await SendMail(contactModel, templatePath); + var response = await SendMail(contactModel, templatePath); + return response?.Successful ?? false; } - public async Task SendConfirmationEmail(ConfirmEmailModel confirmEmailModel) + public async Task SendConfirmationEmail(ConfirmEmailModel confirmEmailModel) { var templatePath = _nameSpace + "ConfirmationMailTemplate.cshtml"; - await SendMail(confirmEmailModel, templatePath, confirmEmailModel.ToEmail); + var response =await SendMail(confirmEmailModel, templatePath, confirmEmailModel.ToEmail); + return response?.Successful ?? false; } - public async Task SendNewsletterEmail(EmailTemplateModel newsletterEmailModel) + public async Task SendNewsletterEmail(EmailTemplateModel newsletterEmailModel) { var templatePath = _nameSpace + "NewsletterTemplate.cshtml"; - await SendMail(newsletterEmailModel, templatePath, newsletterEmailModel.ToEmail); + var response = await SendMail(newsletterEmailModel, templatePath, newsletterEmailModel.ToEmail); + return response?.Successful ?? false; } private async Task SendMail(BaseEmailModel model, string template, string? toEmail = null) { - using var activity = Log.Logger.StartActivity("SendMail"); + using var activity = Log.Logger.StartActivity("SendMail"); try { activity.AddProperty("ToEmail", toEmail); @@ -72,9 +77,15 @@ public async Task SendNewsletterEmail(EmailTemplateModel newsletterEmailModel) return response; } + catch (SmtpException se) + { + activity.Complete(LogEventLevel.Error, se); + logger.LogError(se, "Error sending email"); + throw; + } catch (Exception e) { - activity.Complete(LogEventLevel.Error,e); + activity.Complete(LogEventLevel.Error, e); logger.LogError(e, "Error sending email"); return null; } diff --git a/Mostlylucid.Services/Email/HostedEmailService.cs b/Mostlylucid.Services/Email/HostedEmailService.cs index 70919b4..e5f4a2e 100644 --- a/Mostlylucid.Services/Email/HostedEmailService.cs +++ b/Mostlylucid.Services/Email/HostedEmailService.cs @@ -16,7 +16,7 @@ public class EmailSenderHostedService : IEmailSenderHostedService { private readonly Channel _mailMessages = Channel.CreateUnbounded(); private Task _sendTask = Task.CompletedTask; - private CancellationTokenSource cancellationTokenSource = new(); + private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly IEmailService _emailService; private readonly ILogger _logger; private readonly IAsyncPolicy _policyWrap; @@ -67,7 +67,7 @@ public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting background e-mail delivery"); // Start the background task - _sendTask = DeliverAsync(cancellationTokenSource.Token); + _sendTask = DeliverAsync(_cancellationTokenSource.Token); return Task.CompletedTask; } @@ -76,7 +76,7 @@ public async Task StopAsync(CancellationToken cancellationToken) _logger.LogInformation("Stopping background e-mail delivery"); // Cancel the token to signal the background task to stop - await cancellationTokenSource.CancelAsync(); + await _cancellationTokenSource.CancelAsync(); _mailMessages.Writer.Complete(); // Wait until the background task completes or the cancellation token triggers @@ -138,7 +138,7 @@ await _policyWrap.ExecuteAsync(async () => public void Dispose() { - cancellationTokenSource.Dispose(); + _cancellationTokenSource.Dispose(); } } } \ No newline at end of file diff --git a/Mostlylucid.Services/Email/IEmailService.cs b/Mostlylucid.Services/Email/IEmailService.cs index aa07282..2f796c4 100644 --- a/Mostlylucid.Services/Email/IEmailService.cs +++ b/Mostlylucid.Services/Email/IEmailService.cs @@ -5,8 +5,8 @@ namespace Mostlylucid.Services.Email; public interface IEmailService { - Task SendCommentEmail(CommentEmailModel commentModel); - Task SendContactEmail(ContactEmailModel contactModel); - Task SendConfirmationEmail(ConfirmEmailModel confirmEmailModel); - Task SendNewsletterEmail(EmailTemplateModel newsletterEmailModel); + Task SendCommentEmail(CommentEmailModel commentModel); + Task SendContactEmail(ContactEmailModel contactModel); + Task SendConfirmationEmail(ConfirmEmailModel confirmEmailModel); + Task SendNewsletterEmail(EmailTemplateModel newsletterEmailModel); } \ No newline at end of file diff --git a/Mostlylucid.Shared/Entities/EmailSubscriptionSendLogEntity.cs b/Mostlylucid.Shared/Entities/EmailSubscriptionSendLogEntity.cs index b5148cb..552c3eb 100644 --- a/Mostlylucid.Shared/Entities/EmailSubscriptionSendLogEntity.cs +++ b/Mostlylucid.Shared/Entities/EmailSubscriptionSendLogEntity.cs @@ -9,7 +9,7 @@ public class EmailSubscriptionSendLogEntity { [Key] [DatabaseGenerated(DatabaseGeneratedOption.None)] - [Column(TypeName = "nvarchar(24)")] + [Column(TypeName = "varchar(24)")] public SubscriptionType SubscriptionType { get; set; } diff --git a/Mostlylucid/EmailSubscription/EmailProcessorSetup.cs b/Mostlylucid/EmailSubscription/EmailProcessorSetup.cs index e59d494..55fb8f4 100644 --- a/Mostlylucid/EmailSubscription/EmailProcessorSetup.cs +++ b/Mostlylucid/EmailSubscription/EmailProcessorSetup.cs @@ -1,13 +1,22 @@ using Microsoft.AspNetCore.Mvc.Razor; using Mostlylucid.Services.EmailSubscription; +using Mostlylucid.Shared.Config; namespace Mostlylucid.EmailSubscription; public static class EmailProcessorSetup { - public static void ConfigureEmailProcessor(this IServiceCollection services) + public static void ConfigureEmailProcessor(this IServiceCollection services, ConfigurationManager configurationManager) { services.AddScoped(); - services.AddHttpClient(); + + services.ConfigurePOCO(configurationManager); + + services.AddHttpClient((provider, client)=> + { + var settings = provider.GetRequiredService(); + client.BaseAddress = new Uri(settings.SchedulerServiceUrl); + }).SetHandlerLifetime(TimeSpan.FromMinutes(5)) //Set lifetime to five minutes + .AddPolicyHandler(RetryPolicyExtension.GetRetryPolicy());; } } \ No newline at end of file diff --git a/Mostlylucid/EmailSubscription/NewsletterClient.cs b/Mostlylucid/EmailSubscription/NewsletterClient.cs index 5409ed2..2f42823 100644 --- a/Mostlylucid/EmailSubscription/NewsletterClient.cs +++ b/Mostlylucid/EmailSubscription/NewsletterClient.cs @@ -1,6 +1,15 @@ -namespace Mostlylucid.EmailSubscription; +using System.Text; + +namespace Mostlylucid.EmailSubscription; public class NewsletterClient(HttpClient client) { + public async Task SendNewsletter(string token) + { + var clientCall = new HttpRequestMessage(HttpMethod.Get, "api/sendfortoken"); + clientCall.Content = new StringContent(token, Encoding.UTF8, "application/json"); + var response = await client.SendAsync(clientCall); + + } } \ No newline at end of file diff --git a/Mostlylucid/EmailSubscription/RetryPolicyExtension.cs b/Mostlylucid/EmailSubscription/RetryPolicyExtension.cs new file mode 100644 index 0000000..c5f439f --- /dev/null +++ b/Mostlylucid/EmailSubscription/RetryPolicyExtension.cs @@ -0,0 +1,18 @@ +using System.Net; +using Polly; +using Polly.Contrib.WaitAndRetry; +using Polly.Extensions.Http; + +namespace Mostlylucid.EmailSubscription; + +public static class RetryPolicyExtension +{ + public static IAsyncPolicy GetRetryPolicy() + { + var delay = Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromSeconds(1), 3); + return HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.ServiceUnavailable) + .WaitAndRetryAsync(delay); + } +} \ No newline at end of file diff --git a/Mostlylucid/Markdown/aboutme.md b/Mostlylucid/Markdown/aboutme.md index edda04f..4c435b7 100644 --- a/Mostlylucid/Markdown/aboutme.md +++ b/Mostlylucid/Markdown/aboutme.md @@ -1,18 +1,81 @@ -# About Me +# Resume | Scott Galloway | .NET Developer | Remote -# **Scott Galloway | .NET Developer | Remote** - - - + + I'm a versatile and accomplished developer and lead with an over 25 year track record building teams and platforms and revitalising startups. Proficient in C#, ASP.NET, and modern web frameworks, with extensive experience in cloud computing, DevOps, database management and search technologies. Proven track record of leading successful development projects across diverse industries, from technology giants to innovative startups. -Need help to build your next project? [Contact me](mailto:scott.galloway@gmail.com) +Need help to build your next project? + +**Email:** [scott.galloway@gmail.com](mailto:scott.galloway@gmail.com) + +**Phone:** +44 7498 479 614 + [TOC] -# Employment History +--- + +# Skills + +### Languages & Frameworks +- Server Side: **C#** (25+ years), **JavaScript** (20+ years), **ASP.NET to .NET 8** (25+ years). To a lesser extent: **Python**, **Java**, **C++**, **PHP** +- Frontend: **Vue.js**, **JQuery**, **HTMX**, **Alpine.js**, **React**, **Angular**, **Blazor** (and many more) +- CSS Frameworks: **Tailwind CSS**, **Bootstrap**, **Manual** + +### Databases +- **SQL**: SQL Server, PostgreSQL, MySQL, SQLite +- **NoSQL**: MongoDB, RavenDB + +### Cloud Computing & DevOps +- **Azure**, **Docker**, **Kubernetes** + +### Leadership & Software Development +- Roles: Lead Developer, Senior .NET Developer, Development Lead, Head of Engineering, CTO +- Methodologies: Agile, Scrum, Kanban +- Tools: Jira, Trello, Azure DevOps, GitHub, GitLab +- Training: Developer training program with 90% employment success rate +- Mentorship: Mentored junior developers and led geographically dispersed remote teams + +--- + +## Professional Highlights + +- **Proven Development Expertise**: A 25+ year track record in driving full-stack software development, from hands-on coding to executive leadership roles. +- **Industry Impact**: Made significant contributions to industry-leading organizations like Microsoft and Dell, as well as innovative startups across diverse sectors. +- **Remote Team Leadership**: Spearheaded remote teams, fostering collaboration and innovation across global teams, while designing a developer training program with a 90% employment success rate. +- **Cutting-Edge Technological Focus**: Continuously advancing expertise in modern development stacks, with a strong focus on remote and distributed work methodologies. +- **Strategic Product & Team Builder**: Built and mentored high-performing development teams, leading them to deliver impactful products across industries, ensuring alignment with both business goals and user needs. +--- + +```mermaid +gantt + title Scott Galloway Job Timeline + dateFormat YYYY-MM-DD + axisFormat %Y + + section Employment History + Consulting (Lead Developer) : active, 2012-01-01, 2024-09-29 + Very Jane Ltd (Senior Contract Developer) : 2024-04-01, 2024-08-01 + Seamcor Ltd (Head of Engineering) : 2022-12-01, 2024-04-01 + GBG Plc (Lead Engineer) : 2022-08-01, 2022-11-01 + WCOM AB (Lead Developer) : 2021-10-01, 2022-05-01 + H3Space Ltd (Lead Engineer) : 2021-06-01, 2021-09-01 + Huozhi Ltd (Technical Consultant) : 2018-06-01, 2020-03-01 + Interactive Reporting Ltd (Lead Developer) : 2015-12-01, 2021-06-01 + Numerous Consulting Roles : 2011-07-01, 2015-12-01 + Dell (Development Lead) : 2011-01-01, 2011-07-01 + Microsoft Corp. (Program Manager II) : 2007-01-01, 2009-10-01 + Microsoft Ltd. (Application Developer Consultant) : 2005-06-01, 2007-01-01 + StormID (Lead Developer) : 2003-02-01, 2005-06-01 + VisitScotland (Technical Architect) : 2002-03-01, 2003-02-01 + + +``` + +# Employment History (abbreviated...I've been around a while) + ## Consulting | Lead Developer / Architect / Managing Director | Remote Jan 2012 – Present @@ -54,26 +117,42 @@ Provided technical leadership and team formation for a startup, returning to res Dec 2015 – Jun 2021 Developed a web-based reporting platform using ASP.NET MVC, WPF, and WinForms, supporting multiple database backends. + +## Dell | Development Lead | Glasgow, UK +Jan 2011 - July 2011 +Led the development of a custom machine image deployment platform using ASP.NET MVC and SQL Server. + + ## Microsoft Corp. | Program Manager II | Redmond, WA, USA Jan 2007 – Oct 2009 Drove the ASP.NET release lifecycle, managing bug triage and integration with the broader .NET community. Delivered core features and new security infrastructure for Project Server. + +## Microsoft Ltd. | Application Developer Consultant II | Reading, UK +Jun 2005 – Jan 2007 + +Specialised in performance analysis and tuning for a wide variety of customers ranging from a large NHS project through smaller travel companies, software for the UK police etc. I led multiple performance labs in the Microsoft UK labs as well as in HPC applications in Stuttgart. +Aided customers to deliver a very wide variety of systems whilst also liaising with numerous product teams at Microsoft corporate to assist customers in solving their development issues. + +## StormID | Lead Developer | Edinburgh, UK +Feb 2003 - Jun 2005 + +Fast paced agency environment where I worked on many and varied projects from a massively scalable (.5 million+) mail merge system using ASP.NET, Windows Services etc through an Education Portal for Microsoft UK and multiple custom ecommerce systems which variously increased conversion rates by orders of magnitude. + +## VisitScotland – Technical Architect | Edinburgh, UK +Mar 2002 - Feb 2003 + +Requirement for a ‘templatable’ number of websites based on newer technologies; existing skillset of developers was using a complex CORBA system which was no longer fit for purpose. +Directed training, architecture, workflow for the team of 20 developers to move to a J2EE / MVC based system which allowed the simple delivery of multiple themed websites based on a single expandable platform. + + # Education University of Stirling | BSc (Hons) Psychology Start Date: Sep 1992 - End Date: Jun 1996 Location: Stirling, Scotland, UK -# Skills -Languages & Frameworks: C# (25 years), JavaScript (20 years), ASP.NET to .NET 8 (25 years), Node.js, React, Vue -Databases: SQL (SQL Server, Postgres, MySQL, SQLite), NoSQL (MongoDB, RavenDb) -Cloud Computing & DevOps: Azure, Docker, Kubernetes -Software Development & Leadership: Development Lead, Head of Engineering, CTO -Professional Highlights -Versatile Development Expertise: Over 25 years in software application development and strategy from coding to executive leadership. -Diverse Industry Impact: Contributions to technology giants like Microsoft and Dell, and startups in various sectors. -Remote Leadership & Education: Successfully led remote teams and created a developer training program with a 90% employment success rate. -Recent Technological Focus: Proficient in modern development stacks and adapted to remote work during the global pandemic. + # Links LinkedIn: [Scott Galloway](https://www.linkedin.com/in/scott-galloway-91608691/) diff --git a/Mostlylucid/Markdown/acopybuttonforhightlightjs.md b/Mostlylucid/Markdown/acopybuttonforhightlightjs.md new file mode 100644 index 0000000..8c26c29 --- /dev/null +++ b/Mostlylucid/Markdown/acopybuttonforhightlightjs.md @@ -0,0 +1,148 @@ +# A Copy Button For Highlight.js + + + +# Introduction +In this site I use Hightlight.js to render code snippets client side. I like this as it keeps my server side code clean and simple. However, I wanted to add a copy button to each code snippet so that users could easily copy the code to their clipboard. This is a simple task but I thought I would document it here for anyone else who might want to do the same. + +Oh and this is all waiting to me adding the newsletter functionality to actually show up on the site. As soon as I get the energy to do that I'll add this in. + +The endpoint is that we have a copy button like this on the site: +![Copy Button](copybutton.png) + +**NOTE: All credit for this article goes to [Faraz Patankar](https://dev.to/farazpatankar/highlightjs-copy-button-plugin-3dld) who's article I used to create this one. I just wanted to document the changes I made to it here for my own reference and to share with others.** + +[TOC] + +# The Options +There's a couple of ways to do this; for example there's a [copy button plugin](https://github.com/arronhunt/highlightjs-copy) for Higlight.js but I decided I wanted more control over the button and the styling. So I came across [this article](https://dev.to/farazpatankar/highlightjs-copy-button-plugin-3dld) for adding a copy button. + +## The Problems +While that article is a great approach it had a couple of issues that stopped it being perfect for me: + +1. It uses a font that I don't use on my site (but then manually has the SVG too, not sure why). +```javascript + // Lucide copy icon + copyButton.innerHTML = ``; +``` +While this works, I already use BoxIcons on this site which has a [copy icon in there already](https://icon-sets.iconify.design/bx/copy/). + +2. It uses a toast library which I don't have on this site. +```javascript + // Notify user that the content has been copied + toast.success("Copied to clipboard", { + description: "The code block content has been copied to the clipboard.", + }); +``` +3. It knocks out the y-overflow on the code block and puts the icon at the bottom which I didn't want. So mine is top right. + +# My Adaptations + +## The Main Function +This plugin hooks into the `after:highlightElement` event and adds a button to the code block. + +So first I copied Faraz's code and then made the following changes: + +1. Instead of appending it to the end of the code block I prepend it to the start. +2. Instead of the SVG I used the BoxIcons version by just adding those classes to the inserted button & setting the textsize to `text-xl`. +3. I removed the toast notification and replaced it with a simple `showToast` function that I have in my site (see later) +4. I added an `aria-label` and `title` to the button for accessibility (and to give a nice hover effect). + +```javascript +hljs.addPlugin({ + "after:highlightElement": ({ el, text }) => { + const wrapper = el.parentElement; + if (wrapper == null) { + return; + } + + /** + * Make the parent relative so we can absolutely + * position the copy button + */ + wrapper.classList.add("relative"); + const copyButton = document.createElement("button"); + copyButton.classList.add( + "absolute", + "top-2", + "right-1", + "p-2", + "text-gray-500", + "hover:text-gray-700", + "bx", + "bx-copy", + "text-xl", + "cursor-pointer" + ); + copyButton.setAttribute("aria-label", "Copy code to clipboard"); + copyButton.setAttribute("title", "Copy code to clipboard"); + + copyButton.onclick = () => { + navigator.clipboard.writeText(text); + + // Notify user that the content has been copied + showToast("The code block content has been copied to the clipboard.", 3000, "success"); + + }; + // Append the copy button to the wrapper + wrapper.prepend(copyButton); + }, +}); +``` + +## `showToast` Function +This relies on a razor partial I added to my project. +This partial uses the [DaisyUI Toast component](https://daisyui.com/components/toast/) to show a message to the user. +I like this approach as it keeps the Javascript clean and simple and allows me to style the toast message in the same way as the rest of the site. + + +```html + +``` +You'll note this also has an odd hidden `p` tag at the bottom, this is just for Tailwind to parse these classes when it builds the site's CSS. + +The Javascript function is simple, it just shows the toast message for a set time and then hides it again. + +```javascript +window.showToast = function(message, duration = 3000, type = 'success') { + const toast = document.getElementById('toast'); + const toastText = document.getElementById('toast-text'); + const toastMessage = document.getElementById('toast-message'); + + // Set message and type + toastText.innerText = message; + toastMessage.className = `alert alert-${type}`; // Change alert type (success, warning, error) + + // Show the toast + toast.classList.remove('hidden'); + + // Hide the toast after specified duration + setTimeout(() => { + toast.classList.add('hidden'); + }, duration); +} +``` + +We can then call this using the `showToast` function in the `copyButton.onclick` event. + +```javascript +showToast("The code block content has been copied to the clipboard.", 3000, "success"); +``` +I added this partial right at the top of my `_Layout.cshtml` file so it's available on every page. + +```html +`` +``` + +Now when we show blog posts the code blocks have: +![Copy Button](copybutton.png) + +# In Conclusion +So that's it, a simple change to Faraz's code to make it work for me. I hope this helps someone else out there. \ No newline at end of file diff --git a/Mostlylucid/Markdown/customconfigsectionextensions.md b/Mostlylucid/Markdown/customconfigsectionextensions.md index 9b331b4..95fd3f8 100644 --- a/Mostlylucid/Markdown/customconfigsectionextensions.md +++ b/Mostlylucid/Markdown/customconfigsectionextensions.md @@ -60,6 +60,17 @@ var newsletterConfig = services.ConfigurePOCO(config); ``` +Or even for the `WebApplicationBuilder` like so: + +```csharp +var newsletterConfig = builder.Configure(); +``` +Note: As the builder has access to the `ConfigurationManager` it doesn't need to have that passed in. + +Meaning you now have options about how to bind config. + +Another advantage is that if you need to use these values later in your `Program.cs` file then the object is available to you. + # The Extension Method To enable all this we have a fairly simple extension method that does the work of binding the configuration to the class. diff --git a/Mostlylucid/Mostlylucid.csproj b/Mostlylucid/Mostlylucid.csproj index d439a2f..fed48a2 100644 --- a/Mostlylucid/Mostlylucid.csproj +++ b/Mostlylucid/Mostlylucid.csproj @@ -79,6 +79,7 @@ + diff --git a/Mostlylucid/Program.cs b/Mostlylucid/Program.cs index e5a0003..b37f995 100644 --- a/Mostlylucid/Program.cs +++ b/Mostlylucid/Program.cs @@ -82,12 +82,12 @@ services.AddScoped(); services.AddScoped(); services.AddImageSharp().Configure(options => options.CacheFolder = "cache"); - services.SetupEmail(builder.Configuration); + services.SetupEmail(config); services.SetupRSS(); services.SetupBlog(config, builder.Environment); services.SetupUmamiClient(config); - services.ConfigureEmailProcessor(); + services.ConfigureEmailProcessor(config); services.AddAntiforgery(options => { options.HeaderName = "X-CSRF-TOKEN"; @@ -219,6 +219,11 @@ catch (Exception ex) { + if(args.Contains("migrate")) + { + Log.Information("Migration complete"); + return; + } Log.Fatal(ex, "Application terminated unexpectedly"); } finally diff --git a/Mostlylucid/Properties/launchSettings.json b/Mostlylucid/Properties/launchSettings.json index 886b2d7..ab7514b 100644 --- a/Mostlylucid/Properties/launchSettings.json +++ b/Mostlylucid/Properties/launchSettings.json @@ -22,7 +22,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, -// "applicationUrl": "https://localhost:7240;http://localhost:5118", + "applicationUrl": "https://localhost:7240;http://localhost:8080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "CertPassword": "melkweg26" diff --git a/Mostlylucid/Views/Home/_HomePartial.cshtml b/Mostlylucid/Views/Home/_HomePartial.cshtml index bcbd442..73a16d8 100644 --- a/Mostlylucid/Views/Home/_HomePartial.cshtml +++ b/Mostlylucid/Views/Home/_HomePartial.cshtml @@ -10,7 +10,7 @@

- Subscribe + This is a work in progress site where I share my thoughts, ideas, and projects. I'm always building SOMETHING so decided too share both how I build these somethings and what I build. Note this site is currently being developed you can see the code here. Bits will be broken or missing, but that's the fun of it. If you have any comments / suggestions / feedback please let me know on Mastodon

@@ -30,13 +30,13 @@ Hire Me! - + My Résumé (Word) - + My Résumé (PDF) diff --git a/Mostlylucid/Views/Shared/_Toast.cshtml b/Mostlylucid/Views/Shared/_Toast.cshtml index a07e309..b87e77a 100644 --- a/Mostlylucid/Views/Shared/_Toast.cshtml +++ b/Mostlylucid/Views/Shared/_Toast.cshtml @@ -1,8 +1,8 @@ -