- started implementing backend

This commit is contained in:
Aeolin Ferjünnoz 2026-04-11 21:36:05 +02:00
parent 3da8813c41
commit 22aabc8f27
92 changed files with 2695 additions and 0 deletions

30
.dockerignore Normal file
View File

@ -0,0 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

428
.gitignore vendored Normal file
View File

@ -0,0 +1,428 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.env
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
[Dd]ebug/x64/
[Dd]ebugPublic/x64/
[Rr]elease/x64/
[Rr]eleases/x64/
bin/x64/
obj/x64/
[Dd]ebug/x86/
[Dd]ebugPublic/x86/
[Rr]elease/x86/
[Rr]eleases/x86/
bin/x86/
obj/x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Aa][Rr][Mm]64[Ee][Cc]/
bld/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Build results on 'Bin' directories
**/[Bb]in/*
# Uncomment if you have tasks that rely on *.refresh files to move binaries
# (https://github.com/github/gitignore/pull/3736)
#!**/[Bb]in/*.refresh
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Approval Tests result files
*.received.*
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.idb
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
**/.paket/paket.exe
paket-files/
# FAKE - F# Make
**/.fake/
# CodeRush personal settings
**/.cr/personal
# Python Tools for Visual Studio (PTVS)
**/__pycache__/
*.pyc
# Cake - Uncomment if you are using it
#tools/**
#!tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
MSBuild_Logs/
# AWS SAM Build and Temporary Artifacts folder
.aws-sam
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
**/.mfractor/
# Local History for Visual Studio
**/.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
**/.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp

View File

@ -0,0 +1,4 @@
namespace OnlyPrompt.Backend.ApiModels.Auth
{
public record ApiUser(Guid Id, string UserName, string Email, string[] Roles);
}

View File

@ -0,0 +1,8 @@
using OnlyPrompt.Backend.ApiModels.Validators;
using System.ComponentModel.DataAnnotations;
namespace OnlyPrompt.Backend.ApiModels.Auth
{
public record ApiLoginRequest(string UserNameOrEmail, string Password);
public record ApiRegisterRequest([MaxLength(100)] string DisplayName, [MaxLength(100)][NoWhitespace] string UserName, string Email, string Password);
}

View File

@ -0,0 +1,4 @@
namespace OnlyPrompt.Backend.ApiModels.UserProfile
{
public record ApiUserProfile(string DisplayName, string Slug, string? Bio, string AvatarUrl, string? Specialities, double AverageRating, int Subscribers);
}

View File

@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection.Metadata.Ecma335;
namespace OnlyPrompt.Backend.ApiModels.Validators
{
public class NoWhitespaceAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if(value is string strValue)
{
if(strValue.Any(c => char.IsWhiteSpace(c)))
return new ValidationResult($"{validationContext.DisplayName} should not contain any whitespace characters.");
return ValidationResult.Success;
}
return base.IsValid(value, validationContext);
}
}
}

View File

@ -0,0 +1,11 @@
using OnlyPrompt.Backend.Database;
namespace OnlyPrompt.Backend.Controllers
{
public class AdminController : BaseController
{
public AdminController(OnlyPromptContext db) : base(db)
{
}
}
}

View File

