From d4663653483f217809313dd4f72007ed4b44480f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aeolin=20Ferj=C3=BCnnoz?= Date: Sun, 12 Apr 2026 02:23:26 +0200 Subject: [PATCH] - implement most controllers --- .../ApiModels/Prompt/FeedSortType.cs | 8 + OnlyPrompt.Backend/ApiModels/Prompt/Models.cs | 6 + .../ApiModels/Prompt/Requests.cs | 6 + .../ApiModels/UserProfile/Requests.cs | 8 + .../Controllers/AdminController.cs | 78 +++++++- .../Controllers/AuthController.cs | 6 +- .../Controllers/BaseController.cs | 10 + .../Controllers/FeedController.cs | 70 +++++++ .../Controllers/ProfileController.cs | 39 ++++ .../Controllers/PromptController.cs | 180 ++++++++++++++++++ .../Database/Core/EntityBase.cs | 2 +- .../Database/Core/ITrackableEntity.cs | 8 + OnlyPrompt.Backend/Database/ModelConstants.cs | 8 +- .../Database/Models/PromptModel.cs | 10 +- .../Database/Models/ReviewModel.cs | 7 +- .../Database/Models/UserModel.cs | 2 +- .../Database/OnlyPromptContext.cs | 35 ++++ OnlyPrompt.Backend/OnlyPrompt.Backend.csproj | 2 - OnlyPrompt.Backend/Program.cs | 4 +- .../Properties/launchSettings.json | 2 + OnlyPrompt.Backend/Utils/AutoMapperSetup.cs | 29 +++ .../Utils/AutomapperExtensions.cs | 2 + OnlyPrompt.Backend/Utils/Extensions.cs | 9 + OnlyPrompt.Backend/Utils/SlugHelper.cs | 2 +- 24 files changed, 514 insertions(+), 19 deletions(-) create mode 100644 OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs create mode 100644 OnlyPrompt.Backend/ApiModels/Prompt/Models.cs create mode 100644 OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs create mode 100644 OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs create mode 100644 OnlyPrompt.Backend/Controllers/FeedController.cs create mode 100644 OnlyPrompt.Backend/Controllers/PromptController.cs create mode 100644 OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs b/OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs new file mode 100644 index 0000000..7f60f48 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs @@ -0,0 +1,8 @@ +namespace OnlyPrompt.Backend.ApiModels.Prompt +{ + public enum FeedSortType + { + Date, + Rating + } +} diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs b/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs new file mode 100644 index 0000000..338ff18 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Prompt/Models.cs @@ -0,0 +1,6 @@ +namespace OnlyPrompt.Backend.ApiModels.Prompt +{ + public record ApiPrompt(Guid Id, string Title, string Description, string Content, DateTime TimeStamp, Guid CreatorId, string CreatorName, int? TierLevel, string? TierName, double? AverageRating); + public record ApiMinimalPrompt(Guid Id, string Title, DateTime TimeStamp, Guid CreatorId, string CreatorName, int? TierLevel, string? TierName, double? AverageRating, bool CanAccess); + public record ApiReview(Guid CreatorId, string CreatorName, string? Comment, int Rating); +} diff --git a/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs b/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs new file mode 100644 index 0000000..d9b1fb6 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs @@ -0,0 +1,6 @@ +using OnlyPrompt.Backend.Utils; + +namespace OnlyPrompt.Backend.ApiModels.Prompt +{ + public record ApiCreatePromptRequest(string Title, string Description, string Content, Identifier Category, int? SubscriptionTier, string Slug); +} diff --git a/OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs b/OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs new file mode 100644 index 0000000..b8538d1 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs @@ -0,0 +1,8 @@ +using OnlyPrompt.Backend.ApiModels.Validators; +using System.ComponentModel.DataAnnotations; + +namespace OnlyPrompt.Backend.ApiModels.UserProfile +{ + public record ApiUpdateProfileRequest([MaxLength(100)] string? DisplayName, [MaxLength(100)][NoWhitespace] string? Slug, string? Bio, string? AvatarUrl, string? Specialities, bool IsPublic); + public record ApiCreateReviewRequest(string? Comment, [Range(1, 5)] int Rating); +} diff --git a/OnlyPrompt.Backend/Controllers/AdminController.cs b/OnlyPrompt.Backend/Controllers/AdminController.cs index 8b657c1..475365e 100644 --- a/OnlyPrompt.Backend/Controllers/AdminController.cs +++ b/OnlyPrompt.Backend/Controllers/AdminController.cs @@ -1,11 +1,83 @@ -using OnlyPrompt.Backend.Database; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; namespace OnlyPrompt.Backend.Controllers { + [ApiController] + [Route("api/v1/admin")] + [Authorize(Roles = ModelConstants.AdminRole)] public class AdminController : BaseController { - public AdminController(OnlyPromptContext db) : base(db) - { + public AdminController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + private Task GetNonAdminUserAsync(Guid id, bool isSysAdmin = false) + { + return _db.Users.FirstOrDefaultAsync( + u => u.Id == id + && (isSysAdmin || u.Roles.Contains(ModelConstants.AdminRole) == false) + && u.Roles.Contains(ModelConstants.SysAdminRole) == false + ); + } + + [HttpPost("users/{userId}/disable")] + public async Task>> DisableUserAsync(Guid userId) + { + var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole)); + if (user is null) + return TypedResults.NotFound("User not found."); + + user.IsLockoutEnabled = true; + await _db.SaveChangesAsync(); + return TypedResults.Ok(); + } + + [HttpPost("users/{userId}/enable")] + public async Task>> EnableUserAsync(Guid userId) + { + var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole)); + if (user is null) + return TypedResults.NotFound("User not found."); + + user.IsLockoutEnabled = false; + await _db.SaveChangesAsync(); + return TypedResults.Ok(); + } + + [HttpPut("users/{userId}/roles/{role}")] + public async Task>> UpdateUserRolesAsync(Guid userId, string role) + { + if (ModelConstants.AllRoles.Contains(role) == false) + return TypedResults.NotFound($"No such role '{role}'"); + + var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole)); + if (user is null) + return TypedResults.NotFound("User not found."); + + user.Roles.Add(role); + await _db.SaveChangesAsync(); + return TypedResults.Ok(); + } + + [HttpDelete("users/{userId}/roles/{role}")] + public async Task>> RemoveUserRoleAsync(Guid userId, string role) + { + if (ModelConstants.AllRoles.Contains(role) == false) + return TypedResults.NotFound($"No such role '{role}'"); + + var user = await GetNonAdminUserAsync(userId, User.IsInRole(ModelConstants.SysAdminRole)); + if (user is null) + return TypedResults.NotFound("User not found."); + + user.Roles.Remove(role); + await _db.SaveChangesAsync(); + return TypedResults.Ok(); } } } diff --git a/OnlyPrompt.Backend/Controllers/AuthController.cs b/OnlyPrompt.Backend/Controllers/AuthController.cs index dc4454e..fe00e38 100644 --- a/OnlyPrompt.Backend/Controllers/AuthController.cs +++ b/OnlyPrompt.Backend/Controllers/AuthController.cs @@ -20,12 +20,10 @@ namespace OnlyPrompt.Backend.Controllers private readonly IPasswordHasher _passwordHasher; private readonly ITokenService _jwtService; private readonly ILogger _logger; - private readonly IMapper _mapper; - public AuthController(OnlyPromptContext db, IPasswordHasher passwordHasher, IMapper mapper, ILogger logger, ITokenService jwtService) : base(db) + public AuthController(OnlyPromptContext db, IPasswordHasher passwordHasher, IMapper mapper, ILogger logger, ITokenService jwtService) : base(db, mapper) { _passwordHasher=passwordHasher; - _mapper=mapper; _logger=logger; _jwtService=jwtService; } @@ -70,7 +68,7 @@ namespace OnlyPrompt.Backend.Controllers } var id = Guid.NewGuid(); - var slug = await SlugHelper.GenerateUniqueSlug(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32); + var slug = await SlugHelper.GenerateUniqueSlugAsync(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32); var avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}"; var newUser = new UserModel { diff --git a/OnlyPrompt.Backend/Controllers/BaseController.cs b/OnlyPrompt.Backend/Controllers/BaseController.cs index 0badc54..1e9b378 100644 --- a/OnlyPrompt.Backend/Controllers/BaseController.cs +++ b/OnlyPrompt.Backend/Controllers/BaseController.cs @@ -22,6 +22,16 @@ namespace OnlyPrompt.Backend.Controllers public Task FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName); public Task FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername); + public async Task GetUserProfileAsync() + { + var id = User.GetUserId(); + if (id.HasValue == false) + return null; + + var profile = await _db.UserProfiles.FirstOrDefaultAsync(x => x.Id == id.Value); + return profile; + } + public async Task GetUserAsync() { var id = User.GetUserId(); diff --git a/OnlyPrompt.Backend/Controllers/FeedController.cs b/OnlyPrompt.Backend/Controllers/FeedController.cs new file mode 100644 index 0000000..216bdf4 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/FeedController.cs @@ -0,0 +1,70 @@ +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.ApiModels.Prompt; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Utils; +using System.ComponentModel.DataAnnotations; + +namespace OnlyPrompt.Backend.Controllers +{ + [ApiController] + [Route("api/v1/feed")] + [Authorize(Roles = ModelConstants.UserRole)] + public class FeedController : BaseController + { + public FeedController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + [HttpGet] + public async Task GetFeedAsync( + [FromQuery]int offset = 0, + [Range(1, 100)][FromQuery]int limit = 20, + [FromQuery]FeedSortType sortBy = FeedSortType.Date, + [FromQuery]bool ascending = false, + [FromQuery]Identifier? category = null, + [FromQuery]DateTime? fromDate = null, + [FromQuery]DateTime? toDate = null + ) + { + var userId = User.GetUserId(); + var query = _db.Prompts + .Where(x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)); + + if (category.HasValue) + query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug); + + if (fromDate.HasValue) + query = query.Where(x => x.UpdatedAt >= fromDate.Value); + + if (toDate.HasValue) + query = query.Where(x => x.UpdatedAt <= toDate.Value); + + query = sortBy switch { + FeedSortType.Date => query.OrderBy(x => x.UpdatedAt, ascending), + FeedSortType.Rating => query.OrderBy(x => x.Reviews.Average(r => (double?)r.Rating) ?? 2.5, ascending), + _ => query + }; + + var prompts = await query + .Skip(offset) + .Take(limit) + .Select(x => new ApiMinimalPrompt( + x.Id, + x.Title, + x.UpdatedAt, + x.CreatorId, + x.Creator.Profile.DisplayName, + x.SubscriptionTier.Level, + x.SubscriptionTier.Name, + x.Reviews.Average(r => (double?)r.Rating), + x.SubscriptionTier == null || x.Creator.Subscribers.Any(s => s.SubscriberId == userId && x.SubscriptionTier.Level < s.SubscriptionTier.Level) + )).ToArrayAsync(); + + return prompts; + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/ProfileController.cs b/OnlyPrompt.Backend/Controllers/ProfileController.cs index 014054d..330fdf3 100644 --- a/OnlyPrompt.Backend/Controllers/ProfileController.cs +++ b/OnlyPrompt.Backend/Controllers/ProfileController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using OnlyPrompt.Backend.ApiModels.UserProfile; using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Utils; namespace OnlyPrompt.Backend.Controllers @@ -16,6 +17,11 @@ namespace OnlyPrompt.Backend.Controllers [Authorize(Roles = ModelConstants.UserRole)] public class ProfileController : BaseController { + private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary + { + { nameof(UserProfileModel.Slug), new[] { "Slug already exists." } } + }); + public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) { } @@ -35,5 +41,38 @@ namespace OnlyPrompt.Backend.Controllers return TypedResults.Ok(profile); } + [HttpPut] + public async Task, Ok>> UpdateProfileAsync([FromBody] ApiUpdateProfileRequest request) + { + var self = await GetUserProfileAsync(); + if (self is null) + return TypedResults.NotFound("Profile not found."); + + if (string.IsNullOrEmpty(request.Slug) == false) + { + if (await _db.UserProfiles.AnyAsync(up => up.Slug == request.Slug && up.Id != self.Id)) + return SlugExistsProblem; + + self.Slug = request.Slug; + } + + if(string.IsNullOrEmpty(request.AvatarUrl) == false) + self.AvatarUrl = request.AvatarUrl; + + if(string.IsNullOrEmpty(request.Bio) == false) + self.Bio = request.Bio; + + if(string.IsNullOrEmpty(request.Specialities) == false) + self.Specialities = request.Specialities; + + if (string.IsNullOrEmpty(request.DisplayName) == false) + self.DisplayName = request.DisplayName; + + self.IsPublic = request.IsPublic; + await _db.SaveChangesAsync(); + var result = _mapper.Map(self); + return TypedResults.Ok(result); + } + } } diff --git a/OnlyPrompt.Backend/Controllers/PromptController.cs b/OnlyPrompt.Backend/Controllers/PromptController.cs new file mode 100644 index 0000000..61a26d6 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/PromptController.cs @@ -0,0 +1,180 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.ApiModels.Prompt; +using OnlyPrompt.Backend.ApiModels.UserProfile; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; +using OnlyPrompt.Backend.Utils; +using System.ComponentModel.DataAnnotations; + +namespace OnlyPrompt.Backend.Controllers +{ + [ApiController] + [Route("api/v1/prompts")] + [Authorize(Roles = ModelConstants.UserRole)] + public class PromptController : BaseController + { + public PromptController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + private IQueryable GetAccessiblePrompts(Guid userId) + { + return _db.Prompts.Where( + p => p.SubscriptionTier == null + || p.Creator.Subscribers.Any( + sub => sub.SubscriberId == userId + && p.SubscriptionTier!.Level <= sub.SubscriptionTier!.Level + ) + ); + } + + [HttpGet("{id}")] + public async Task, NotFound>> GetPromptAsync(Identifier id) + { + var userId = User.GetUserId(); + var prompt = await GetAccessiblePrompts(userId.Value) + .OfIdentifer(id) + .FirstOrDefaultAsync(); + + if (prompt is null) + return TypedResults.NotFound("Prompt not found or no permission"); + + var apiPrompt = _mapper.Map(prompt); + return TypedResults.Ok(apiPrompt); + } + + [HttpDelete("{id}")] + public async Task>> DeletePromptAsync(Identifier id) + { + var userId = User.GetUserId(); + var isAdmin = User.IsInRole(ModelConstants.AdminRole); + var count = await _db.Prompts + .OfIdentifer(id) + .Where(p => p.CreatorId == userId || isAdmin) + .ExecuteDeleteAsync(); + + if (count == 0) + return TypedResults.NotFound("Prompt not found or no permission"); + + return TypedResults.NoContent(); + } + + [HttpPost] + public async Task, NotFound>> CreatePromptAsync([FromBody] ApiCreatePromptRequest request) + { + var userId = User.GetUserId(); + + var category = await _db.Categories.FindByIdentifierAsync(request.Category); + if (category is null) + return TypedResults.NotFound("Category not found"); + + SubscriptionTierModel? subscriptionTier = null; + if (request.SubscriptionTier.HasValue) + { + subscriptionTier = await _db.SubscriptionTiers.FirstOrDefaultAsync( + t => t.Level == request.SubscriptionTier.Value + && t.UserId == userId + ); + + if (subscriptionTier is null) + return TypedResults.NotFound("Subscription tier not found"); + } + + var slug = request.Slug; + if (string.IsNullOrEmpty(slug)) + slug = await SlugHelper.GenerateUniqueSlugAsync(request.Title, slug => _db.Prompts.AnyAsync(p => p.Slug == slug), ModelConstants.MaxSlugLength); + + var prompt = new PromptModel + { + Id = Guid.NewGuid(), + Title = request.Title, + Description = request.Description, + Prompt = request.Content, + CreatorId = userId.Value, + SubscriptionTier = subscriptionTier, + Category = category, + Slug = slug + }; + + _db.Prompts.Add(prompt); + await _db.SaveChangesAsync(); + var apiPrompt = _mapper.Map(prompt); + return TypedResults.Ok(apiPrompt); + } + + [HttpGet("{id}/reviews")] + public async Task> GetReviewsAsync(Identifier id, [FromQuery] int offset = 0, [Range(1, 200)][FromQuery] int limit = 20) + { + var userId = User.GetUserId(); + var accessiblePrompts = GetAccessiblePrompts(userId!.Value); + var reviews = await accessiblePrompts.Select(x => x.Reviews) + .Skip(offset) + .Take(limit) + .ProjectTo(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return TypedResults.Ok(reviews); + } + + [HttpPut("{id}/reviews")] + public async Task, NotFound>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request) + { + var userId = User.GetUserId(); + var prompt = await GetAccessiblePrompts(userId!.Value) + .OfIdentifer(id) + .FirstOrDefaultAsync(); + + if (prompt is null) + return TypedResults.NotFound("Prompt not found or no permission"); + + var review = await _db.Reviews.FirstOrDefaultAsync( + r => r.PromptId == prompt.Id + && r.ReviewerId == userId + ); + + if (review is null) + { + review = new ReviewModel + { + PromptId = prompt.Id, + ReviewerId = userId.Value, + Comment = request.Comment, + Rating = request.Rating + }; + + _db.Reviews.Add(review); + } + else + { + review.Comment = request.Comment; + review.Rating = request.Rating; + } + + await _db.SaveChangesAsync(); + var apiReview = _mapper.Map(review); + return TypedResults.Ok(apiReview); + } + + [HttpDelete("{promptId}/reviews/{reviewerId}")] + public async Task>> DeleteReviewAsync(Identifier promptId, Guid reviewerId) + { + var userId = User.GetUserId(); + var isAdmin = User.IsInRole(ModelConstants.AdminRole); + var count = await _db.Reviews + .Where( + r => (promptId.Id.HasValue ? r.PromptId == promptId.Id : r.Prompt.Slug == promptId.Slug) + && (r.ReviewerId == reviewerId || isAdmin) + ) + .ExecuteDeleteAsync(); + + if (count == 0) + return TypedResults.NotFound("Review not found or no permission"); + return TypedResults.NoContent(); + } + } +} diff --git a/OnlyPrompt.Backend/Database/Core/EntityBase.cs b/OnlyPrompt.Backend/Database/Core/EntityBase.cs index 0962583..6f30c4e 100644 --- a/OnlyPrompt.Backend/Database/Core/EntityBase.cs +++ b/OnlyPrompt.Backend/Database/Core/EntityBase.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema; namespace OnlyPrompt.Backend.Database.Core { - public class EntityBase : IEntity + public class EntityBase : IEntity, ITrackableEntity { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] diff --git a/OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs b/OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs new file mode 100644 index 0000000..49277ba --- /dev/null +++ b/OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs @@ -0,0 +1,8 @@ +namespace OnlyPrompt.Backend.Database.Core +{ + public interface ITrackableEntity + { + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/OnlyPrompt.Backend/Database/ModelConstants.cs b/OnlyPrompt.Backend/Database/ModelConstants.cs index 1b4bbc5..d5230c7 100644 --- a/OnlyPrompt.Backend/Database/ModelConstants.cs +++ b/OnlyPrompt.Backend/Database/ModelConstants.cs @@ -1,8 +1,14 @@ -namespace OnlyPrompt.Backend.Database +using System.Collections.Frozen; + +namespace OnlyPrompt.Backend.Database { public static class ModelConstants { public const int MaxSlugLength = 100; public const string UserRole = "user"; + public const string AdminRole = "admin"; + public const string SysAdminRole = "sys-admin"; + + public static readonly FrozenSet AllRoles = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, UserRole, AdminRole, SysAdminRole); } } diff --git a/OnlyPrompt.Backend/Database/Models/PromptModel.cs b/OnlyPrompt.Backend/Database/Models/PromptModel.cs index 47c2758..3cc889e 100644 --- a/OnlyPrompt.Backend/Database/Models/PromptModel.cs +++ b/OnlyPrompt.Backend/Database/Models/PromptModel.cs @@ -13,19 +13,23 @@ namespace OnlyPrompt.Backend.Database.Models public Guid CreatorId { get; set; } [DeleteBehavior(DeleteBehavior.Cascade)] - public required virtual UserModel Creator { get; set; } + public virtual UserModel Creator { get; set; } [Required] [ForeignKey(nameof(Category))] public Guid CategoryId { get; set; } [DeleteBehavior(DeleteBehavior.Cascade)] - public required virtual CategoryModel Category { get; set; } + public virtual CategoryModel Category { get; set; } [MaxLength(200)] public required string Title { get; set; } - public required string Content { get; set; } + [MaxLength(4000)] + public required string Prompt { get; set; } + + [MaxLength(1000)] + public required string Description { get; set; } [MaxLength(ModelConstants.MaxSlugLength)] [Column(TypeName = "citext")] diff --git a/OnlyPrompt.Backend/Database/Models/ReviewModel.cs b/OnlyPrompt.Backend/Database/Models/ReviewModel.cs index 28eee85..36ef545 100644 --- a/OnlyPrompt.Backend/Database/Models/ReviewModel.cs +++ b/OnlyPrompt.Backend/Database/Models/ReviewModel.cs @@ -5,9 +5,12 @@ using System.ComponentModel.DataAnnotations.Schema; namespace OnlyPrompt.Backend.Database.Models { - [Index(nameof(ReviewerId), nameof(PromptId), IsUnique = true)] - public class ReviewModel : EntityBase + [PrimaryKey(nameof(ReviewerId), nameof(PromptId))] + public class ReviewModel : ITrackableEntity { + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + [Required] [ForeignKey(nameof(Reviewer))] public Guid ReviewerId { get; set; } diff --git a/OnlyPrompt.Backend/Database/Models/UserModel.cs b/OnlyPrompt.Backend/Database/Models/UserModel.cs index e6fae25..d4de834 100644 --- a/OnlyPrompt.Backend/Database/Models/UserModel.cs +++ b/OnlyPrompt.Backend/Database/Models/UserModel.cs @@ -16,7 +16,7 @@ namespace OnlyPrompt.Backend.Database.Models [Column(TypeName = "citext")] public required string Email { get; set; } public required string PasswordHash { get; set; } - public required string[] Roles { get; set; } + public required List Roles { get; set; } = new List(); [Required] public required virtual UserProfileModel Profile { get; set; } diff --git a/OnlyPrompt.Backend/Database/OnlyPromptContext.cs b/OnlyPrompt.Backend/Database/OnlyPromptContext.cs index 76a4969..61ad105 100644 --- a/OnlyPrompt.Backend/Database/OnlyPromptContext.cs +++ b/OnlyPrompt.Backend/Database/OnlyPromptContext.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using OnlyPrompt.Backend.Database.Core; using OnlyPrompt.Backend.Database.Models; namespace OnlyPrompt.Backend.Database @@ -14,6 +15,40 @@ namespace OnlyPrompt.Backend.Database public DbSet Subscriptions { get; set; } public DbSet Reviews { get; set; } + public OnlyPromptContext(DbContextOptions options) : base(options) + { + } + + private void HandleEntityTimestamps() + { + var entries = ChangeTracker.Entries(); + var now = DateTime.UtcNow; + foreach (var entry in entries) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = now; + } + } + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + { + HandleEntityTimestamps(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + HandleEntityTimestamps(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); diff --git a/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj index 0c379d3..b236d9e 100644 --- a/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj +++ b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj @@ -4,7 +4,6 @@ net10.0 enable enable - aspnet-OnlyPrompt.Backend-dc36f9f9-a53c-4ee3-a0b4-3396ee55b7c8 Linux @@ -24,7 +23,6 @@ - diff --git a/OnlyPrompt.Backend/Program.cs b/OnlyPrompt.Backend/Program.cs index 60c9fbd..ffc42e9 100644 --- a/OnlyPrompt.Backend/Program.cs +++ b/OnlyPrompt.Backend/Program.cs @@ -7,6 +7,7 @@ using OnlyPrompt.Backend.Database; using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Services.Jwt; using OnlyPrompt.Backend.Utils; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -38,7 +39,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); builder.Services.AddControllers(); -builder.Services.AddOpenApi(); +builder.Services.AddOpenApi(opts => opts.AddScalarTransformers()); var app = builder.Build(); @@ -46,6 +47,7 @@ var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); + app.MapScalarApiReference(); } app.UseHttpsRedirection(); diff --git a/OnlyPrompt.Backend/Properties/launchSettings.json b/OnlyPrompt.Backend/Properties/launchSettings.json index 0f86129..4d4a6f1 100644 --- a/OnlyPrompt.Backend/Properties/launchSettings.json +++ b/OnlyPrompt.Backend/Properties/launchSettings.json @@ -2,6 +2,8 @@ "profiles": { "https": { "commandName": "Project", + "launchBrowser": true, + "launchUrl": "https://localhost:7163/scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs index 652bccd..e155f06 100644 --- a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs +++ b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs @@ -1,5 +1,6 @@ using AutoMapper; using OnlyPrompt.Backend.ApiModels.Auth; +using OnlyPrompt.Backend.ApiModels.Prompt; using OnlyPrompt.Backend.ApiModels.UserProfile; using OnlyPrompt.Backend.Database.Models; @@ -23,6 +24,34 @@ namespace OnlyPrompt.Backend.Utils .MapCtorParamFrom(x => x.Specialities, x => x.Specialities) .MapCtorParamFrom(x => x.AverageRating, x => x.User.Prompts.Average(p => p.Reviews.Average(r => r.Rating))) .MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count()); + + config.CreateMap() + .MapCtorParamFrom(x => x.Id, x => x.Id) + .MapCtorParamFrom(x => x.Title, x => x.Title) + .MapCtorParamFrom(x => x.Description, x => x.Description) + .MapCtorParamFrom(x => x.Content, x => x.Prompt) + .MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt) + .MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) + .MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name) + .MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName) + .MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId) + .MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating)); + + config.CreateMap() + .MapCtorParamFrom(x => x.Id, x => x.Id) + .MapCtorParamFrom(x => x.Title, x => x.Title) + .MapCtorParamFrom(x => x.CreatorName, x => x.Creator.Profile.DisplayName) + .MapCtorParamFrom(x => x.TimeStamp, x => x.UpdatedAt) + .MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId) + .MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) + .MapCtorParamFrom(x => x.TierName, x => x.SubscriptionTier == null ? null : x.SubscriptionTier.Name) + .MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating)); + + config.CreateMap() + .MapCtorParamFrom(x => x.CreatorId, x => x.ReviewerId) + .MapCtorParamFrom(x => x.CreatorName, x => x.Reviewer.Profile.DisplayName) + .MapCtorParamFrom(x => x.Comment, x => x.Comment) + .MapCtorParamFrom(x => x.Rating, x => x.Rating); } } } diff --git a/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs b/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs index b3c44cb..5546ced 100644 --- a/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs +++ b/OnlyPrompt.Backend/Utils/AutomapperExtensions.cs @@ -42,5 +42,7 @@ namespace OnlyPrompt.Backend.Utils mapping.ForCtorParam(ctorParamName, configure); return mapping; } + + } } diff --git a/OnlyPrompt.Backend/Utils/Extensions.cs b/OnlyPrompt.Backend/Utils/Extensions.cs index 75bc583..806ecad 100644 --- a/OnlyPrompt.Backend/Utils/Extensions.cs +++ b/OnlyPrompt.Backend/Utils/Extensions.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using OnlyPrompt.Backend.Database.Models; using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using System.Security.Claims; @@ -33,6 +34,14 @@ namespace OnlyPrompt.Backend.Utils yield return new Claim(ClaimTypes.Role, role); } + public static IQueryable OrderBy(this IQueryable source, Expression> selecter, bool ascending) + { + if(ascending) + return source.OrderBy(selecter); + else + return source.OrderByDescending(selecter); + } + public static CookieOptions Copy(this CookieOptions options, Action? modify = null) { var newOptions = new CookieOptions diff --git a/OnlyPrompt.Backend/Utils/SlugHelper.cs b/OnlyPrompt.Backend/Utils/SlugHelper.cs index 176536e..2ab2007 100644 --- a/OnlyPrompt.Backend/Utils/SlugHelper.cs +++ b/OnlyPrompt.Backend/Utils/SlugHelper.cs @@ -20,7 +20,7 @@ namespace OnlyPrompt.Backend.Utils } private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789"; - public static async Task GenerateUniqueSlug(string input, Func> existsFunc, int? maxLenght) + public static async Task GenerateUniqueSlugAsync(string input, Func> existsFunc, int? maxLenght) { var baseSlug = GenerateSlug(input, maxLenght - 9); var slug = baseSlug;