- first version of backend done

This commit is contained in:
Aeolin Ferjünnoz 2026-04-12 03:45:01 +02:00
parent d466365348
commit 3c9f7323ba
19 changed files with 940 additions and 34 deletions

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -31,7 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<Results<RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request) public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request)
{ {
var user = await FindUserAsync(request.UserNameOrEmail); var user = await FindUserAsync(request.UserNameOrEmail);
if (user is null) if (user is null)
@ -46,7 +46,7 @@ namespace OnlyPrompt.Backend.Controllers
var token = _jwtService.BuildToken(user, out var validUntil); var token = _jwtService.BuildToken(user, out var validUntil);
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil)); this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
return TypedResults.Redirect("feed"); return TypedResults.Ok();
} }
[AllowAnonymous] [AllowAnonymous]
@ -67,7 +67,7 @@ namespace OnlyPrompt.Backend.Controllers
return TypedResults.ValidationProblem(errors); 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 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 avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}";
var newUser = new UserModel var newUser = new UserModel

View File

@ -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<string, string[]>
{
{ 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<ApiMinimalCategory[]> GetMinimalCategoriesAsync()
{
var categories = await _db.Categories
.ProjectTo<ApiMinimalCategory>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return categories;
}
[HttpGet]
[Authorize(Roles = ModelConstants.UserRole)]
public async Task<ApiCategory[]> 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<ApiCategory>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return categories;
}
[HttpPost]
public async Task<Results<Ok<ApiCategory>, ValidationProblem>> CreateCategoryAsync([FromBody] ApiCreateCategoryRequest request)
{
var exists = await _db.Categories.AnyAsync(c => c.Slug == request.Slug);
if (exists)
return SlugExistsProblem;
var model = _mapper.Map<CategoryModel>(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<ApiCategory>(model));
}
[HttpPut("{id}")]
public async Task<Results<Ok<ApiCategory>, NotFound<string>, 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<ApiCategory>(category));
}
[HttpDelete("{id}")]
public async Task<Results<NoContent, BadRequest<string>, NotFound<string>>> 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();
}
}
}

View File

@ -21,7 +21,7 @@ namespace OnlyPrompt.Backend.Controllers
[HttpGet] [HttpGet]
public async Task<ApiMinimalPrompt[]> GetFeedAsync( public async Task<ApiMinimalPrompt[]> GetFeedAsync(
[FromQuery]int offset = 0, [Range(0, double.MaxValue)][FromQuery]int offset = 0,
[Range(1, 100)][FromQuery]int limit = 20, [Range(1, 100)][FromQuery]int limit = 20,
[FromQuery]FeedSortType sortBy = FeedSortType.Date, [FromQuery]FeedSortType sortBy = FeedSortType.Date,
[FromQuery]bool ascending = false, [FromQuery]bool ascending = false,
@ -32,7 +32,10 @@ namespace OnlyPrompt.Backend.Controllers
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
var query = _db.Prompts 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) if (category.HasValue)
query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug); query = query.Where(x => category.Value.Id.HasValue ? x.CategoryId == category.Value.Id.Value : x.Category.Slug == category.Value.Slug);

View File

@ -122,7 +122,7 @@ namespace OnlyPrompt.Backend.Controllers
} }
[HttpPut("{id}/reviews")] [HttpPut("{id}/reviews")]
public async Task<Results<Ok<ApiReview>, NotFound<string>>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request) public async Task<Results<Ok<ApiReview>, BadRequest<string>, NotFound<string>>> AddReviewAsync(Identifier id, [FromBody] ApiCreateReviewRequest request)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
var prompt = await GetAccessiblePrompts(userId!.Value) var prompt = await GetAccessiblePrompts(userId!.Value)
@ -132,6 +132,9 @@ namespace OnlyPrompt.Backend.Controllers
if (prompt is null) if (prompt is null)
return TypedResults.NotFound("Prompt not found or no permission"); 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( var review = await _db.Reviews.FirstOrDefaultAsync(
r => r.PromptId == prompt.Id r => r.PromptId == prompt.Id
&& r.ReviewerId == userId && r.ReviewerId == userId

View File

@ -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<string, string[]>
{
{ 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<Results<Ok, BadRequest<string>, NotFound<string>>> 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<ApiSubscription[]> 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<ApiSubscription>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return subscriptions;
}
[HttpGet("{userId}")]
public async Task<ApiSubscription?> 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<ApiSubscription>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
return subscription;
}
[HttpDelete("{userId}")]
public async Task<Results<Ok, NotFound<string>>> 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<Results<Ok<ApiSubscriptionTier>, 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<SubscriptionTierModel>(tier);
model.UserId = userId!.Value;
_db.SubscriptionTiers.Add(model);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiSubscriptionTier>(model));
}
[HttpPut("tiers/{id}")]
public async Task<Results<Ok<ApiSubscriptionTier>, NotFound<string>, 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<ApiSubscriptionTier>(existingTier));
}
[HttpDelete("tiers/{id}")]
public async Task<Results<Ok, NotFound<string>>> 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();
}
}
}