@ -0,0 +1,105 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/auth")]
public class AuthController : BaseController
{
private static readonly CookieOptions AuthCookieOptions = new CookieOptions { Secure = true, HttpOnly = true, IsEssential = true };
private readonly IPasswordHasher<UserModel> _passwordHasher;
private readonly ITokenService _jwtService;
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)
{
_passwordHasher=passwordHasher;
_mapper=mapper;
_logger=logger;
_jwtService=jwtService;
}
[AllowAnonymous]
[HttpPost("login")]
public async Task<Results<RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request)
{
var user = await FindUserAsync(request.UserNameOrEmail);
if (user is null)
return TypedResults.NotFound("User not found");
var verificationResult = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (verificationResult == PasswordVerificationResult.Failed)
return TypedResults.NotFound("User not found"); // Don't reveal that the user exists
if (user.IsLockoutEnabled)
return TypedResults.BadRequest("User is locked out"); // Don't reveal that the user exists
var token = _jwtService.BuildToken(user, out var validUntil);
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
return TypedResults.Redirect("feed");
}
[AllowAnonymous]
[HttpPost("register")]
public async Task<Results<RedirectHttpResult, ValidationProblem, Ok<ApiUser>>> RegisterAsync([FromBody] ApiRegisterRequest request)
{
var existingUser = await FindUserAsync(request.UserName, request.Email);
if (existingUser is not null)
{
var errors = new Dictionary<string, string[]>();
if (existingUser.UserName == request.UserName)
errors.Add(nameof(request.UserName), ["Username is already taken"]);
if (existingUser.Email == request.Email)
errors.Add(nameof(request.Email), ["Email is already registered"]);
return TypedResults.ValidationProblem(errors);
}
var id = Guid.NewGuid();
var slug = await SlugHelper.GenerateUniqueSlug(request.UserName, s => _db.UserProfiles.AnyAsync(up => up.Slug == s), 32);
var avatarUrl = $"https://api.dicebear.com/9.x/bottts/svg?seed={id}";
var newUser = new UserModel
{
Id = id,
Profile = new UserProfileModel
{
AvatarUrl = avatarUrl,
DisplayName = request.DisplayName,
Slug = slug,
},
Roles = [ModelConstants.UserRole],
PasswordHash = null,
UserName = request.UserName,
Email = request.Email,
IsLockoutEnabled = false,
};
newUser.PasswordHash = _passwordHasher.HashPassword(newUser, request.Password);
_db.Users.Add(newUser);
await _db.SaveChangesAsync();
return TypedResults.Ok(_mapper.Map<ApiUser>(newUser));
}
[HttpPost("logout")]
public RedirectHttpResult Logout()
{
this.Response.Cookies.Delete("jwt", AuthCookieOptions);
return TypedResults.Redirect("login");
}
}
}

View File

@ -0,0 +1,35 @@
using AutoMapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
public abstract class BaseController : Controller
{
protected OnlyPromptContext _db;
protected IMapper _mapper;
public BaseController(OnlyPromptContext db, IMapper mapper)
{
_db=db;
_mapper=mapper;
}
public Task<UserModel?> FindUserAsync(Guid id) => _db.Users.FirstOrDefaultAsync(x => x.Id == id);
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 async Task<UserModel?> GetUserAsync()
{
var id = User.GetUserId();
if (id.HasValue == false)
return null;
var user = await _db.Users.FindAsync(id.Value);
return user;
}
}
}

View File

@ -0,0 +1,39 @@
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.UserProfile;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Utils;
namespace OnlyPrompt.Backend.Controllers
{
[ApiController]
[Route("api/v1/profiles")]
[Authorize(Roles = ModelConstants.UserRole)]
public class ProfileController : BaseController
{
public ProfileController(OnlyPromptContext db, IMapper mapper) : base(db, mapper)
{
}
[HttpGet("{id}")]
public async Task<Results<NotFound<string>, Ok<ApiUserProfile>>> GetProfileAsync(Identifier id)
{
var userId = User.GetUserId();
var profile = await _db.UserProfiles.OfIdentifer(id)
.Where(up => up.IsPublic || up.Id == userId)
.ProjectTo<ApiUserProfile>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
if (profile is null)
return TypedResults.NotFound("Profile not found or is private.");
return TypedResults.Ok(profile);
}
}
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Core
{
public class EntityBase : IEntity
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,7 @@
namespace OnlyPrompt.Backend.Database.Core
{
public interface IEntity
{
public Guid Id { get; }
}
}

View File

@ -0,0 +1,7 @@
namespace OnlyPrompt.Backend.Database.Core
{
public interface IHasSlug
{
public string Slug { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace OnlyPrompt.Backend.Database
{
public static class ModelConstants
{
public const int MaxSlugLength = 100;
public const string UserRole = "user";
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Slug), IsUnique = true)]
public class CategoryModel : EntityBase, IHasSlug
{
[MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
}
}

View File

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Slug), IsUnique = true)]
public class PromptModel : EntityBase, IHasSlug
{
[Required]
[ForeignKey(nameof(Creator))]
public Guid CreatorId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel Creator { get; set; }
[Required]
[ForeignKey(nameof(Category))]
public Guid CategoryId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual CategoryModel Category { get; set; }
[MaxLength(200)]
public required string Title { get; set; }
public required string Content { get; set; }
[MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
[ForeignKey(nameof(SubscriptionTier))]
public Guid? SubscriptionTierId { get; set; }
[DeleteBehavior(DeleteBehavior.SetNull)]
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
public virtual IList<ReviewModel> Reviews { get; set; } = new List<ReviewModel>();
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(ReviewerId), nameof(PromptId), IsUnique = true)]
public class ReviewModel : EntityBase
{
[Required]
[ForeignKey(nameof(Reviewer))]
public Guid ReviewerId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual UserModel Reviewer { get; set; }
[Required]
[ForeignKey(nameof(Prompt))]
public Guid PromptId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public virtual PromptModel Prompt { get; set; }
[Range(1, 5)]
public int Rating { get; set; }
[MaxLength(200)]
public string? Comment { get; set; } = null;
}
}

