Skip to content

Commit 450147f

Browse files
committed
Add blog series feature. New admin editing page, series selection when editing blog posts, and series display for readers.
1 parent a031699 commit 450147f

14 files changed

Lines changed: 681 additions & 9 deletions

File tree

src/LinkDotNet.Blog.Domain/BlogPost.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public sealed partial class BlogPost : Entity
3030

3131
public int Likes { get; set; }
3232

33+
public int Order { get; set; }
34+
3335
public bool IsScheduled => ScheduledPublishDate is not null;
3436

3537
public string TagsAsString => string.Join(",", Tags);
@@ -40,6 +42,10 @@ public sealed partial class BlogPost : Entity
4042

4143
public string? AuthorName { get; private set; }
4244

45+
public string? SeriesId { get; private set; }
46+
47+
public Series? Series { get; private set; }
48+
4349
private string GenerateSlug()
4450
{
4551
if (string.IsNullOrWhiteSpace(Title))
@@ -95,7 +101,9 @@ public static BlogPost Create(
95101
DateTime? scheduledPublishDate = null,
96102
IEnumerable<string>? tags = null,
97103
string? previewImageUrlFallback = null,
98-
string? authorName = null)
104+
string? authorName = null,
105+
string? seriesId = null,
106+
int order = 0)
99107
{
100108
if (scheduledPublishDate is not null && isPublished)
101109
{
@@ -116,7 +124,9 @@ public static BlogPost Create(
116124
IsPublished = isPublished,
117125
Tags = tags?.Select(t => t.Trim()).ToImmutableArray() ?? [],
118126
ReadingTimeInMinutes = ReadingTimeCalculator.CalculateReadingTime(content),
119-
AuthorName = authorName
127+
AuthorName = authorName,
128+
SeriesId = seriesId,
129+
Order = order
120130
};
121131

122132
return blogPost;
@@ -128,6 +138,22 @@ public void Publish()
128138
IsPublished = true;
129139
}
130140

141+
public void RemoveFromSeries()
142+
{
143+
SeriesId = null;
144+
Series = null;
145+
Order = 0;
146+
}
147+
148+
public void AssignToSeries(string seriesId, int order)
149+
{
150+
ArgumentException.ThrowIfNullOrEmpty(seriesId);
151+
152+
SeriesId = seriesId;
153+
Series = null;
154+
Order = order;
155+
}
156+
131157
public void Update(BlogPost from)
132158
{
133159
ArgumentNullException.ThrowIfNull(from);
@@ -148,5 +174,7 @@ public void Update(BlogPost from)
148174
Tags = from.Tags;
149175
ReadingTimeInMinutes = from.ReadingTimeInMinutes;
150176
AuthorName = from.AuthorName;
177+
SeriesId = from.SeriesId;
178+
Order = from.Order;
151179
}
152180
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
using System;
2+
3+
namespace LinkDotNet.Blog.Domain;
4+
5+
public sealed class Series : Entity
6+
{
7+
public string Name { get; set; } = default!;
8+
}

src/LinkDotNet.Blog.Infrastructure/Persistence/MongoDB/Repository.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,20 @@ public async ValueTask<HealthCheckResult> PerformHealthCheckAsync()
4040
{
4141
var filter = Builders<TEntity>.Filter.Eq(e => e.Id, id);
4242
using var result = await Collection.FindAsync(filter);
43-
return await result.FirstOrDefaultAsync();
43+
var entity = await result.FirstOrDefaultAsync();
44+
45+
if (entity is BlogPost { SeriesId: not null } blogPost)
46+
{
47+
var seriesCollection = database.GetCollection<Series>(nameof(Series));
48+
var seriesFilter = Builders<Series>.Filter.Eq(s => s.Id, blogPost.SeriesId);
49+
var seriesResult = await seriesCollection.FindAsync(seriesFilter);
50+
var series = await seriesResult.FirstOrDefaultAsync();
51+
52+
var seriesProperty = typeof(BlogPost).GetProperty(nameof(BlogPost.Series));
53+
seriesProperty?.SetValue(blogPost, series);
54+
}
55+
56+
return entity;
4457
}
4558

4659
public async ValueTask<IPagedList<TEntity>> GetAllAsync(Expression<Func<TEntity, bool>>? filter = null,
@@ -105,9 +118,9 @@ public async ValueTask StoreBulkAsync(IReadOnlyCollection<TEntity> records)
105118
{
106119
ArgumentNullException.ThrowIfNull(records);
107120

108-
if (records.Count != 0)
121+
foreach (var record in records)
109122
{
110-
await Collection.InsertManyAsync(records);
123+
await StoreAsync(record);
111124
}
112125
}
113126
}

src/LinkDotNet.Blog.Infrastructure/Persistence/RavenDb/Repository.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,16 @@ public async ValueTask<HealthCheckResult> PerformHealthCheckAsync()
3636
public async ValueTask<TEntity?> GetByIdAsync(string id)
3737
{
3838
using var session = documentStore.OpenAsyncSession();
39-
return await session.LoadAsync<TEntity>(id);
39+
var entity = await session.LoadAsync<TEntity>(id);
40+
41+
if (entity is BlogPost { SeriesId: not null } blogPost)
42+
{
43+
var series = await session.LoadAsync<Series>(blogPost.SeriesId);
44+
var seriesProperty = typeof(BlogPost).GetProperty(nameof(BlogPost.Series));
45+
seriesProperty?.SetValue(blogPost, series);
46+
}
47+
48+
return entity;
4049
}
4150

4251
public ValueTask<IPagedList<TEntity>> GetAllAsync(Expression<Func<TEntity, bool>>? filter = null,

src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/BlogDbContext.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public BlogDbContext(DbContextOptions options)
1515

1616
public DbSet<BlogPost> BlogPosts { get; set; }
1717

18+
public DbSet<Series> Series { get; set; }
19+
1820
public DbSet<ProfileInformationEntry> ProfileInformationEntries { get; set; }
1921

2022
public DbSet<Skill> Skills { get; set; }
@@ -38,6 +40,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
3840
ArgumentNullException.ThrowIfNull(modelBuilder);
3941

4042
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
43+
modelBuilder.ApplyConfiguration(new SeriesConfiguration());
4144
modelBuilder.ApplyConfiguration(new BlogPostRecordConfiguration());
4245
modelBuilder.ApplyConfiguration(new BlogPostTemplateConfiguration());
4346
modelBuilder.ApplyConfiguration(new BlogPostVersionConfiguration());

src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Mapping/BlogPostConfiguration.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,16 @@ public void Configure(EntityTypeBuilder<BlogPost> builder)
1919
builder.Property(x => x.ShortDescription).IsRequired();
2020
builder.Property(x => x.Likes).IsRequired();
2121
builder.Property(x => x.IsPublished).IsRequired();
22+
builder.Property(x => x.Order).IsRequired();
2223

2324
builder.Property(x => x.Tags).HasMaxLength(2048);
2425
builder.Property(x => x.AuthorName).HasMaxLength(256).IsRequired(false);
26+
builder.Property(x => x.SeriesId).IsUnicode(false).IsRequired(false);
27+
28+
builder.HasOne(x => x.Series)
29+
.WithMany()
30+
.HasForeignKey(x => x.SeriesId)
31+
.OnDelete(DeleteBehavior.SetNull);
2532

2633
builder.HasIndex(x => new { x.IsPublished, x.UpdatedDate })
2734
.HasDatabaseName("IX_BlogPosts_IsPublished_UpdatedDate")
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using LinkDotNet.Blog.Domain;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
4+
5+
namespace LinkDotNet.Blog.Infrastructure.Persistence.Sql.Mapping;
6+
7+
internal sealed class SeriesConfiguration : IEntityTypeConfiguration<Series>
8+
{
9+
public void Configure(EntityTypeBuilder<Series> builder)
10+
{
11+
builder.HasKey(c => c.Id);
12+
builder.Property(c => c.Id)
13+
.IsUnicode(false)
14+
.ValueGeneratedOnAdd();
15+
builder.Property(x => x.Name).HasMaxLength(256).IsRequired();
16+
}
17+
}

src/LinkDotNet.Blog.Infrastructure/Persistence/Sql/Repository.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ public async ValueTask<HealthCheckResult> PerformHealthCheckAsync()
3939
public async ValueTask<TEntity?> GetByIdAsync(string id)
4040
{
4141
await using var blogDbContext = await dbContextFactory.CreateDbContextAsync();
42-
return await blogDbContext.Set<TEntity>().FirstOrDefaultAsync(b => b.Id == id);
42+
var query = blogDbContext.Set<TEntity>().AsQueryable();
43+
44+
if (typeof(TEntity) == typeof(BlogPost))
45+
{
46+
query = query.Include("Series");
47+
}
48+
49+
return await query.FirstOrDefaultAsync(b => b.Id == id);
4350
}
4451

4552
public ValueTask<IPagedList<TEntity>> GetAllAsync(Expression<Func<TEntity, bool>>? filter = null,
@@ -150,7 +157,15 @@ async Task StoreBulkAsyncInBatchesAsync()
150157
var count = 0;
151158
foreach (var record in records)
152159
{
153-
await blogDbContext.Set<TEntity>().AddAsync(record);
160+
if (string.IsNullOrEmpty(record.Id))
161+
{
162+
await blogDbContext.Set<TEntity>().AddAsync(record);
163+
}
164+
else
165+
{
166+
blogDbContext.Entry(record).State = EntityState.Modified;
167+
}
168+
154169
if (++count % 1000 == 0)
155170
{
156171
LogBatch(count);

0 commit comments

Comments
 (0)