Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/posts #421

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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!;
}
}
NielsPilgaard marked this conversation as resolved.
Show resolved Hide resolved

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!;
}
}
NielsPilgaard marked this conversation as resolved.
Show resolved Hide resolved
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
};
}
NielsPilgaard marked this conversation as resolved.
Show resolved Hide resolved

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;
}
NielsPilgaard marked this conversation as resolved.
Show resolved Hide resolved

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;
}
NielsPilgaard marked this conversation as resolved.
Show resolved Hide resolved

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;
}
NielsPilgaard marked this conversation as resolved.
Show resolved Hide resolved

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
Loading