View File

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Table("UserSubscriptions")]
[PrimaryKey(nameof(SubscriberId), nameof(SubscribedToId))]
public class SubscriptionModel
{
[Required]
[ForeignKey(nameof(SubscribedTo))]
public Guid SubscribedToId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel SubscribedTo { get; set; }
[Required]
[ForeignKey(nameof(Subscriber))]
public Guid SubscriberId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel Subscriber { get; set; }
[ForeignKey(nameof(SubscriptionTier))]
public virtual Guid? SubscriptionTierId { get; set; }
[DeleteBehavior(DeleteBehavior.SetNull)]
public virtual SubscriptionTierModel? SubscriptionTier { get; set; }
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Level), nameof(UserId), IsUnique = true)]
public class SubscriptionTierModel : EntityBase
{
[Required]
public required string Name { get; set; }
[MaxLength(1000)]
public string? Description { get; set; }
[Required]
[ForeignKey(nameof(User))]
public Guid UserId { get; set; }
[DeleteBehavior(DeleteBehavior.Cascade)]
public required virtual UserModel User { get; set; }
public decimal MonthlyPrice { get; set; }
public int Level { get; set; }
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Email), IsUnique = true)]
[Index(nameof(UserName), IsUnique = true)]
public class UserModel : EntityBase
{
[MaxLength(100)]
[Column(TypeName = "citext")]
public required string UserName { get; set; }
[Column(TypeName = "citext")]
public required string Email { get; set; }
public required string PasswordHash { get; set; }
public required string[] Roles { get; set; }
[Required]
public required virtual UserProfileModel Profile { get; set; }
public virtual IList<PromptModel> Prompts { get; set; } = new List<PromptModel>();
public virtual IList<SubscriptionModel> Subscriptions { get; set; } = new List<SubscriptionModel>();
public virtual IList<SubscriptionModel> Subscribers { get; set; } = new List<SubscriptionModel>();
public bool IsLockoutEnabled { get; set; } = false;
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OnlyPrompt.Backend.Database.Models
{
[Index(nameof(Slug), IsUnique = true)]
public class UserProfileModel : EntityBase, IHasSlug
{
[MaxLength(100)]
public required string DisplayName { get; set; }
[MaxLength(ModelConstants.MaxSlugLength)]
[Column(TypeName = "citext")]
public required string Slug { get; set; }
[MaxLength(2000)]
public string? Bio { get; set; }
public required string AvatarUrl { get; set; }
[MaxLength(200)]
public string? Specialities { get; set; }
public virtual UserModel User { get; set; }
public bool IsPublic { get; set; } = false;
}
}

View File

@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using OnlyPrompt.Backend.Database.Models;
namespace OnlyPrompt.Backend.Database
{
public class OnlyPromptContext : DbContext
{
public DbSet<UserModel> Users { get; set; }
public DbSet<UserProfileModel> UserProfiles { get; set; }
public DbSet<CategoryModel> Categories { get; set; }
public DbSet<PromptModel> Prompts { get; set; }
public DbSet<SubscriptionTierModel> SubscriptionTiers { get; set; }
public DbSet<SubscriptionModel> Subscriptions { get; set; }
public DbSet<ReviewModel> Reviews { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
if(optionsBuilder.IsConfigured == false)
{
optionsBuilder.UseNpgsql();
optionsBuilder.UseLazyLoadingProxies();
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UserModel>(entity =>
{
entity.HasOne(e => e.Profile)
.WithOne(p => p.User)
.HasForeignKey<UserProfileModel>(p => p.Id);
});
modelBuilder.Entity<SubscriptionModel>(entity =>
{
entity.HasOne(e => e.Subscriber)
.WithMany(s => s.Subscriptions)
.HasForeignKey(e => e.SubscriberId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.SubscribedTo)
.WithMany(c => c.Subscribers)
.HasForeignKey(e => e.SubscribedToId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.SubscriptionTier)
.WithMany(t => t.Subscriptions)
.HasForeignKey(e => e.SubscriptionTierId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}
}

View File

@ -0,0 +1,30 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
COPY . .
WORKDIR "/src/OnlyPrompt.Backend"
RUN dotnet build "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./OnlyPrompt.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "OnlyPrompt.Backend.dll"]

View File

@ -0,0 +1,419 @@
// <auto-generated />
using System;
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("20260411191205_Initial")]
partial class Initial
{
/// <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<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
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>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("PromptId");
b.HasIndex("ReviewerId", "PromptId")
.IsUnique();
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<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,300 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:citext", ",,");
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserName = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
Email = table.Column<string>(type: "citext", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: false),
Roles = table.Column<string[]>(type: "text[]", nullable: false),
IsLockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SubscriptionTiers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MonthlyPrice = table.Column<decimal>(type: "numeric", nullable: false),
Level = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SubscriptionTiers", x => x.Id);
table.ForeignKey(
name: "FK_SubscriptionTiers_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserProfiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DisplayName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
Bio = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
AvatarUrl = table.Column<string>(type: "text", nullable: false),
Specialities = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsPublic = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserProfiles", x => x.Id);
table.ForeignKey(
name: "FK_UserProfiles_Users_Id",
column: x => x.Id,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Prompts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CreatorId = table.Column<Guid>(type: "uuid", nullable: false),
CategoryId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
Slug = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
SubscriptionTierId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Prompts", x => x.Id);
table.ForeignKey(
name: "FK_Prompts_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Prompts_SubscriptionTiers_SubscriptionTierId",
column: x => x.SubscriptionTierId,
principalTable: "SubscriptionTiers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Prompts_Users_CreatorId",
column: x => x.CreatorId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSubscriptions",
columns: table => new
{
SubscribedToId = table.Column<Guid>(type: "uuid", nullable: false),
SubscriberId = table.Column<Guid>(type: "uuid", nullable: false),
SubscriptionTierId = table.Column<Guid>(type: "uuid", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSubscriptions", x => new { x.SubscriberId, x.SubscribedToId });
table.ForeignKey(
name: "FK_UserSubscriptions_SubscriptionTiers_SubscriptionTierId",
column: x => x.SubscriptionTierId,
principalTable: "SubscriptionTiers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_UserSubscriptions_Users_SubscribedToId",
column: x => x.SubscribedToId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserSubscriptions_Users_SubscriberId",
column: x => x.SubscriberId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Reviews",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ReviewerId = table.Column<Guid>(type: "uuid", nullable: false),
PromptId = table.Column<Guid>(type: "uuid", nullable: false),
Rating = table.Column<int>(type: "integer", nullable: false),
Comment = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reviews", x => x.Id);
table.ForeignKey(
name: "FK_Reviews_Prompts_PromptId",
column: x => x.PromptId,
principalTable: "Prompts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Reviews_Users_ReviewerId",
column: x => x.ReviewerId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Categories_Slug",
table: "Categories",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Prompts_CategoryId",
table: "Prompts",
column: "CategoryId");
migrationBuilder.CreateIndex(
name: "IX_Prompts_CreatorId",
table: "Prompts",
column: "CreatorId");
migrationBuilder.CreateIndex(
name: "IX_Prompts_Slug",
table: "Prompts",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Prompts_SubscriptionTierId",
table: "Prompts",
column: "SubscriptionTierId");
migrationBuilder.CreateIndex(
name: "IX_Reviews_PromptId",
table: "Reviews",
column: "PromptId");
migrationBuilder.CreateIndex(
name: "IX_Reviews_ReviewerId_PromptId",
table: "Reviews",
columns: new[] { "ReviewerId", "PromptId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SubscriptionTiers_Level_UserId",
table: "SubscriptionTiers",
columns: new[] { "Level", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_SubscriptionTiers_UserId",
table: "SubscriptionTiers",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_UserProfiles_Slug",
table: "UserProfiles",
column: "Slug",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_UserName",
table: "Users",
column: "UserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSubscriptions_SubscribedToId",
table: "UserSubscriptions",
column: "SubscribedToId");
migrationBuilder.CreateIndex(
name: "IX_UserSubscriptions_SubscriptionTierId",
table: "UserSubscriptions",
column: "SubscriptionTierId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Reviews");
migrationBuilder.DropTable(
name: "UserProfiles");
migrationBuilder.DropTable(
name: "UserSubscriptions");
migrationBuilder.DropTable(
name: "Prompts");
migrationBuilder.DropTable(
name: "Categories");
migrationBuilder.DropTable(
name: "SubscriptionTiers");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,416 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using OnlyPrompt.Backend.Database;
#nullable disable
namespace OnlyPrompt.Backend.Migrations
{
[DbContext(typeof(OnlyPromptContext))]
partial class OnlyPromptContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatorId")
.HasColumnType("uuid");
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>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("PromptId")
.HasColumnType("uuid");
b.Property<int>("Rating")
.HasColumnType("integer");
b.Property<Guid>("ReviewerId")
.HasColumnType("uuid");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("PromptId");
b.HasIndex("ReviewerId", "PromptId")
.IsUnique();
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<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,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-OnlyPrompt.Backend-dc36f9f9-a53c-4ee3-a0b4-3396ee55b7c8</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.5" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Identity.Web" 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="Scalar.AspNetCore" Version="2.13.22" />
<PackageReference Include="Scalar.AspNetCore.Microsoft" Version="2.13.22" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@OnlyPrompt.Backend_HostAddress = http://localhost:5093
GET {{OnlyPrompt.Backend_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Identity.Web;
using OnlyPrompt.Backend.Database;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme);
builder.Services.AddDbContext<OnlyPromptContext>(opts =>
{
opts.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
opts.UseLazyLoadingProxies();
});
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
builder.Services.AddSingleton<ITokenService, JwtTokenService>();
builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => {
opts.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (context.Request.Cookies.ContainsKey("jwt"))
context.Token = context.Request.Cookies["jwt"];
return Task.CompletedTask;
}
};
});
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,23 @@
{
"profiles": {
"https": {
"commandName": "Project",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7163;http://localhost:5093"
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}

View File

@ -0,0 +1,15 @@
using OnlyPrompt.Backend.Database.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace OnlyPrompt.Backend.Services.Jwt
{
public interface ITokenService
{
string BuildToken(UserModel user, out DateTime validUntil);
bool ValidateToken(string token, out ClaimsPrincipal claims);
}
}

View File

@ -0,0 +1,72 @@
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using OnlyPrompt.Backend.Database.Models;
using OnlyPrompt.Backend.Utils;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace OnlyPrompt.Backend.Services.Jwt
{
public class JwtTokenService : ITokenService
{
private string _key;
private string _issuer;
private string _audience;
private TimeSpan _valid;
public JwtTokenService(IConfiguration config)
{
config = config.GetSection("Jwt");
_key = config["Key"];
_issuer = config["Issuer"];
_audience = config["Audience"];
_valid = config.GetValue<TimeSpan>("Valid");
}
public string BuildToken(UserModel user, out DateTime validUntil)
{
var claims = user.GetClaims().ToList();
validUntil = DateTime.UtcNow.Add(_valid);
claims.Add(new Claim("exp", new DateTimeOffset(validUntil).ToUnixTimeSeconds().ToString()));
claims.Add(new Claim("amr", "pwd"));
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(_issuer, _audience, claims, expires: validUntil, signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}
public bool ValidateToken(string token, out ClaimsPrincipal claims)
{
var mySecret = Encoding.UTF8.GetBytes(_key);
var mySecurityKey = new SymmetricSecurityKey(mySecret);
var tokenHandler = new JwtSecurityTokenHandler();
try
{
claims = tokenHandler.ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidIssuer = _issuer,
ValidAudience = _audience,
IssuerSigningKey = mySecurityKey,
}, out SecurityToken validatedToken);
}
catch
{
claims = null;
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,28 @@
using AutoMapper;
using OnlyPrompt.Backend.ApiModels.Auth;
using OnlyPrompt.Backend.ApiModels.UserProfile;
using OnlyPrompt.Backend.Database.Models;
namespace OnlyPrompt.Backend.Utils
{
public static class AutoMapperSetup
{
public static void Setup(IMapperConfigurationExpression config)
{
config.CreateMap<UserModel, ApiUser>()
.MapCtorParamFrom(x => x.Id, x => x.Id)
.MapCtorParamFrom(x => x.UserName, x => x.UserName)
.MapCtorParamFrom(x => x.Roles, x => x.Roles)
.MapCtorParamFrom(x => x.Email, x => x.Email);
config.CreateMap<UserProfileModel, ApiUserProfile>()
.MapCtorParamFrom(x => x.DisplayName, x => x.DisplayName)
.MapCtorParamFrom(x => x.Slug, x => x.Slug)
.MapCtorParamFrom(x => x.Bio, x => x.Bio)
.MapCtorParamFrom(x => x.AvatarUrl, x => x.AvatarUrl)
.MapCtorParamFrom(x => x.Specialities, x => x.Specialities)
.MapCtorParamFrom(x => x.AverageRating, x => x.User.Prompts.Average(p => p.Reviews.Average(r => r.Rating)))
.MapCtorParamFrom(x => x.Subscribers, x => x.User.Subscribers.Count());
}
}
}

View File

@ -0,0 +1,46 @@
using AutoMapper;
using AutoMapper.Configuration;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading.Tasks;
namespace OnlyPrompt.Backend.Utils
{
public static class AutomapperExtensions
{
public static IMappingExpression<TSource, TDestination> CreateUpdateMap<TSource, TDestination>(this IMapperConfigurationExpression cfg, MemberList memberList = MemberList.Source)
{
return cfg.CreateMap<TSource, TDestination>(memberList)
.IgnoreNullMembers();
}
public static IMappingExpression<TSource, TDestination> MapMemberFrom<TSource, TDestination, TMember, TSourceMember>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TSourceMember>> sourceMember)
{
mapping.ForMember(destinationMember, x => x.MapFrom(sourceMember));
return mapping;
}
public static IMappingExpression<TSource, TDestination> IgnoreNullMembers<TSource, TDestination>(this IMappingExpression<TSource, TDestination> mapping)
{
mapping.ForAllMembers(opts => opts.Condition((src, dest, member) => src != null));
return mapping;
}
public static IMappingExpression<TSource, TDestination> MapCtorParamFrom<TSource, TDestination, TMember, TSourceMember>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TSourceMember>> sourceMember)
{
mapping.ForCtorParam(destinationMember, x => x.MapFrom(sourceMember));
return mapping;
}
public static IMappingExpression<TSource, TDestination> ForCtorParam<TSource, TDestination, DValue>(this IMappingExpression<TSource, TDestination> mapping, Expression<Func<TDestination, DValue>> paramSelector, Action<ICtorParamConfigurationExpression<TSource>> configure)
{
var ctorParamName = ((MemberExpression)paramSelector.Body).Member.Name;
mapping.ForCtorParam(ctorParamName, configure);
return mapping;
}
}
}

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using OnlyPrompt.Backend.Database.Core;
namespace OnlyPrompt.Backend.Utils
{
public static class EntityExtensions
{
public static Task<T?> FindBySlugAsync<T>(this IQueryable<T> queryable, string slug) where T : class, IHasSlug
{
return queryable.FirstOrDefaultAsync(e => e.Slug == slug);
}
public static Task<T?> FindByIdentifierAsync<T>(this IQueryable<T> queryable, Identifier identifier) where T : class, IHasSlug, IEntity
{
if (identifier.Id.HasValue)
return queryable.FirstOrDefaultAsync(e => e.Id == identifier.Id.Value);
return queryable.FindBySlugAsync(identifier.Slug);
}
public static IQueryable<T> OfIdentifer<T>(this IQueryable<T> queryable, Identifier identifier) where T : class, IHasSlug, IEntity
{
if (identifier.Id.HasValue)
return queryable.Where(e => e.Id == identifier.Id.Value);
return queryable.Where(e => e.Slug == identifier.Slug);
}
}
}

View File

@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OnlyPrompt.Backend.Database.Models;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Security.Claims;
namespace OnlyPrompt.Backend.Utils
{
public static class Extensions
{
public static string? GetIdentifier(this ClaimsPrincipal principal) => principal.FindFirstValue(ClaimTypes.NameIdentifier);
public static bool TryGetIdentifier(this ClaimsPrincipal principal, [NotNullWhen(true)]out string? identifier)
{
identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier);
return identifier != null;
}
public static Guid? GetUserId(this ClaimsPrincipal principal)
{
if (principal.TryGetIdentifier(out var identifier) && Guid.TryParse(identifier, out var userId))
return userId;
return null;
}
public static IEnumerable<Claim> GetClaims(this UserModel user)
{
yield return new Claim(ClaimTypes.NameIdentifier, user.Id.ToString());
yield return new Claim(ClaimTypes.Name, user.UserName);
yield return new Claim(ClaimTypes.Email, user.Email);
foreach (var role in user.Roles)
yield return new Claim(ClaimTypes.Role, role);
}
public static CookieOptions Copy(this CookieOptions options, Action<CookieOptions>? modify = null)
{
var newOptions = new CookieOptions
{
Domain = options.Domain,
Expires = options.Expires,
HttpOnly = options.HttpOnly,
IsEssential = options.IsEssential,
MaxAge = options.MaxAge,
Path = options.Path,
SameSite = options.SameSite,
Secure = options.Secure
};
modify?.Invoke(newOptions);
return newOptions;
}
public static string Limit(this string @string, int maxLength)
{
if (@string.Length <= maxLength)
return @string;
return @string.Substring(0, maxLength);
}
public const string LowerAlphabet = "abcdefghijklmnopqrstuvwxyz";
public static string GetString(this Random @random, int lenght, string alphabet = LowerAlphabet)
{
Span<char> chars = stackalloc char[lenght];
for (int i = 0; i < lenght; i++)
chars[i] = alphabet[@random.Next(alphabet.Length)];
return new string(chars);
}
}
}

View File

@ -0,0 +1,34 @@
using Scalar.AspNetCore;
namespace OnlyPrompt.Backend.Utils
{
public struct Identifier
{
public string Slug { get; init; }
public Guid? Id { get; init; }
public Identifier(string slug)
{
this.Slug = slug;
this.Id = null;
}
public Identifier(Guid guid)
{
this.Id = guid;
this.Slug = string.Empty;
}
public static implicit operator Identifier(string slug) => new Identifier(slug);
public static implicit operator Identifier(Guid guid) => new Identifier(guid);
public static bool TryParse(string input, out Identifier identifier)
{
identifier= new Identifier(input);
if (Guid.TryParse(input, out var guid))
identifier = new Identifier(guid);
return true;
}
}
}

View File

@ -0,0 +1,31 @@
using System.Text.RegularExpressions;
namespace OnlyPrompt.Backend.Utils
{
public static class SlugHelper
{
private static readonly Regex InvalidCharacters = new(@"[^a-z0-9\-]", RegexOptions.Compiled);
private static readonly Regex MultipleDashes = new(@"-+", RegexOptions.Compiled);
public static string GenerateSlug(string input, int? maxLength = null)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
var slug = input.ToLowerInvariant().Replace(" ", "-").Replace("_", "-");
slug = InvalidCharacters.Replace(slug, string.Empty);
slug = MultipleDashes.Replace(slug, "-");
slug = slug.Trim('-');
return slug;
}
private const string SuffixChars = "abcdefghijklmnopqrstuvwxyz0123456789";
public static async Task<string> GenerateUniqueSlug(string input, Func<string, Task<bool>> existsFunc, int? maxLenght)
{
var baseSlug = GenerateSlug(input, maxLenght - 9);
var slug = baseSlug;
var suffix = Random.Shared.GetString(8, SuffixChars);
return $"{slug}-{suffix}";
}
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,18 @@
{
"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;"
},
"Jwt": {
"Issuer": "https://onlyprompts.com",
"Audience": "https://onlyprompts.com",
"Key": "TfZi@!CC!b5UoD81gs&%tvY4J0M$p3cI",
"Valid": "3.0:0:0"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 579 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 660 KiB

After

Width:  |  Height:  |  Size: 660 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 659 KiB

After

Width:  |  Height:  |  Size: 659 KiB

View File

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 334 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 283 KiB

View File

Before

Width:  |  Height:  |  Size: 868 KiB

After

Width:  |  Height:  |  Size: 868 KiB

View File

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

6
OnlyPrompt.slnx Normal file
View File

@ -0,0 +1,6 @@
<Solution>
<Project Path="docker-compose.dcproj" Id="ee26a7fb-2dee-47a7-964c-12ef03b1114a">
<Build />
</Project>
<Project Path="OnlyPrompt.Backend/OnlyPrompt.Backend.csproj" />
</Solution>

17
docker-compose.dcproj Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" Sdk="Microsoft.Docker.Sdk">
<PropertyGroup Label="Globals">
<ProjectVersion>2.1</ProjectVersion>
<DockerTargetOS>Linux</DockerTargetOS>
<ProjectGuid>ee26a7fb-2dee-47a7-964c-12ef03b1114a</ProjectGuid>
<DockerLaunchAction>LaunchBrowser</DockerLaunchAction>
<DockerServiceUrl>{Scheme}://localhost:{ServicePort}/scalar</DockerServiceUrl>
<DockerServiceName>documentapi</DockerServiceName>
</PropertyGroup>
<ItemGroup>
<None Include=".env" />
<None Include="docker-compose.debug.yml" />
<None Include="docker-compose.yml" />
<None Include=".dockerignore" />
</ItemGroup>
</Project>

15
docker-compose.debug.yml Normal file
View File

@ -0,0 +1,15 @@
services:
database:
image: postgres:latest
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_USER=${DB_USER}
- POSTGRES_DB=${DB_NAME}
volumes:
- 'database_data:/var/lib/postgresql'
ports:
- "${PORT_PREFIX}3:5432"
volumes:
database_data:

27
docker-compose.yml Normal file
View File

@ -0,0 +1,27 @@
services:
onlyprompt.backend:
image: ${DOCKER_REGISTRY-}onlyprompt
build:
context: .
dockerfile: OnlyPrompt.Backend/Dockerfile
ports:
- "${PORT_PREFIX}1:8080"
- "${PORT_PREFIX}2:8081"
environment:
JWT__ISSUER: "https://onlyprompt.com"
CONNECTIONSTRINGS__DEFAULT: "Include Error Detail=true;User ID=${DB_USER};Password=${DB_PASSWORD};Host=postgres;Port=5432;Database=${DB_NAME};Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
ASPNETCORE_URLS: "http://*:8080"
ASPNETCORE_ENVIRONMENT: "Development"
database:
image: postgres:latest
restart: unless-stopped
environment:
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_USER=${DB_USER}
- POSTGRES_DB=${DB_NAME}
volumes:
- 'database_data:/var/lib/postgresql'
volumes:
database_data: