- implement most controllers

This commit is contained in:
Aeolin Ferjünnoz 2026-04-12 02:23:26 +02:00
parent 22aabc8f27
commit d466365348
24 changed files with 514 additions and 19 deletions

View File

@ -0,0 +1,8 @@
namespace OnlyPrompt.Backend.ApiModels.Prompt
{
public enum FeedSortType
{
Date,
Rating
}
}

View File

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

View File

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

View File

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

View File

@ -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 namespace OnlyPrompt.Backend.Controllers
{ {
[ApiController]
[Route("api/v1/admin")]
[Authorize(Roles = ModelConstants.AdminRole)]
public class AdminController : BaseController public class AdminController : BaseController
{ {
public AdminController(OnlyPromptContext db) : base(db) public AdminController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{ {
}
private Task<UserModel?> 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<Results<Ok, NotFound<string>>> 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<Results<Ok, NotFound<string>>> 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<Results<Ok, NotFound<string>>> 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<Results<Ok, NotFound<string>>> 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();
} }
} }
} }

View File

@ -20,12 +20,10 @@ namespace OnlyPrompt.Backend.Controllers
private readonly IPasswordHasher<UserModel> _passwordHasher; private readonly IPasswordHasher<UserModel> _passwordHasher;
private readonly ITokenService _jwtService; private readonly ITokenService _jwtService;
private readonly ILogger<AuthController> _logger; private readonly ILogger<AuthController> _logger;
private readonly IMapper _mapper;
public AuthController(OnlyPromptContext db, IPasswordHasher<UserModel> passwordHasher, IMapper mapper, ILogger<AuthController> logger, ITokenService jwtService) : base(db) public AuthController(OnlyPromptContext db, IPasswordHasher<UserModel> passwordHasher, IMapper mapper, ILogger<AuthController> logger, ITokenService jwtService) : base(db, mapper)
{ {
_passwordHasher=passwordHasher; _passwordHasher=passwordHasher;
_mapper=mapper;
_logger=logger; _logger=logger;
_jwtService=jwtService; _jwtService=jwtService;
} }
@ -70,7 +68,7 @@ namespace OnlyPrompt.Backend.Controllers
} }
var id = Guid.NewGuid(); 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 avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}";
var newUser = new UserModel var newUser = new UserModel
{ {

View File

@ -22,6 +22,16 @@ namespace OnlyPrompt.Backend.Controllers
public Task<UserModel?> FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName); public Task<UserModel?> FindUserAsync(string userName, string email) => _db.Users.FirstOrDefaultAsync(x => x.Email == email || x.UserName == userName);
public Task<UserModel?> FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername); public Task<UserModel?> FindUserAsync(string emailOrUsername) => _db.Users.FirstOrDefaultAsync(x => x.Email == emailOrUsername || x.UserName == emailOrUsername);
public async Task<UserProfileModel?> 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<UserModel?> GetUserAsync() public async Task<UserModel?> GetUserAsync()
{ {
var id = User.GetUserId(); var id = User.GetUserId();

View File

@ -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<ApiMinimalPrompt[]> 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;
}
}
}

View File

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.UserProfile; using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database; using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils; using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers namespace OnlyPrompt.Backend.Controllers
@ -16,6 +17,11 @@ namespace OnlyPrompt.Backend.Controllers
[Authorize(Roles = ModelConstants.UserRole)] [Authorize(Roles = ModelConstants.UserRole)]
public class ProfileController : BaseController public class ProfileController : BaseController
{ {
private static ValidationProblem SlugExistsProblem = TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(UserProfileModel.Slug), new[] { "Slug already exists." } }
});
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper) public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{ {
} }
@ -35,5 +41,38 @@ namespace OnlyPrompt.Backend.Controllers
return TypedResults.Ok(profile); return TypedResults.Ok(profile);
} }
[HttpPut]
public async Task<Results<ValidationProblem, NotFound<string>, Ok<ApiUserProfile>>> 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<ApiUserProfile>(self);
return TypedResults.Ok(result);
}
} }
} }

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.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<PromptModel> 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<Results<Ok<ApiPrompt>, NotFound<string>>> 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<ApiPrompt>(prompt);
return TypedResults.Ok(apiPrompt);
}
[HttpDelete("{id}")]
public async Task<Results<NoContent, NotFound<string>>> 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<Results<Ok<ApiPrompt>, NotFound<string>>> 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<ApiPrompt>(prompt);
return TypedResults.Ok(apiPrompt);
}
[HttpGet("{id}/reviews")]
public async Task<Ok<ApiReview[]>> 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<ApiReview>(_mapper.ConfigurationProvider)
.ToArrayAsync();
return TypedResults.Ok(reviews);
}
[HttpPut("{id}/reviews")]
public async Task<Results<Ok<ApiReview>, NotFound<string>>> 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<ApiReview>(review);
return TypedResults.Ok(apiReview);
}
[HttpDelete("{promptId}/reviews/{reviewerId}")]
public async Task<Results<NoContent, NotFound<string>>> 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();
}
}
}

View File

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Core namespace OnlyPrompt.Backend.Database.Core
{ {
public class EntityBase : IEntity public class EntityBase : IEntity, ITrackableEntity
{ {
[Key] [Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)] [DatabaseGenerated(DatabaseGeneratedOption.Identity)]

View File

@ -0,0 +1,8 @@
namespace OnlyPrompt.Backend.Database.Core
{
public interface ITrackableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
}

View File

@ -1,8 +1,14 @@
namespace OnlyPrompt.Backend.Database using System.Collections.Frozen;
namespace OnlyPrompt.Backend.Database
{ {
public static class ModelConstants public static class ModelConstants
{ {
public const int MaxSlugLength = 100; public const int MaxSlugLength = 100;
public const string UserRole = "user"; public const string UserRole = "user";
public const string AdminRole = "admin";
public const string SysAdminRole = "sys-admin";
public static readonly FrozenSet<string> AllRoles = FrozenSet.Create(StringComparer.OrdinalIgnoreCase, UserRole, AdminRole, SysAdminRole);
} }
} }

View File

@ -13,19 +13,23 @@ namespace OnlyPrompt.Backend.Database.Models
public Guid CreatorId { get; set; } public Guid CreatorId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)] [DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel Creator { get; set; } public virtual UserModel Creator { get; set; }
[Required] [Required]
[ForeignKey(nameof(Category))] [ForeignKey(nameof(Category))]
public Guid CategoryId { get; set; } public Guid CategoryId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)] [DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual CategoryModel Category { get; set; } public virtual CategoryModel Category { get; set; }
[MaxLength(200)] [MaxLength(200)]
public required string Title { get; set; } 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)] [MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")] [Column(TypeName = "citext")]

View File

@ -5,9 +5,12 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models namespace OnlyPrompt.Backend.Database.Models
{ {
[Index(nameof(ReviewerId), nameof(PromptId), IsUnique = true)] [PrimaryKey(nameof(ReviewerId), nameof(PromptId))]
public class ReviewModel : EntityBase public class ReviewModel : ITrackableEntity
{ {
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
[Required] [Required]
[ForeignKey(nameof(Reviewer))] [ForeignKey(nameof(Reviewer))]
public Guid ReviewerId { get; set; } public Guid ReviewerId { get; set; }

View File

@ -16,7 +16,7 @@ namespace OnlyPrompt.Backend.Database.Models
[Column(TypeName = "citext")] [Column(TypeName = "citext")]
public required string Email { get; set; } public required string Email { get; set; }
public required string PasswordHash { get; set; } public required string PasswordHash { get; set; }
public required string[] Roles { get; set; } public required List<string> Roles { get; set; } = new List<string>();
[Required] [Required]
public required virtual UserProfileModel Profile { get; set; } public required virtual UserProfileModel Profile { get; set; }

View File

@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using OnlyPrompt.Backend.Database.Core;
using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Database.Models;
namespace OnlyPrompt.Backend.Database namespace OnlyPrompt.Backend.Database
@ -14,6 +15,40 @@ namespace OnlyPrompt.Backend.Database
public DbSet<SubscriptionModel> Subscriptions { get; set; } public DbSet<SubscriptionModel> Subscriptions { get; set; }
public DbSet<ReviewModel> Reviews { get; set; } public DbSet<ReviewModel> Reviews { get; set; }
public OnlyPromptContext(DbContextOptions<OnlyPromptContext> options) : base(options)
{
}
private void HandleEntityTimestamps()
{
var entries = ChangeTracker.Entries<ITrackableEntity>();
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<int> 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) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
base.OnConfiguring(optionsBuilder); base.OnConfiguring(optionsBuilder);

View File

@ -4,7 +4,6 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-OnlyPrompt.Backend-dc36f9f9-a53c-4ee3-a0b4-3396ee55b7c8</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
@ -24,7 +23,6 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Identity.Web" Version="3.14.1" /> <PackageReference Include="Microsoft.Identity.Web" Version="3.14.1" />
<PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.14.1" /> <PackageReference Include="Microsoft.Identity.Web.DownstreamApi" Version="3.14.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.13.22" /> <PackageReference Include="Scalar.AspNetCore" Version="2.13.22" />
<PackageReference Include="Scalar.AspNetCore.Microsoft" Version="2.13.22" /> <PackageReference Include="Scalar.AspNetCore.Microsoft" Version="2.13.22" />

View File

@ -7,6 +7,7 @@ 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;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -38,7 +39,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
}); });
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
var app = builder.Build(); var app = builder.Build();
@ -46,6 +47,7 @@ var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
app.MapScalarApiReference();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();

View File

@ -2,6 +2,8 @@
"profiles": { "profiles": {
"https": { "https": {
"commandName": "Project", "commandName": "Project",
"launchBrowser": true,
"launchUrl": "https://localhost:7163/scalar",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },

View File

@ -1,5 +1,6 @@
using AutoMapper; using AutoMapper;
using OnlyPrompt.Backend.ApiModels.Auth; using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.ApiModels.Prompt;
using OnlyPrompt.Backend.ApiModels.UserProfile; using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Database.Models;
@ -23,6 +24,34 @@ namespace OnlyPrompt.Backend.Utils
.MapCtorParamFrom(x => x.Specialities, x => x.Specialities) .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.AverageRating, x => x.User.Prompts.Average(p => p.Reviews.Average(r => r.Rating)))
.MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count()); .MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count());
config.CreateMap<PromptModel, ApiPrompt>()
.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<PromptModel, ApiMinimalPrompt>()
.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<ReviewModel, ApiReview>()
.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);
} }
} }
} }

