Skip to content

Commit

Permalink
make 90% of the backend for posts (not groupposts yet)
Browse files Browse the repository at this point in the history
  • Loading branch information
NielsPilgaard committed Aug 30, 2024
1 parent abd8d32 commit 8b0ca9d
Show file tree
Hide file tree
Showing 13 changed files with 392 additions and 8 deletions.
4 changes: 2 additions & 2 deletions src/shared/Jordnaer.Shared/Database/GroupCategory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace Jordnaer.Shared;

public class GroupCategory
{
public required Guid GroupId { get; set; }
public required Guid GroupId { get; set; }

public required int CategoryId { get; set; }
public required int CategoryId { get; set; }
}
30 changes: 30 additions & 0 deletions src/shared/Jordnaer.Shared/Database/GroupPost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.ComponentModel.DataAnnotations;

namespace Jordnaer.Shared;

public class GroupPost
{

[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public required Guid Id { get; init; }

[StringLength(1000, ErrorMessage = "Opslag må højest være 1000 karakterer lang.")]
[Required(AllowEmptyStrings = false, ErrorMessage = "Opslag skal have mindst 1 karakter.")]
public required string Text { get; init; }

public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;

public int? ZipCode { get; set; }

[ForeignKey(nameof(UserProfile))]
public required string UserProfileId { get; init; } = null!;

public UserProfile UserProfile { get; init; } = null!;

[ForeignKey(nameof(Group))]
public required Guid GroupId { get; init; }

public Group Group { get; init; } = null!;
}
27 changes: 27 additions & 0 deletions src/shared/Jordnaer.Shared/Database/Post.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Jordnaer.Shared;

public class Post
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public required Guid Id { get; init; }

[StringLength(1000, ErrorMessage = "Opslag må højest være 1000 karakterer lang.")]
[Required(AllowEmptyStrings = false, ErrorMessage = "Opslag skal have mindst 1 karakter.")]
public required string Text { get; init; }

public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;

public int? ZipCode { get; set; }
public string? City { get; set; }

[ForeignKey(nameof(UserProfile))]
public required string UserProfileId { get; init; } = null!;

public UserProfile UserProfile { get; init; } = null!;

public List<Category> Categories { get; set; } = [];
}
8 changes: 8 additions & 0 deletions src/shared/Jordnaer.Shared/Database/PostCategory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Jordnaer.Shared;

public class PostCategory
{
public required Guid PostId { get; set; }

public required int CategoryId { get; set; }
}
24 changes: 24 additions & 0 deletions src/shared/Jordnaer.Shared/Extensions/PostExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace Jordnaer.Shared;

public static class PostExtensions
{
public static PostDto ToPostDto(this Post post)
{
return new PostDto
{
Id = post.Id,
Text = post.Text,
CreatedUtc = post.CreatedUtc,
Author = new UserSlim
{
Id = post.UserProfileId,
ProfilePictureUrl = post.UserProfile.ProfilePictureUrl,
UserName = post.UserProfile.UserName,
DisplayName = post.UserProfile.DisplayName
},
City = post.City,
ZipCode = post.ZipCode,
Categories = post.Categories.Select(category => category.Name).ToList()
};
}
}
21 changes: 21 additions & 0 deletions src/shared/Jordnaer.Shared/Posts/PostDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;

namespace Jordnaer.Shared;

public class PostDto
{
public required Guid Id { get; init; }

[StringLength(1000, ErrorMessage = "Opslag må højest være 1000 karakterer lang.")]
[Required(AllowEmptyStrings = false, ErrorMessage = "Opslag skal have mindst 1 karakter.")]
public required string Text { get; init; }

public int? ZipCode { get; set; }
public string? City { get; set; }

public DateTimeOffset CreatedUtc { get; init; }

public required UserSlim Author { get; init; }

public List<string> Categories { get; set; } = [];
}
57 changes: 57 additions & 0 deletions src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;

namespace Jordnaer.Shared;

public class PostSearchFilter
{
public string? Contents { get; set; }
public string[]? Categories { get; set; } = [];

/// <summary>
/// Only show user results within this many kilometers of the <see cref="Location"/>.
/// </summary>
[Range(1, 50, ErrorMessage = "Afstand skal være mellem 1 og 50 km")]
[LocationRequired]
public int? WithinRadiusKilometers { get; set; }

[RadiusRequired]
public string? Location { get; set; }

public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 10;
}

file class RadiusRequiredAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
{
var postSearchFilter = (PostSearchFilter)validationContext.ObjectInstance;

if (postSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(postSearchFilter.Location))
{
return ValidationResult.Success!;
}

return postSearchFilter.WithinRadiusKilometers is null
? new ValidationResult("Radius skal vælges når et område er valgt.")
: ValidationResult.Success!;
}
}

file class LocationRequiredAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
{
var postSearchFilter = (PostSearchFilter)validationContext.ObjectInstance;

if (postSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(postSearchFilter.Location))
{
return ValidationResult.Success!;

}

return string.IsNullOrEmpty(postSearchFilter.Location)
? new ValidationResult("Område skal vælges når en radius er valgt.")
: ValidationResult.Success!;
}
}
7 changes: 7 additions & 0 deletions src/shared/Jordnaer.Shared/Posts/PostSearchResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Jordnaer.Shared;