View File

@ -13,14 +13,14 @@ namespace OnlyPrompt.Backend.Database.Models
public Guid SubscribedToId { get; set; } public Guid SubscribedToId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)] [DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel SubscribedTo { get; set; } public virtual UserModel SubscribedTo { get; set; }
[Required] [Required]
[ForeignKey(nameof(Subscriber))] [ForeignKey(nameof(Subscriber))]
public Guid SubscriberId { get; set; } public Guid SubscriberId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)] [DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel Subscriber { get; set; } public virtual UserModel Subscriber { get; set; }
[ForeignKey(nameof(SubscriptionTier))] [ForeignKey(nameof(SubscriptionTier))]

View File

@ -19,7 +19,7 @@ namespace OnlyPrompt.Backend.Database.Models
public Guid UserId { get; set; } public Guid UserId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)] [DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel User { get; set; } public virtual UserModel User { get; set; }
public decimal MonthlyPrice { get; set; } public decimal MonthlyPrice { get; set; }
public int Level { get; set; } public int Level { get; set; }

View File

@ -24,6 +24,7 @@ namespace OnlyPrompt.Backend.Database.Models
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>(); public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>(); public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
public virtual IList<SubscriptionModel> Subscribers { get; set; } = new List<SubscriptionModel>(); public virtual IList<SubscriptionModel> Subscribers { get; set; } = new List<SubscriptionModel>();
public virtual IList<SubscriptionTierModel> SubscriptionTiers { get; set; } = new List<SubscriptionTierModel>();
public bool IsLockoutEnabled { get; set; } = false; public bool IsLockoutEnabled { get; set; } = false;
} }

View File

@ -0,0 +1,419 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Prompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<Guid?>("SubscriptionTierId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<DateTime>("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<Guid>("SubscriberId")
.HasColumnType("uuid");
b.Property<Guid>("SubscribedToId")
.HasColumnType("uuid");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<decimal>("MonthlyPrice")
.HasColumnType("numeric");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("citext");
b.Property<bool>("IsLockoutEnabled")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsPublic")
.HasColumnType("boolean");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("citext");
b.Property<string>("Specialities")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("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
}
}
}

View File

@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class ReviewManyToMany : Migration
{
/// <inheritdoc />
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<string>(
name: "Description",
table: "Prompts",
type: "character varying(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Prompt",
table: "Prompts",
type: "character varying(4000)",
maxLength: 4000,
nullable: false,
defaultValue: "");
migrationBuilder.AddPrimaryKey(
name: "PK_Reviews",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" });
}
/// <inheritdoc />
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<Guid>(
name: "Id",
table: "Reviews",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<string>(
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);
}
}
}

View File

@ -1,5 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -67,16 +68,22 @@ namespace OnlyPrompt.Backend.Migrations
b.Property<Guid>("CategoryId") b.Property<Guid>("CategoryId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId") b.Property<Guid>("CreatorId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Prompt")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@ -109,8 +116,10 @@ namespace OnlyPrompt.Backend.Migrations
modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b => modelBuilder.Entity("OnlyPrompt.Backend.Database.Models.ReviewModel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("ReviewerId")
.ValueGeneratedOnAdd() .HasColumnType("uuid");
b.Property<Guid>("PromptId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Comment") b.Property<string>("Comment")
@ -120,25 +129,16 @@ namespace OnlyPrompt.Backend.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<int>("Rating") b.Property<int>("Rating")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.HasKey("Id"); b.HasKey("ReviewerId", "PromptId");
b.HasIndex("PromptId"); b.HasIndex("PromptId");
b.HasIndex("ReviewerId", "PromptId")
.IsUnique();
b.ToTable("Reviews"); b.ToTable("Reviews");
}); });
@ -221,7 +221,7 @@ namespace OnlyPrompt.Backend.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.PrimitiveCollection<string[]>("Roles") b.PrimitiveCollection<List<string>>("Roles")
.IsRequired() .IsRequired()
.HasColumnType("text[]"); .HasColumnType("text[]");

View File