View File

@ -42,5 +42,7 @@ namespace OnlyPrompt.Backend.Utils
mapping.ForCtorParam(ctorParamName, configure); mapping.ForCtorParam(ctorParamName, configure);
return mapping; return mapping;
} }
} }
} }

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using OnlyPrompt.Backend.Database.Models; using OnlyPrompt.Backend.Database.Models;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Security.Claims; using System.Security.Claims;
@ -33,6 +34,14 @@ namespace OnlyPrompt.Backend.Utils
yield return new Claim(ClaimTypes.Role, role); yield return new Claim(ClaimTypes.Role, role);
} }
public static IQueryable<T> OrderBy<T, TKey>(this IQueryable<T> source, Expression<Func<T, TKey>> selecter, bool ascending)
{
if(ascending)
return source.OrderBy(selecter);
else
return source.OrderByDescending(selecter);
}
public static CookieOptions Copy(this CookieOptions options, Action<CookieOptions>? modify = null) public static CookieOptions Copy(this CookieOptions options, Action<CookieOptions>? modify = null)
{ {
var newOptions = new CookieOptions var newOptions = new CookieOptions

View File

@ -20,7 +20,7 @@ namespace OnlyPrompt.Backend.Utils
} }
private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789"; private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static async Task<string> GenerateUniqueSlug(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 baseSlug = GenerateSlug(input, maxLenght - 9);
var slug = baseSlug; var slug = baseSlug;