- implement most controllers
This commit is contained in:
parent
22aabc8f27
commit
d466365348
8
OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs
Normal file
8
OnlyPrompt.Backend/ApiModels/Prompt/FeedSortType.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace OnlyPrompt.Backend.ApiModels.Prompt
|
||||||
|
{
|
||||||
|
public enum FeedSortType
|
||||||
|
{
|
||||||
|
Date,
|
||||||
|
Rating
|
||||||
|
}
|
||||||
|
}
|
||||||
6
OnlyPrompt.Backend/ApiModels/Prompt/Models.cs
Normal file
6
OnlyPrompt.Backend/ApiModels/Prompt/Models.cs
Normal 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);
|
||||||
|
}
|
||||||
6
OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs
Normal file
6
OnlyPrompt.Backend/ApiModels/Prompt/Requests.cs
Normal 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);
|
||||||
|
}
|
||||||
8
OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs
Normal file
8
OnlyPrompt.Backend/ApiModels/UserProfile/Requests.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
70
OnlyPrompt.Backend/Controllers/FeedController.cs
Normal file
70
OnlyPrompt.Backend/Controllers/FeedController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
180
OnlyPrompt.Backend/Controllers/PromptController.cs
Normal file
180
OnlyPrompt.Backend/Controllers/PromptController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)]
|
||||||
|
|||||||
8
OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs
Normal file
8
OnlyPrompt.Backend/Database/Core/ITrackableEntity.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace OnlyPrompt.Backend.Database.Core
|
||||||
|
{
|
||||||
|
public interface ITrackableEntity
|
||||||
|
{
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,5 +42,7 @@ namespace OnlyPrompt.Backend.Utils
|
|||||||
mapping.ForCtorParam(ctorParamName, configure);
|
mapping.ForCtorParam(ctorParamName, configure);
|
||||||
return mapping;
|
return mapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user