@ -2,15 +2,18 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.Identity.Web; using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Tokens;
using OnlyPrompt.Backend.Database; using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt; using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils; using OnlyPrompt.Backend.Utils;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using System.Text;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
// Add services to the container. // Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
builder.Services.AddDbContext<OnlyPromptContext>(opts => builder.Services.AddDbContext<OnlyPromptContext>(opts =>
@ -26,6 +29,16 @@ builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) 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 opts.Events = new JwtBearerEvents
{ {
OnMessageReceived = context => OnMessageReceived = context =>
@ -56,4 +69,8 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync();
app.Run(); app.Run();

View File

@ -1,6 +1,8 @@
using AutoMapper; using AutoMapper;
using OnlyPrompt.Backend.ApiModels.Auth; using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.ApiModels.Category;
using OnlyPrompt.Backend.ApiModels.Prompt; using OnlyPrompt.Backend.ApiModels.Prompt;
using OnlyPrompt.Backend.ApiModels.Subscription;
using OnlyPrompt.Backend.ApiModels.UserProfile; using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Database.Models;
@ -45,13 +47,47 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId) .MapCtorParamFrom(x => x.CreatorId, x => x.CreatorId)
.MapCtorParamFrom(x => x.TierLevel, x => x.SubscriptionTier == null ? (int?)null : x.SubscriptionTier.Level) .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.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<ReviewModel, ApiReview>() config.CreateMap<ReviewModel, ApiReview>()
.MapCtorParamFrom(x => x.CreatorId, x => x.ReviewerId) .MapCtorParamFrom(x => x.CreatorId, x => x.ReviewerId)
.MapCtorParamFrom(x => x.CreatorName, x => x.Reviewer.Profile.DisplayName) .MapCtorParamFrom(x => x.CreatorName, x => x.Reviewer.Profile.DisplayName)
.MapCtorParamFrom(x => x.Comment, x => x.Comment) .MapCtorParamFrom(x => x.Comment, x => x.Comment)
.MapCtorParamFrom(x => x.Rating, x => x.Rating); .MapCtorParamFrom(x => x.Rating, x => x.Rating);
config.CreateMap<CategoryModel, ApiCategory>()
.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<CategoryModel, ApiMinimalCategory>()
.MapCtorParamFrom(x => x.Name, x => x.Name)
.MapCtorParamFrom(x => x.Slug, x => x.Slug);
config.CreateMap<ApiCreateCategoryRequest, CategoryModel>()
.MapMemberFrom(x => x.Description, x => x.Description)
.MapMemberFrom(x => x.Name, x => x.Name)
.MapMemberFrom(x => x.Slug, x => x.Slug);
config.CreateMap<SubscriptionTierModel, ApiSubscriptionTier>()
.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<ApiCreateSubscriptionTierRequest, SubscriptionTierModel>()
.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<SubscriptionModel, ApiSubscription>()
.MapCtorParamFrom(x => x.SubscribedToId, x => x.SubscribedToId)
.MapCtorParamFrom(x => x.SubscribedToName, x => x.SubscribedTo.Profile.DisplayName)
.MapCtorParamFrom(x => x.CurrentTier, x => x.SubscriptionTier);
} }
} }
} }

View File

@ -16,16 +16,27 @@ namespace OnlyPrompt.Backend.Utils
slug = InvalidCharacters.Replace(slug, string.Empty); slug = InvalidCharacters.Replace(slug, string.Empty);
slug = MultipleDashes.Replace(slug, "-"); slug = MultipleDashes.Replace(slug, "-");
slug = slug.Trim('-'); slug = slug.Trim('-');
if (maxLength.HasValue)
slug = slug.Limit(maxLength.Value);
return slug; return slug;
} }
private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789"; private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static async Task<string> GenerateUniqueSlugAsync(string input, Func<string, Task<bool>> existsFunc, int? maxLenght) public static async Task<string> GenerateUniqueSlugAsync(string input, Func<string, Task<bool>> existsFunc, int? maxLenght)
{ {
var baseSlug = GenerateSlug(input, maxLenght - 9); var slug = GenerateSlug(input);
var slug = baseSlug; var exists = await existsFunc(slug);
var suffix = Random.Shared.GetString(8, SuffixChars); if (exists)
return $"{slug}-{suffix}"; {
var suffix = Random.Shared.GetString(8, SuffixChars);
if (maxLenght.HasValue)
slug = slug.Limit(maxLenght.Value - 9);
slug = $"{slug}-{suffix}";
}
return slug;
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"ConnectionStrings": { "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": { "Jwt": {
"Issuer": "https://onlyprompts.com", "Issuer": "https://onlyprompts.com",