diff --git a/OnlyPrompt.Backend/ApiModels/Category/Models.cs b/OnlyPrompt.Backend/ApiModels/Category/Models.cs new file mode 100644 index 0000000..31311b2 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Category/Models.cs @@ -0,0 +1,5 @@ +namespace OnlyPrompt.Backend.ApiModels.Category +{ + public record ApiCategory(Guid Id, string Name, string Slug, string? Description); + public record ApiMinimalCategory(string Name, string Slug); +} diff --git a/OnlyPrompt.Backend/ApiModels/Category/Requests.cs b/OnlyPrompt.Backend/ApiModels/Category/Requests.cs new file mode 100644 index 0000000..647afe7 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Category/Requests.cs @@ -0,0 +1,5 @@ +namespace OnlyPrompt.Backend.ApiModels.Category +{ + public record ApiCreateCategoryRequest(string Name, string? Slug, string? Description); + public record ApiUpdateCategoryRequest(string? Name, string? Slug, string? Description); +} diff --git a/OnlyPrompt.Backend/ApiModels/Subscription/Models.cs b/OnlyPrompt.Backend/ApiModels/Subscription/Models.cs new file mode 100644 index 0000000..573adbd --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Subscription/Models.cs @@ -0,0 +1,5 @@ +namespace OnlyPrompt.Backend.ApiModels.Subscription +{ + public record ApiSubscriptionTier(Guid Id, string Name, int Level, decimal MonthlyPrice, string? Description); + public record ApiSubscription(Guid SubscribedToId, string SubscribedToName, ApiSubscriptionTier? CurrentTier); +} diff --git a/OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs b/OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs new file mode 100644 index 0000000..aa9fcf0 --- /dev/null +++ b/OnlyPrompt.Backend/ApiModels/Subscription/Requests.cs @@ -0,0 +1,5 @@ +namespace OnlyPrompt.Backend.ApiModels.Subscription +{ + public record ApiCreateSubscriptionTierRequest(string Name, decimal MonthlyPrice, int Level, string? Description); + public record ApiUpdateSubscriptionTierRequest(string? Name, decimal? MonthlyPrice, int? Level, string? Description); +} diff --git a/OnlyPrompt.Backend/Controllers/AuthController.cs b/OnlyPrompt.Backend/Controllers/AuthController.cs index fe00e38..23dbe08 100644 --- a/OnlyPrompt.Backend/Controllers/AuthController.cs +++ b/OnlyPrompt.Backend/Controllers/AuthController.cs @@ -31,7 +31,7 @@ namespace OnlyPrompt.Backend.Controllers [AllowAnonymous] [HttpPost("login")] - public async Task, NotFound>> LoginAsync([FromBody] ApiLoginRequest request) + public async Task, NotFound>> LoginAsync([FromBody] ApiLoginRequest request) { var user = await FindUserAsync(request.UserNameOrEmail); if (user is null) @@ -46,7 +46,7 @@ namespace OnlyPrompt.Backend.Controllers var token = _jwtService.BuildToken(user, out var validUntil); this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil)); - return TypedResults.Redirect("feed"); + return TypedResults.Ok(); } [AllowAnonymous] @@ -67,7 +67,7 @@ namespace OnlyPrompt.Backend.Controllers return TypedResults.ValidationProblem(errors); } - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); 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/CategoryController.cs b/OnlyPrompt.Backend/Controllers/CategoryController.cs new file mode 100644 index 0000000..0a7cc66 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/CategoryController.cs @@ -0,0 +1,123 @@ +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OnlyPrompt.Backend.ApiModels.Category; +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/categories")] + [Authorize(Roles = ModelConstants.AdminRole, AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + public class CategoryController : BaseController + { + private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary + { + { nameof(CategoryModel.Slug), new[] { "Slug already exists." } } + }); + + public CategoryController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + [HttpGet("minimal")] + [Authorize(Roles = ModelConstants.UserRole)] + public async Task GetMinimalCategoriesAsync() + { + var categories = await _db.Categories + .ProjectTo(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return categories; + } + + [HttpGet] + [Authorize(Roles = ModelConstants.UserRole)] + public async Task GetCategoriesAsync([Range(0, double.MaxValue)][FromQuery] int offset = 0, [Range(1, 100)][FromQuery] int limit = 20) + { + var categories = await _db.Categories + .OrderBy(c => c.Id) + .Skip(offset) + .Take(limit) + .ProjectTo(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return categories; + } + + [HttpPost] + public async Task, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request) + { + var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug); + if (exists) + return SlugExistsProblem; + + var model = _mapper.Map(request); + if (string.IsNullOrWhiteSpace(model.Slug)) + model.Slug = await SlugHelper.GenerateUniqueSlugAsync(request.Name, slug => _db.Categories.AnyAsync(c => c.Slug == slug), ModelConstants.MaxSlugLength); + + _db.Categories.Add(model); + await _db.SaveChangesAsync(); + return TypedResults.Ok(_mapper.Map(model)); + } + + [HttpPut("{id}")] + public async Task, NotFound, ValidationProblem>> UpdateCategoryAsync(Identifier id, [FromBody] ApiUpdateCategoryRequest request) + { + var category = await _db.Categories.FindByIdentifierAsync(id); + if (category is null) + return TypedResults.NotFound("Category not found"); + + if (string.IsNullOrWhiteSpace(request.Name) == false) + category.Name = request.Name; + + if(string.IsNullOrWhiteSpace(request.Description) == false) + category.Description = request.Description; + + if (string.IsNullOrWhiteSpace(request.Slug) == false && request.Slug != category.Slug) + { + var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug && c.Id != category.Id); + if (exists) + return SlugExistsProblem; + + category.Slug = request.Slug; + } + + await _db.SaveChangesAsync(); + return TypedResults.Ok(_mapper.Map(category)); + } + + [HttpDelete("{id}")] + public async Task, NotFound>> DeleteCategoryAsync(Identifier id, [FromQuery] Identifier? replaceWith = null) + { + var hasPrompts = await _db.Prompts.AnyAsync(p => p.CategoryId == id.Id); + if (hasPrompts) + { + if (replaceWith.HasValue == false) + return TypedResults.BadRequest("Category has associated prompts. Provide a replacement category to reassign them to."); + + var replacement = await _db.Categories.FindByIdentifierAsync(replaceWith.Value); + if(replacement is null) + return TypedResults.NotFound("Replacement category not found."); + + await _db.Prompts.Where(p => p.CategoryId == id.Id) + .ExecuteUpdateAsync(p => p.SetProperty(p => p.CategoryId, replacement.Id)); + } + + var count = await _db.Categories.OfIdentifer(id) + .ExecuteDeleteAsync(); + + if (count == 0) + return TypedResults.NotFound("Category not found"); + + return TypedResults.NoContent(); + } + } +} diff --git a/OnlyPrompt.Backend/Controllers/FeedController.cs b/OnlyPrompt.Backend/Controllers/FeedController.cs index 216bdf4..f6d0c00 100644 --- a/OnlyPrompt.Backend/Controllers/FeedController.cs +++ b/OnlyPrompt.Backend/Controllers/FeedController.cs @@ -21,7 +21,7 @@ namespace OnlyPrompt.Backend.Controllers [HttpGet] public async Task GetFeedAsync( - [FromQuery]int offset = 0, + [Range(0, double.MaxValue)][FromQuery]int offset = 0, [Range(1, 100)][FromQuery]int limit = 20, [FromQuery]FeedSortType sortBy = FeedSortType.Date, [FromQuery]bool ascending = false, @@ -32,7 +32,10 @@ namespace OnlyPrompt.Backend.Controllers { var userId = User.GetUserId(); var query = _db.Prompts - .Where(x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId)); + .Where( + x => x.Creator.Subscribers.Any(s => s.SubscriberId == userId) + && x.CreatorId != userId + ); if (category.HasValue) query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug); diff --git a/OnlyPrompt.Backend/Controllers/PromptController.cs b/OnlyPrompt.Backend/Controllers/PromptController.cs index 61a26d6..82aef98 100644 --- a/OnlyPrompt.Backend/Controllers/PromptController.cs +++ b/OnlyPrompt.Backend/Controllers/PromptController.cs @@ -122,7 +122,7 @@ namespace OnlyPrompt.Backend.Controllers } [HttpPut("{id}/reviews")] - public async Task, NotFound>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request) + public async Task, BadRequest, NotFound>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request) { var userId = User.GetUserId(); var prompt = await GetAccessiblePrompts(userId!.Value) @@ -132,6 +132,9 @@ namespace OnlyPrompt.Backend.Controllers if (prompt is null) return TypedResults.NotFound("Prompt not found or no permission"); + if(prompt.CreatorId == userId) + return TypedResults.BadRequest("Cannot review your own prompt"); + var review = await _db.Reviews.FirstOrDefaultAsync( r => r.PromptId == prompt.Id && r.ReviewerId == userId diff --git a/OnlyPrompt.Backend/Controllers/SubscriptionController.cs b/OnlyPrompt.Backend/Controllers/SubscriptionController.cs new file mode 100644 index 0000000..dee4b31 --- /dev/null +++ b/OnlyPrompt.Backend/Controllers/SubscriptionController.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.Subscription; +using OnlyPrompt.Backend.Database; +using OnlyPrompt.Backend.Database.Models; +using OnlyPrompt.Backend.Utils; +using System.ComponentModel.DataAnnotations; +using System.Formats.Asn1; + +namespace OnlyPrompt.Backend.Controllers +{ + [ApiController] + [Route("api/v1/subscriptions")] + [Authorize(Roles = ModelConstants.UserRole)] + public class SubscriptionController : BaseController + { + private static ValidationProblem TierLevelExistsProblem = TypedResults.ValidationProblem(new Dictionary + { + { nameof(SubscriptionTierModel.Level), new[] { "Tier with this level already exists." } } + }); + public SubscriptionController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) + { + } + + [HttpPut("{userId}/{level}")] + public async Task, NotFound>> SubscribeAsync(Identifier subscribeToId, int? level = null) + { + var userId = User.GetUserId(); + var subscribeTo = await _db.Users.Include(x => x.SubscriptionTiers.Where(st => st.Level == level)) + .FirstOrDefaultAsync( + user => subscribeToId.Id.HasValue ? user.Id == subscribeToId.Id.Value : user.Profile.Slug == subscribeToId.Slug + ); + + if (subscribeTo is null) + return TypedResults.NotFound($"No user found with identifier {subscribeToId}"); + + if (subscribeTo.Id == userId) + return TypedResults.BadRequest("Cannot subscribe to yourself"); + + SubscriptionTierModel? tier = subscribeTo.SubscriptionTiers.FirstOrDefault(); + if (level.HasValue && tier is null) + return TypedResults.NotFound($"No subscription tier found for user {subscribeToId} with level {level.Value}"); + + var existingSubscription = await _db.Subscriptions.FirstOrDefaultAsync( + sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug + && sub.SubscriberId == userId + ); + + if (existingSubscription is null) + { + existingSubscription = new SubscriptionModel + { + SubscribedTo = subscribeTo, + SubscriberId = userId.Value, + SubscriptionTier = tier + }; + + _db.Subscriptions.Add(existingSubscription); + } + else + { + existingSubscription.SubscriptionTier = tier; + } + + await _db.SaveChangesAsync(); + return TypedResults.Ok(); + } + + [HttpGet] + public async Task GetSubscriptionsAsync([Range(0, double.MaxValue)]int offset = 0, [Range(1, 100)]int limit = 20) + { + var userId = User.GetUserId(); + var subscriptions = await _db.Subscriptions + .Where(x => x.SubscriberId == userId) + .OrderBy(x => x.SubscribedToId) + .Skip(offset) + .Take(limit) + .ProjectTo(_mapper.ConfigurationProvider) + .ToArrayAsync(); + + return subscriptions; + } + + [HttpGet("{userId}")] + public async Task GetCurrentSubscriptionAsync(Identifier subscribeToId) + { + var userId = User.GetUserId(); + var subscription = await _db.Subscriptions + .Where( + sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug + && sub.SubscriberId == userId + ) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + + return subscription; + } + + [HttpDelete("{userId}")] + public async Task>> UnsubscribeAsync(Identifier subscribeToId) + { + var userId = User.GetUserId(); + var count = await _db.Subscriptions + .Where( + sub => subscribeToId.Id.HasValue ? sub.SubscribedToId == subscribeToId.Id.Value : sub.SubscribedTo.Profile.Slug == subscribeToId.Slug + && sub.SubscriberId == userId + ) + .ExecuteDeleteAsync(); + + if (count == 0) + return TypedResults.NotFound($"No subscription found for user {subscribeToId}"); + + return TypedResults.Ok(); + } + + [HttpPost("tiers")] + public async Task, ValidationProblem>> CreateOrUpdateSubscriptionTierAsync([FromBody] ApiCreateSubscriptionTierRequest tier) + { + var userId = User.GetUserId(); + var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level); + if (levelExists) + return TierLevelExistsProblem; + + var model = _mapper.Map(tier); + model.UserId = userId!.Value; + _db.SubscriptionTiers.Add(model); + await _db.SaveChangesAsync(); + return TypedResults.Ok(_mapper.Map(model)); + } + + [HttpPut("tiers/{id}")] + public async Task, NotFound, ValidationProblem>> UpdateSubscriptionTierAsync(Guid id, [FromBody] ApiUpdateSubscriptionTierRequest tier) + { + var userId = User.GetUserId(); + var existingTier = await _db.SubscriptionTiers.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId); + if (existingTier is null) + return TypedResults.NotFound($"No subscription tier found with id {id}"); + + if (existingTier.Level != tier.Level) + { + var levelExists = await _db.SubscriptionTiers.AnyAsync(t => t.UserId == userId && t.Level == tier.Level); + if (levelExists) + return TierLevelExistsProblem; + } + + if (string.IsNullOrEmpty(tier.Name) == false) + existingTier.Name = tier.Name; + + if (string.IsNullOrEmpty(tier.Description) == false) + existingTier.Description = tier.Description; + + if (tier.Level.HasValue) + existingTier.Level = tier.Level.Value; + + if (tier.MonthlyPrice.HasValue) + existingTier.MonthlyPrice = tier.MonthlyPrice.Value; + + await _db.SaveChangesAsync(); + return TypedResults.Ok(_mapper.Map(existingTier)); + } + + [HttpDelete("tiers/{id}")] + public async Task>> DeleteSubscriptionTierAsync(Guid id) + { + var userId = User.GetUserId(); + var count = await _db.SubscriptionTiers + .Where(t => t.Id == id && t.UserId == userId) + .ExecuteDeleteAsync(); + + if (count == 0) + return TypedResults.NotFound($"No subscription tier found with id {id}"); + + return TypedResults.Ok(); + } + } +} diff --git a/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs b/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs index 7381d52..499df7b 100644 --- a/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs +++ b/OnlyPrompt.Backend/Database/Models/SubscriptionModel.cs @@ -13,14 +13,14 @@ namespace OnlyPrompt.Backend.Database.Models public Guid SubscribedToId { get; set; } [DeleteBehavior(DeleteBehavior.Cascade)] - public required virtual UserModel SubscribedTo { get; set; } + public virtual UserModel SubscribedTo { get; set; } [Required] [ForeignKey(nameof(Subscriber))] public Guid SubscriberId { get; set; } [DeleteBehavior(DeleteBehavior.Cascade)] - public required virtual UserModel Subscriber { get; set; } + public virtual UserModel Subscriber { get; set; } [ForeignKey(nameof(SubscriptionTier))] diff --git a/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs b/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs index 9bacaf6..069dc88 100644 --- a/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs +++ b/OnlyPrompt.Backend/Database/Models/SubscriptionTierModel.cs @@ -19,7 +19,7 @@ namespace OnlyPrompt.Backend.Database.Models public Guid UserId { get; set; } [DeleteBehavior(DeleteBehavior.Cascade)] - public required virtual UserModel User { get; set; } + public virtual UserModel User { get; set; } public decimal MonthlyPrice { get; set; } public int Level { get; set; } diff --git a/OnlyPrompt.Backend/Database/Models/UserModel.cs b/OnlyPrompt.Backend/Database/Models/UserModel.cs index d4de834..c0053b8 100644 --- a/OnlyPrompt.Backend/Database/Models/UserModel.cs +++ b/OnlyPrompt.Backend/Database/Models/UserModel.cs @@ -24,6 +24,7 @@ namespace OnlyPrompt.Backend.Database.Models public virtual IList Prompts { get; set; } = new List(); public virtual IList Subscriptions { get; set; } = new List(); public virtual IList Subscribers { get; set; } = new List(); + public virtual IList SubscriptionTiers { get; set; } = new List(); public bool IsLockoutEnabled { get; set; } = false; } diff --git a/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs new file mode 100644 index 0000000..54cbe23 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.Designer.cs @@ -0,0 +1,419 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OnlyPrompt.Backend.Database; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + [DbContext(typeof(OnlyPromptContext))] + [Migration("20260412002927_ReviewManyToMany")] + partial class ReviewManyToMany + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.HasIndex("SubscriptionTierId"); + + b.ToTable("Prompts"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => + { + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("PromptId") + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ReviewerId", "PromptId"); + + b.HasIndex("PromptId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b => + { + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("SubscribedToId") + .HasColumnType("uuid"); + + b.Property("SubscriptionTierId") + .HasColumnType("uuid"); + + b.HasKey("SubscriberId", "SubscribedToId"); + + b.HasIndex("SubscribedToId"); + + b.HasIndex("SubscriptionTierId"); + + b.ToTable("UserSubscriptions"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Level", "UserId") + .IsUnique(); + + b.ToTable("SubscriptionTiers"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("citext"); + + b.Property("IsLockoutEnabled") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("Bio") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext"); + + b.Property("Specialities") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("UserProfiles"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.CategoryModel", "Category") + .WithMany("Prompts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Creator") + .WithMany("Prompts") + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier") + .WithMany("Prompts") + .HasForeignKey("SubscriptionTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("Creator"); + + b.Navigation("SubscriptionTier"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.PromptModel", "Prompt") + .WithMany("Reviews") + .HasForeignKey("PromptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Reviewer") + .WithMany() + .HasForeignKey("ReviewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prompt"); + + b.Navigation("Reviewer"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "SubscribedTo") + .WithMany("Subscribers") + .HasForeignKey("SubscribedToId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "Subscriber") + .WithMany("Subscriptions") + .HasForeignKey("SubscriberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", "SubscriptionTier") + .WithMany("Subscriptions") + .HasForeignKey("SubscriptionTierId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("SubscribedTo"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionTier"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserProfileModel", b => + { + b.HasOne("OnlyPrompt.Backend.Database.Models.UserModel", "User") + .WithOne("Profile") + .HasForeignKey("OnlyPrompt.Backend.Database.Models.UserProfileModel", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.CategoryModel", b => + { + b.Navigation("Prompts"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.PromptModel", b => + { + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.SubscriptionTierModel", b => + { + b.Navigation("Prompts"); + + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.UserModel", b => + { + b.Navigation("Profile") + .IsRequired(); + + b.Navigation("Prompts"); + + b.Navigation("Subscribers"); + + b.Navigation("Subscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.cs b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.cs new file mode 100644 index 0000000..5ed18a9 --- /dev/null +++ b/OnlyPrompt.Backend/Migrations/20260412002927_ReviewManyToMany.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OnlyPrompt.Backend.Migrations +{ + /// + public partial class ReviewManyToMany : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Reviews", + table: "Reviews"); + + migrationBuilder.DropIndex( + name: "IX_Reviews_ReviewerId_PromptId", + table: "Reviews"); + + migrationBuilder.DropColumn( + name: "Id", + table: "Reviews"); + + migrationBuilder.DropColumn( + name: "Content", + table: "Prompts"); + + migrationBuilder.AddColumn( + name: "Description", + table: "Prompts", + type: "character varying(1000)", + maxLength: 1000, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Prompt", + table: "Prompts", + type: "character varying(4000)", + maxLength: 4000, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddPrimaryKey( + name: "PK_Reviews", + table: "Reviews", + columns: new[] { "ReviewerId", "PromptId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_Reviews", + table: "Reviews"); + + migrationBuilder.DropColumn( + name: "Description", + table: "Prompts"); + + migrationBuilder.DropColumn( + name: "Prompt", + table: "Prompts"); + + migrationBuilder.AddColumn( + name: "Id", + table: "Reviews", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "Content", + table: "Prompts", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddPrimaryKey( + name: "PK_Reviews", + table: "Reviews", + column: "Id"); + + migrationBuilder.CreateIndex( + name: "IX_Reviews_ReviewerId_PromptId", + table: "Reviews", + columns: new[] { "ReviewerId", "PromptId" }, + unique: true); + } + } +} diff --git a/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs b/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs index 7295fc4..4dd139c 100644 --- a/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs +++ b/OnlyPrompt.Backend/Migrations/OnlyPromptContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -67,16 +68,22 @@ namespace OnlyPrompt.Backend.Migrations b.Property("CategoryId") .HasColumnType("uuid"); - b.Property("Content") - .IsRequired() - .HasColumnType("text"); - b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); b.Property("CreatorId") .HasColumnType("uuid"); + b.Property("Description") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + b.Property("Slug") .IsRequired() .HasMaxLength(100) @@ -109,8 +116,10 @@ namespace OnlyPrompt.Backend.Migrations modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => { - b.Property("Id") - .ValueGeneratedOnAdd() + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("PromptId") .HasColumnType("uuid"); b.Property("Comment") @@ -120,25 +129,16 @@ namespace OnlyPrompt.Backend.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("PromptId") - .HasColumnType("uuid"); - b.Property("Rating") .HasColumnType("integer"); - b.Property("ReviewerId") - .HasColumnType("uuid"); - b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); - b.HasKey("Id"); + b.HasKey("ReviewerId", "PromptId"); b.HasIndex("PromptId"); - b.HasIndex("ReviewerId", "PromptId") - .IsUnique(); - b.ToTable("Reviews"); }); @@ -221,7 +221,7 @@ namespace OnlyPrompt.Backend.Migrations .IsRequired() .HasColumnType("text"); - b.PrimitiveCollection("Roles") + b.PrimitiveCollection>("Roles") .IsRequired() .HasColumnType("text[]"); diff --git a/OnlyPrompt.Backend/Program.cs b/OnlyPrompt.Backend/Program.cs index ffc42e9..937bdfa 100644 --- a/OnlyPrompt.Backend/Program.cs +++ b/OnlyPrompt.Backend/Program.cs @@ -2,15 +2,18 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Microsoft.Identity.Web; +using Microsoft.IdentityModel.Tokens; using OnlyPrompt.Backend.Database; using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Services.Jwt; using OnlyPrompt.Backend.Utils; using Scalar.AspNetCore; +using System.Text; var builder = WebApplication.CreateBuilder(args); - +var config = builder.Configuration; // Add services to the container. builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme); builder.Services.AddDbContext(opts => @@ -25,7 +28,17 @@ builder.Services.AddAutoMapper(AutoMapperSetup.Setup); builder.Services.AddAuthorization(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => { + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => { + opts.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = config["Jwt:Issuer"], + ValidAudience = config["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"])) + }; opts.Events = new JwtBearerEvents { OnMessageReceived = context => @@ -56,4 +69,8 @@ app.UseAuthorization(); app.MapControllers(); +using var scope = app.Services.CreateScope(); +var db = scope.ServiceProvider.GetRequiredService(); +await db.Database.MigrateAsync(); + app.Run(); diff --git a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs index e155f06..16bb02d 100644 --- a/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs +++ b/OnlyPrompt.Backend/Utils/AutoMapperSetup.cs @@ -1,6 +1,8 @@ using AutoMapper; using OnlyPrompt.Backend.ApiModels.Auth; +using OnlyPrompt.Backend.ApiModels.Category; using OnlyPrompt.Backend.ApiModels.Prompt; +using OnlyPrompt.Backend.ApiModels.Subscription; using OnlyPrompt.Backend.ApiModels.UserProfile; using OnlyPrompt.Backend.Database.Models; @@ -45,13 +47,47 @@ namespace OnlyPrompt.Backend.Utils .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)); + .MapCtorParamFrom(x => x.AverageRating, x => x.Reviews.Average(r => (double?)r.Rating)) + .MapCtorParamFrom(x => x.CanAccess, x => true); 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); + + config.CreateMap() + .MapCtorParamFrom(x => x.Id, x => x.Id) + .MapCtorParamFrom(x => x.Name, x => x.Name) + .MapCtorParamFrom(x => x.Description, x => x.Description) + .MapCtorParamFrom(x => x.Slug, x => x.Slug); + + config.CreateMap() + .MapCtorParamFrom(x => x.Name, x => x.Name) + .MapCtorParamFrom(x => x.Slug, x => x.Slug); + + config.CreateMap() + .MapMemberFrom(x => x.Description, x => x.Description) + .MapMemberFrom(x => x.Name, x => x.Name) + .MapMemberFrom(x => x.Slug, x => x.Slug); + + config.CreateMap() + .MapCtorParamFrom(x => x.Id, x => x.Id) + .MapCtorParamFrom(x => x.Name, x => x.Name) + .MapCtorParamFrom(x => x.Level, x => x.Level) + .MapCtorParamFrom(x => x.MonthlyPrice, x => x.MonthlyPrice) + .MapCtorParamFrom(x => x.Description, x => x.Description); + + config.CreateMap() + .MapMemberFrom(x => x.Name, x => x.Name) + .MapMemberFrom(x => x.Level, x => x.Level) + .MapMemberFrom(x => x.MonthlyPrice, x => x.MonthlyPrice) + .MapMemberFrom(x => x.Description, x => x.Description); + + config.CreateMap() + .MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId) + .MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName) + .MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier); } } } diff --git a/OnlyPrompt.Backend/Utils/SlugHelper.cs b/OnlyPrompt.Backend/Utils/SlugHelper.cs index 2ab2007..1906359 100644 --- a/OnlyPrompt.Backend/Utils/SlugHelper.cs +++ b/OnlyPrompt.Backend/Utils/SlugHelper.cs @@ -16,16 +16,27 @@ namespace OnlyPrompt.Backend.Utils slug = InvalidCharacters.Replace(slug, string.Empty); slug = MultipleDashes.Replace(slug, "-"); slug = slug.Trim('-'); + if (maxLength.HasValue) + slug = slug.Limit(maxLength.Value); + return slug; } private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789"; public static async Task GenerateUniqueSlugAsync(string input, Func> existsFunc, int? maxLenght) { - var baseSlug = GenerateSlug(input, maxLenght - 9); - var slug = baseSlug; - var suffix = Random.Shared.GetString(8, SuffixChars); - return $"{slug}-{suffix}"; + var slug = GenerateSlug(input); + var exists = await existsFunc(slug); + if (exists) + { + var suffix = Random.Shared.GetString(8, SuffixChars); + if (maxLenght.HasValue) + slug = slug.Limit(maxLenght.Value - 9); + + slug = $"{slug}-{suffix}"; + } + + return slug; } } } diff --git a/OnlyPrompt.Backend/appsettings.json b/OnlyPrompt.Backend/appsettings.json index ee7e11f..74c60dc 100644 --- a/OnlyPrompt.Backend/appsettings.json +++ b/OnlyPrompt.Backend/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=postgres;Port=1803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;" + "DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=1803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;" }, "Jwt": { "Issuer": "https://onlyprompts.com",