public class PostSearchResult
{
public List<PostDto> Posts { get; set; } = [];
public int TotalCount { get; set; }
}
22 changes: 21 additions & 1 deletion src/web/Jordnaer/Database/JordnaerDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class JordnaerDbContext : IdentityDbContext<ApplicationUser>
public DbSet<Category> Categories { get; set; } = default!;
public DbSet<UserProfileCategory> UserProfileCategories { get; set; } = default!;
public DbSet<UserContact> UserContacts { get; set; } = default!;
public DbSet<Shared.Chat> Chats { get; set; } = default!;
public DbSet<Chat> Chats { get; set; } = default!;
public DbSet<ChatMessage> ChatMessages { get; set; } = default!;
public DbSet<UnreadMessage> UnreadMessages { get; set; } = default;
public DbSet<UserChat> UserChats { get; set; } = default!;
Expand All @@ -20,8 +20,28 @@ public class JordnaerDbContext : IdentityDbContext<ApplicationUser>
public DbSet<GroupMembership> GroupMemberships { get; set; } = default!;
public DbSet<GroupCategory> GroupCategories { get; set; } = default!;

public DbSet<Post> Posts { get; set; } = default!;
public DbSet<GroupPost> GroupPosts { get; set; } = default!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Post>()
.HasOne(e => e.UserProfile)
.WithMany();

modelBuilder.Entity<Post>()
.HasMany(e => e.Categories)
.WithMany()
.UsingEntity<PostCategory>();

modelBuilder.Entity<GroupPost>()
.HasOne(e => e.UserProfile)
.WithMany();

modelBuilder.Entity<GroupPost>()
.HasOne(e => e.Group)
.WithMany();

modelBuilder.Entity<Group>()
.HasMany(e => e.Members)
.WithMany(e => e.Groups)
Expand Down
5 changes: 5 additions & 0 deletions src/web/Jordnaer/Features/Metrics/JordnaerMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ internal static class JordnaerMetrics
internal static readonly Counter<int> UserSearchesCounter =
Meter.CreateCounter<int>("jordnaer_user_user_searches_total");

internal static readonly Counter<int> PostSearchesCounter =
Meter.CreateCounter<int>("jordnaer_post_post_searches_total");
internal static readonly Counter<int> PostsCreatedCounter =
Meter.CreateCounter<int>("jordnaer_post_posts_created_total");

internal static readonly Counter<int> SponsorAdViewCounter =
Meter.CreateCounter<int>("jordnaer_ad_sponsor_views_total");
}
109 changes: 109 additions & 0 deletions src/web/Jordnaer/Features/PostSearch/PostSearchService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using Jordnaer.Database;
using Jordnaer.Features.Metrics;
using Jordnaer.Features.Search;
using Jordnaer.Shared;
using Microsoft.EntityFrameworkCore;

namespace Jordnaer.Features.PostSearch;

public interface IPostSearchService
{
Task<PostSearchResult> GetPostsAsync(PostSearchFilter filter,
CancellationToken cancellationToken = default);
}

public class PostSearchService(
IDbContextFactory<JordnaerDbContext> contextFactory,
IZipCodeService zipCodeService) : IPostSearchService
{
public async Task<PostSearchResult> GetPostsAsync(PostSearchFilter filter,
CancellationToken cancellationToken = default)
{
JordnaerMetrics.PostSearchesCounter.Add(1, MakeTagList(filter));

await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

var query = context.Posts
.AsNoTracking()
.AsQueryable();

query = ApplyCategoryFilter(filter, query);
query = await ApplyLocationFilterAsync(filter, query, cancellationToken);
query = ApplyContentFilter(filter.Contents, query);

var postsToSkip = filter.PageNumber == 1
? 0
: (filter.PageNumber - 1) * filter.PageSize;

var posts = await query.OrderByDescending(x => x.CreatedUtc)
.Skip(postsToSkip)
.Take(filter.PageSize)
.Select(x => x.ToPostDto())
.ToListAsync(cancellationToken);

var totalCount = await query.CountAsync(cancellationToken);

return new PostSearchResult
{
Posts = posts,
TotalCount = totalCount
};
}

internal async Task<IQueryable<Post>> ApplyLocationFilterAsync(
PostSearchFilter filter,
IQueryable<Post> posts,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(filter.Location) || filter.WithinRadiusKilometers is null)
{
return posts;
}

var (zipCodesWithinCircle, searchedZipCode) = await zipCodeService.GetZipCodesNearLocationAsync(
filter.Location,
filter.WithinRadiusKilometers.Value,
cancellationToken);

if (zipCodesWithinCircle.Count is 0 || searchedZipCode is null)
{
return posts;
}

posts = posts.Where(user => user.ZipCode != null &&
zipCodesWithinCircle.Contains(user.ZipCode.Value));

return posts;
}

internal static IQueryable<Post> ApplyCategoryFilter(PostSearchFilter filter, IQueryable<Post> posts)
{
if (filter.Categories is not null && filter.Categories.Length > 0)
{
posts = posts.Where(
user => user.Categories.Any(category => filter.Categories.Contains(category.Name)));
}

return posts;
}

internal static IQueryable<Post> ApplyContentFilter(string? filter, IQueryable<Post> posts)
{
if (!string.IsNullOrWhiteSpace(filter))
{
posts = posts.Where(post => EF.Functions.Like(post.Text, $"%{filter}%"));
}

return posts;
}

private static ReadOnlySpan<KeyValuePair<string, object?>> MakeTagList(PostSearchFilter filter)
{
return new KeyValuePair<string, object?>[]
{
new(nameof(filter.Location), filter.Location),
new(nameof(filter.Categories), string.Join(',', filter.Categories ?? [])),
new(nameof(filter.WithinRadiusKilometers), filter.WithinRadiusKilometers)
};
}
}
Loading

0 comments on commit 8b0ca9d

Please sign in to comment.